Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a99b90cc02 | ||
|
|
ff4fde282f | ||
|
|
f327341b00 | ||
|
|
459cc21e3d | ||
|
|
eb947213c8 | ||
|
|
c2095037ee | ||
|
|
93bf3b908a | ||
|
|
38f6a23306 | ||
|
|
12463ad593 | ||
|
|
618e505b8b | ||
|
|
0a3bf6094a | ||
|
|
957e2ecde7 | ||
|
|
9316fed3e3 | ||
|
|
d6fea0c6c2 | ||
|
|
d9a554b4b1 | ||
|
|
b20c10f820 | ||
|
|
49700bc7cf | ||
|
|
fcdcc8e081 | ||
|
|
de64e9768a | ||
|
|
f9137f0463 | ||
|
|
798ac814a9 | ||
|
|
6913e9ce16 | ||
|
|
9aca3b2627 | ||
|
|
0cb7007429 | ||
|
|
bcd347e092 | ||
|
|
9c50de521e | ||
|
|
da00a850ec | ||
|
|
fcedaf578e | ||
|
|
53269f8b1a | ||
|
|
9388eb79d4 | ||
|
|
500f7d2735 | ||
|
|
e5aebaa67d | ||
|
|
88658aceaa | ||
|
|
098f21b47a | ||
|
|
efecf88af7 | ||
|
|
6c74f057eb | ||
|
|
607bb33249 | ||
|
|
3829a871f5 | ||
|
|
981a174e9f | ||
|
|
1154a6d8da | ||
|
|
076b3aaa7b | ||
|
|
58d774ae0c | ||
|
|
7c34abdfbf | ||
|
|
fb51793595 | ||
|
|
fee4686750 | ||
|
|
63f0f7f0b0 | ||
|
|
97419f3ef7 | ||
|
|
b29f2d7b0d | ||
|
|
a0977645a3 | ||
|
|
aa339a73d6 | ||
|
|
65c3ab9ab0 |
@@ -0,0 +1,602 @@
|
||||
name: GoodBuddy Agent Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'agent-v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: agent-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
authorize:
|
||||
name: Authorize Agent release
|
||||
if: >-
|
||||
github.ref_type == 'tag' &&
|
||||
startsWith(github.ref_name, 'agent-v')
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
authorized: ${{ steps.policy.outputs.authorized }}
|
||||
version: ${{ steps.policy.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify immutable Agent release tag
|
||||
id: policy
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(node -p "require('./agent-runtime-lock.json').agentVersion")"
|
||||
expected="agent-v$version"
|
||||
test "$GITHUB_REF_NAME" = "$expected"
|
||||
test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = "tag"
|
||||
test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA"
|
||||
git fetch --no-tags origin main
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main
|
||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build Linux ${{ matrix.arch }} compound package
|
||||
needs: authorize
|
||||
if: needs.authorize.outputs.authorized == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 25
|
||||
environment:
|
||||
name: agent-signing
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.19.0
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Resolve locked native inputs
|
||||
id: inputs
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node - <<'NODE'
|
||||
const { appendFileSync } = require('node:fs')
|
||||
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 runtime = remote.runtimes?.opencode
|
||||
const opencode = runtime?.targets?.[process.env.AGENT_ARCH]
|
||||
if (
|
||||
!node ||
|
||||
!opencode ||
|
||||
!/^[A-Za-z0-9._-]+\.tar\.gz$/u.test(node.archive) ||
|
||||
!/^[a-f0-9]{64}$/u.test(node.sha256) ||
|
||||
!/^[a-z0-9][a-z0-9._-]*$/u.test(opencode.package) ||
|
||||
!/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(opencode.integrity)
|
||||
) {
|
||||
throw new Error('Locked native Agent package inputs are invalid')
|
||||
}
|
||||
const nodeUrl = new URL(node.archive, agent.node.source)
|
||||
if (
|
||||
nodeUrl.protocol !== 'https:' ||
|
||||
nodeUrl.hostname !== 'nodejs.org' ||
|
||||
nodeUrl.username ||
|
||||
nodeUrl.password ||
|
||||
nodeUrl.search ||
|
||||
nodeUrl.hash
|
||||
) {
|
||||
throw new Error('Agent Node runtime must use the official HTTPS origin')
|
||||
}
|
||||
const opencodeArchive =
|
||||
`${opencode.package}-${runtime.version}.tgz`
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
[
|
||||
`node-archive=${node.archive}`,
|
||||
`node-sha256=${node.sha256}`,
|
||||
`node-url=${nodeUrl.href}`,
|
||||
`opencode-package=${opencode.package}`,
|
||||
`opencode-version=${runtime.version}`,
|
||||
`opencode-integrity=${opencode.integrity}`,
|
||||
`opencode-archive=${opencodeArchive}`,
|
||||
`cache-key=${createHash('sha256')
|
||||
.update(`${node.sha256}:${opencode.integrity}`)
|
||||
.digest('hex')}`,
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
NODE
|
||||
|
||||
- name: Cache verified native inputs
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/goodbuddy-agent-release-inputs
|
||||
key: goodbuddy-agent-release-${{ matrix.arch }}-${{ steps.inputs.outputs.cache-key }}
|
||||
|
||||
- name: Acquire and verify native inputs
|
||||
shell: bash
|
||||
env:
|
||||
NODE_ARCHIVE: ${{ steps.inputs.outputs.node-archive }}
|
||||
NODE_SHA256: ${{ steps.inputs.outputs.node-sha256 }}
|
||||
NODE_URL: ${{ steps.inputs.outputs.node-url }}
|
||||
OPENCODE_ARCHIVE: ${{ steps.inputs.outputs.opencode-archive }}
|
||||
OPENCODE_INTEGRITY: ${{ steps.inputs.outputs.opencode-integrity }}
|
||||
OPENCODE_PACKAGE: ${{ steps.inputs.outputs.opencode-package }}
|
||||
OPENCODE_VERSION: ${{ steps.inputs.outputs.opencode-version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
directory="$RUNNER_TEMP/goodbuddy-agent-release-inputs"
|
||||
mkdir -p "$directory"
|
||||
node_archive="$directory/$NODE_ARCHIVE"
|
||||
if [[ ! -f "$node_archive" ]]; then
|
||||
curl --proto '=https' --tlsv1.2 \
|
||||
--fail --silent --show-error --location \
|
||||
--retry 3 --retry-all-errors \
|
||||
--connect-timeout 15 --max-time 300 \
|
||||
--max-filesize 104857600 \
|
||||
"$NODE_URL" --output "$node_archive.partial"
|
||||
mv "$node_archive.partial" "$node_archive"
|
||||
fi
|
||||
printf '%s %s\n' "$NODE_SHA256" "$node_archive" |
|
||||
sha256sum --check --strict -
|
||||
|
||||
opencode_archive="$directory/$OPENCODE_ARCHIVE"
|
||||
if [[ ! -f "$opencode_archive" ]]; then
|
||||
npm pack \
|
||||
--ignore-scripts \
|
||||
--registry https://registry.npmjs.org/ \
|
||||
--pack-destination "$directory" \
|
||||
"$OPENCODE_PACKAGE@$OPENCODE_VERSION"
|
||||
fi
|
||||
node - "$opencode_archive" "$OPENCODE_INTEGRITY" <<'NODE'
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync, statSync } = require('node:fs')
|
||||
const [archive, expected] = process.argv.slice(2)
|
||||
const size = statSync(archive).size
|
||||
if (size <= 0 || size > 536870912) {
|
||||
throw new Error('OpenCode Runtime archive exceeds its safety limit')
|
||||
}
|
||||
const actual = `sha512-${createHash('sha512')
|
||||
.update(readFileSync(archive))
|
||||
.digest('base64')}`
|
||||
if (actual !== expected) {
|
||||
throw new Error('OpenCode Runtime archive integrity mismatch')
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Preflight production GoodBuddy signing identity
|
||||
shell: bash
|
||||
env:
|
||||
GOODBUDDY_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_SIGNING_KEY_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node build/agent-bundle.cjs preflight
|
||||
|
||||
- name: Build production compound Agent package
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
GOODBUDDY_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_SIGNING_KEY_ID }}
|
||||
GOODBUDDY_SIGNING_PRIVATE_KEY: ${{ secrets.GOODBUDDY_SIGNING_PRIVATE_KEY }}
|
||||
NODE_ARCHIVE: ${{ steps.inputs.outputs.node-archive }}
|
||||
OPENCODE_ARCHIVE: ${{ steps.inputs.outputs.opencode-archive }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
output="$RUNNER_TEMP/goodbuddy-agent-output"
|
||||
mkdir -p "$output"
|
||||
node build/agent-package.cjs build \
|
||||
--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/result.json"
|
||||
node - "$output/result.json" "$AGENT_ARCH" "$AGENT_VERSION" <<'NODE'
|
||||
const { readFileSync } = require('node:fs')
|
||||
const [resultPath, architecture, version] = process.argv.slice(2)
|
||||
const result = JSON.parse(readFileSync(resultPath, 'utf8'))
|
||||
if (
|
||||
result.descriptor?.architecture !== architecture ||
|
||||
result.descriptor?.version !== version ||
|
||||
result.archive !==
|
||||
`goodbuddy-agent-${version}-linux-${architecture}.gbagent` ||
|
||||
!/^[a-f0-9]{64}$/u.test(result.sha256)
|
||||
) {
|
||||
throw new Error('Production Agent package result is invalid')
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Upload verified compound package
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: goodbuddy-agent-package-linux-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/goodbuddy-agent-output/goodbuddy-agent-*.gbagent
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
catalog:
|
||||
name: Sign cumulative Agent catalog
|
||||
needs:
|
||||
- authorize
|
||||
- build
|
||||
if: needs.authorize.outputs.authorized == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
environment:
|
||||
name: agent-signing
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.19.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download compound packages
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: goodbuddy-agent-package-linux-*
|
||||
path: dist/agent-release
|
||||
merge-multiple: true
|
||||
|
||||
- name: Fetch previous signed catalog when available
|
||||
id: previous
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
root="https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases"
|
||||
current_catalog_status="$(
|
||||
curl --silent --show-error \
|
||||
--output dist/agent-release/previous.json \
|
||||
--write-out '%{http_code}' \
|
||||
"$root/v$AGENT_VERSION/agent-catalog.json"
|
||||
)"
|
||||
current_signature_status="$(
|
||||
curl --silent --show-error \
|
||||
--output dist/agent-release/previous.sig \
|
||||
--write-out '%{http_code}' \
|
||||
"$root/v$AGENT_VERSION/agent-catalog.sig"
|
||||
)"
|
||||
if [[ "$current_catalog_status" == 200 && "$current_signature_status" == 200 ]]; then
|
||||
echo "arguments=--previous-catalog dist/agent-release/previous.json --previous-signature dist/agent-release/previous.sig" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$current_catalog_status" != 404 || "$current_signature_status" != 404 ]]; then
|
||||
echo "Current Agent catalog objects are inconsistent: $current_catalog_status / $current_signature_status" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f dist/agent-release/previous.json dist/agent-release/previous.sig
|
||||
pointer_status="$(
|
||||
curl --silent --show-error \
|
||||
--output dist/agent-release/previous-pointer.json \
|
||||
--write-out '%{http_code}' \
|
||||
"$root/latest.json"
|
||||
)"
|
||||
if [[ "$pointer_status" == 200 ]]; then
|
||||
readarray -t paths < <(
|
||||
node - dist/agent-release/previous-pointer.json <<'NODE'
|
||||
const { readFileSync } = require('node:fs')
|
||||
const pointer = JSON.parse(readFileSync(process.argv[2], 'utf8'))
|
||||
if (
|
||||
pointer?.formatVersion !== 1 ||
|
||||
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(pointer.version) ||
|
||||
pointer.catalog !== `v${pointer.version}/agent-catalog.json` ||
|
||||
pointer.signature !== `v${pointer.version}/agent-catalog.sig`
|
||||
) {
|
||||
throw new Error('Previous Agent catalog pointer is invalid')
|
||||
}
|
||||
process.stdout.write(`${pointer.catalog}\n${pointer.signature}\n`)
|
||||
NODE
|
||||
)
|
||||
curl --fail --silent --show-error \
|
||||
"$root/${paths[0]}" \
|
||||
--output dist/agent-release/previous.json
|
||||
curl --fail --silent --show-error \
|
||||
"$root/${paths[1]}" \
|
||||
--output dist/agent-release/previous.sig
|
||||
echo "arguments=--previous-catalog dist/agent-release/previous.json --previous-signature dist/agent-release/previous.sig" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "$pointer_status" == 404 ]]; then
|
||||
rm -f dist/agent-release/previous-pointer.json
|
||||
echo "arguments=" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Agent catalog pointer request failed: $pointer_status" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Sign cumulative Agent catalog
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
PREVIOUS_ARGUMENTS: ${{ steps.previous.outputs.arguments }}
|
||||
GOODBUDDY_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_SIGNING_KEY_ID }}
|
||||
GOODBUDDY_SIGNING_PRIVATE_KEY: ${{ secrets.GOODBUDDY_SIGNING_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
generated_at="$(git show -s --format=%cI "$GITHUB_SHA")"
|
||||
read -r -a previous <<< "$PREVIOUS_ARGUMENTS"
|
||||
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" \
|
||||
"${previous[@]}" \
|
||||
--catalog dist/agent-release/agent-catalog.json \
|
||||
--signature dist/agent-release/agent-catalog.sig \
|
||||
--generated-at "$generated_at"
|
||||
node build/agent-catalog.cjs verify \
|
||||
--catalog dist/agent-release/agent-catalog.json \
|
||||
--signature dist/agent-release/agent-catalog.sig
|
||||
|
||||
- name: Upload signed release set
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: goodbuddy-agent-release-${{ needs.authorize.outputs.version }}
|
||||
path: |
|
||||
dist/agent-release/goodbuddy-agent-*.gbagent
|
||||
dist/agent-release/agent-catalog.json
|
||||
dist/agent-release/agent-catalog.sig
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish Agent to GitHub and OSS
|
||||
needs:
|
||||
- authorize
|
||||
- catalog
|
||||
if: needs.authorize.outputs.authorized == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
environment:
|
||||
name: aliyun-oss-release
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.19.0
|
||||
|
||||
- name: Install verification dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Download signed release set
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: goodbuddy-agent-release-${{ needs.authorize.outputs.version }}
|
||||
path: dist/agent-release
|
||||
|
||||
- name: Verify signed release set
|
||||
run: |
|
||||
node build/agent-catalog.cjs verify \
|
||||
--catalog dist/agent-release/agent-catalog.json \
|
||||
--signature dist/agent-release/agent-catalog.sig
|
||||
|
||||
- name: Verify OSS release configuration
|
||||
shell: bash
|
||||
env:
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
OIDC_PROVIDER_ARN: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }}
|
||||
ROLE_ARN: ${{ vars.ALIYUN_ROLE_ARN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$OSS_BUCKET" = "goodbuddy"
|
||||
test "$OSS_ENDPOINT" = "https://oss-cn-beijing.aliyuncs.com"
|
||||
case "$OIDC_PROVIDER_ARN" in
|
||||
acs:ram::*:oidc-provider/*) ;;
|
||||
*) echo "OIDC Provider ARN 无效" >&2; exit 1 ;;
|
||||
esac
|
||||
case "$ROLE_ARN" in
|
||||
acs:ram::*:role/*) ;;
|
||||
*) echo "RAM Role ARN 无效" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Authenticate to Alibaba Cloud
|
||||
uses: aliyun/configure-aliyun-credentials-action@v1
|
||||
with:
|
||||
role-to-assume: ${{ vars.ALIYUN_ROLE_ARN }}
|
||||
oidc-provider-arn: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }}
|
||||
role-session-name: goodbuddy-agent-${{ github.run_id }}
|
||||
role-session-expiration: 3600
|
||||
audience: sts.aliyuncs.com
|
||||
|
||||
- name: Install ossutil
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="2.3.0"
|
||||
archive="$RUNNER_TEMP/ossutil.zip"
|
||||
directory="$RUNNER_TEMP/ossutil"
|
||||
curl --fail --silent --show-error --location \
|
||||
"https://gosspublic.alicdn.com/ossutil/v2/$version/ossutil-$version-linux-amd64.zip" \
|
||||
--output "$archive"
|
||||
mkdir "$directory"
|
||||
unzip -q "$archive" -d "$directory"
|
||||
binary="$(find "$directory" -type f -name ossutil -print -quit)"
|
||||
test -n "$binary"
|
||||
chmod +x "$binary"
|
||||
echo "$(dirname "$binary")" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Upload immutable Agent release to OSS
|
||||
id: oss
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID"
|
||||
export OSS_ACCESS_KEY_SECRET="$ALIBABA_CLOUD_ACCESS_KEY_SECRET"
|
||||
export OSS_SESSION_TOKEN="$ALIBABA_CLOUD_SECURITY_TOKEN"
|
||||
endpoint_host="${OSS_ENDPOINT#https://}"
|
||||
region="${endpoint_host#oss-}"
|
||||
region="${region%.aliyuncs.com}"
|
||||
test "$region" = "cn-beijing"
|
||||
for file in dist/agent-release/*; do
|
||||
name="$(basename "$file")"
|
||||
ossutil cp "$file" \
|
||||
"oss://${OSS_BUCKET}/agent-releases/v${AGENT_VERSION}/${name}" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
--region "$region" \
|
||||
--update
|
||||
done
|
||||
echo "region=$region" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify public immutable OSS packages
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
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"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$root/$name" --output "$RUNNER_TEMP/$name"
|
||||
cmp "dist/agent-release/$name" "$RUNNER_TEMP/$name"
|
||||
done
|
||||
for name in agent-catalog.json agent-catalog.sig; do
|
||||
curl --fail --silent --show-error --location \
|
||||
"$root/$name" --output "$RUNNER_TEMP/immutable-$name"
|
||||
cmp "dist/agent-release/$name" \
|
||||
"$RUNNER_TEMP/immutable-$name"
|
||||
done
|
||||
|
||||
- name: Publish non-Latest GitHub Agent release
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="agent-v$AGENT_VERSION"
|
||||
title="GoodBuddy Agent $AGENT_VERSION"
|
||||
notes="$RUNNER_TEMP/agent-release-notes.md"
|
||||
cat > "$notes" <<EOF
|
||||
GoodBuddy Agent $AGENT_VERSION
|
||||
|
||||
此版本供 GoodBuddy 的“设置 > 平台功能 > 远程项目”手动下载。软件包包含 Agent、固定 Node 和 GoodBuddy 适配的远端 OpenCode Runtime。
|
||||
|
||||
This release is downloaded manually from GoodBuddy Settings > Platform Features > Remote Projects. Each package contains the Agent, pinned Node, and the GoodBuddy-adapted remote OpenCode Runtime.
|
||||
EOF
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
is_draft="$(gh release view "$tag" --json isDraft --jq .isDraft)"
|
||||
if [[ "$is_draft" == true ]]; then
|
||||
gh release edit "$tag" \
|
||||
--title "$title" \
|
||||
--notes-file "$notes" \
|
||||
--latest=false
|
||||
gh release upload "$tag" dist/agent-release/* --clobber
|
||||
gh release edit "$tag" --draft=false --latest=false
|
||||
else
|
||||
verify="$RUNNER_TEMP/github-agent-release"
|
||||
mkdir "$verify"
|
||||
gh release download "$tag" --dir "$verify"
|
||||
for file in dist/agent-release/*; do
|
||||
cmp "$file" "$verify/$(basename "$file")"
|
||||
done
|
||||
fi
|
||||
else
|
||||
gh release create "$tag" \
|
||||
--draft \
|
||||
--verify-tag \
|
||||
--title "$title" \
|
||||
--notes-file "$notes" \
|
||||
--latest=false
|
||||
gh release upload "$tag" dist/agent-release/* --clobber
|
||||
gh release edit "$tag" --draft=false --latest=false
|
||||
fi
|
||||
test "$(gh api repos/mesalogo/goodbuddy/releases/latest --jq .tag_name)" != "$tag"
|
||||
|
||||
- name: Point OSS Agent catalog to published release
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }}
|
||||
OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }}
|
||||
OSS_REGION: ${{ steps.oss.outputs.region }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID"
|
||||
export OSS_ACCESS_KEY_SECRET="$ALIBABA_CLOUD_ACCESS_KEY_SECRET"
|
||||
export OSS_SESSION_TOKEN="$ALIBABA_CLOUD_SECURITY_TOKEN"
|
||||
node - "$AGENT_VERSION" > dist/agent-release/latest.json <<'NODE'
|
||||
const version = process.argv[2]
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
formatVersion: 1,
|
||||
version,
|
||||
catalog: `v${version}/agent-catalog.json`,
|
||||
signature: `v${version}/agent-catalog.sig`
|
||||
}, null, 2)}\n`)
|
||||
NODE
|
||||
ossutil cp dist/agent-release/latest.json \
|
||||
"oss://${OSS_BUCKET}/agent-releases/latest.json" \
|
||||
--endpoint "$OSS_ENDPOINT" \
|
||||
--region "$OSS_REGION" \
|
||||
--force
|
||||
|
||||
- name: Verify public Agent catalogs
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mirror="https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases"
|
||||
github="https://github.com/mesalogo/goodbuddy/releases/download/agent-v$AGENT_VERSION"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$mirror/latest.json" --output "$RUNNER_TEMP/mirror-pointer.json"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$mirror/v$AGENT_VERSION/agent-catalog.json" \
|
||||
--output "$RUNNER_TEMP/mirror.json"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$mirror/v$AGENT_VERSION/agent-catalog.sig" \
|
||||
--output "$RUNNER_TEMP/mirror.sig"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$github/agent-catalog.json" --output "$RUNNER_TEMP/github.json"
|
||||
curl --fail --silent --show-error --location \
|
||||
"$github/agent-catalog.sig" --output "$RUNNER_TEMP/github.sig"
|
||||
cmp dist/agent-release/latest.json "$RUNNER_TEMP/mirror-pointer.json"
|
||||
cmp dist/agent-release/agent-catalog.json "$RUNNER_TEMP/mirror.json"
|
||||
cmp dist/agent-release/agent-catalog.sig "$RUNNER_TEMP/mirror.sig"
|
||||
cmp dist/agent-release/agent-catalog.json "$RUNNER_TEMP/github.json"
|
||||
cmp dist/agent-release/agent-catalog.sig "$RUNNER_TEMP/github.sig"
|
||||
@@ -0,0 +1,313 @@
|
||||
name: GoodBuddy Agents
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/agents.yml'
|
||||
- 'agent-runtime-lock.json'
|
||||
- 'build/agent-bundle.cjs'
|
||||
- 'build/agent-package.cjs'
|
||||
- 'build/agent-ci-bundle.cjs'
|
||||
- 'build/file-hash.cjs'
|
||||
- 'build/remote-runtime-bundle.cjs'
|
||||
- 'build/signing-key-preflight.cjs'
|
||||
- 'package-lock.json'
|
||||
- 'package.json'
|
||||
- 'resources/agent-release-keys.json'
|
||||
- 'remote-runtime-lock.json'
|
||||
- 'src/agent-daemon/**'
|
||||
- 'src/shared/**'
|
||||
- 'tests/agent-bundle.test.ts'
|
||||
- 'tests/agent-package.test.ts'
|
||||
- 'tests/agents-workflow.test.ts'
|
||||
- 'tests/remote-runtime-bundle.test.ts'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '.github/workflows/agents.yml'
|
||||
- 'agent-runtime-lock.json'
|
||||
- 'build/agent-bundle.cjs'
|
||||
- 'build/agent-package.cjs'
|
||||
- 'build/agent-ci-bundle.cjs'
|
||||
- 'build/file-hash.cjs'
|
||||
- 'build/remote-runtime-bundle.cjs'
|
||||
- 'build/signing-key-preflight.cjs'
|
||||
- 'package-lock.json'
|
||||
- 'package.json'
|
||||
- 'resources/agent-release-keys.json'
|
||||
- 'remote-runtime-lock.json'
|
||||
- 'src/agent-daemon/**'
|
||||
- 'src/shared/**'
|
||||
- 'tests/agent-bundle.test.ts'
|
||||
- 'tests/agent-package.test.ts'
|
||||
- 'tests/agents-workflow.test.ts'
|
||||
- 'tests/remote-runtime-bundle.test.ts'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: agents-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate Agent source
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.19.0
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Validate Agent source and workflow
|
||||
run: |
|
||||
npm test -- \
|
||||
tests/agent-bundle.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
|
||||
needs: validate
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: ubuntu-24.04
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.19.0
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Resolve locked official Node runtime
|
||||
id: runtime
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
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}`]
|
||||
if (
|
||||
!target ||
|
||||
!/^[A-Za-z0-9._-]+\.tar\.gz$/u.test(target.archive) ||
|
||||
!/^[a-f0-9]{64}$/u.test(target.sha256)
|
||||
) {
|
||||
throw new Error('Locked Agent Node runtime metadata is invalid')
|
||||
}
|
||||
const url = new URL(target.archive, lock.node.source)
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.hostname !== 'nodejs.org' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
throw new Error('Agent Node runtime must use the official HTTPS origin')
|
||||
}
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
[
|
||||
`archive=${target.archive}`,
|
||||
`sha256=${target.sha256}`,
|
||||
`url=${url.href}`,
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
NODE
|
||||
|
||||
- name: Cache verified Node runtime input
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/goodbuddy-agent-node
|
||||
key: goodbuddy-agent-ci-node-${{ matrix.arch }}-${{ steps.runtime.outputs.sha256 }}
|
||||
|
||||
- name: Acquire and verify locked Node runtime
|
||||
shell: bash
|
||||
env:
|
||||
NODE_ARCHIVE: ${{ steps.runtime.outputs.archive }}
|
||||
NODE_SHA256: ${{ steps.runtime.outputs.sha256 }}
|
||||
NODE_URL: ${{ steps.runtime.outputs.url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
directory="$RUNNER_TEMP/goodbuddy-agent-node"
|
||||
archive="$directory/$NODE_ARCHIVE"
|
||||
mkdir -p "$directory"
|
||||
if [[ ! -f "$archive" ]]; then
|
||||
rm -f "$archive.partial"
|
||||
curl --proto '=https' --tlsv1.2 \
|
||||
--fail --silent --show-error --location \
|
||||
--retry 3 --retry-all-errors \
|
||||
--connect-timeout 15 --max-time 300 \
|
||||
--max-filesize 104857600 \
|
||||
"$NODE_URL" --output "$archive.partial"
|
||||
mv "$archive.partial" "$archive"
|
||||
fi
|
||||
printf '%s %s\n' "$NODE_SHA256" "$archive" |
|
||||
sha256sum --check --strict -
|
||||
|
||||
- name: Resolve locked OpenCode Runtime
|
||||
id: opencode
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
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]
|
||||
if (
|
||||
!runtime ||
|
||||
!target ||
|
||||
!/^[a-z0-9][a-z0-9._-]*$/u.test(target.package) ||
|
||||
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(runtime.version) ||
|
||||
!/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(target.integrity)
|
||||
) {
|
||||
throw new Error('Locked OpenCode Runtime metadata is invalid')
|
||||
}
|
||||
const archive = `${target.package}-${runtime.version}.tgz`
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
[
|
||||
`package=${target.package}`,
|
||||
`version=${runtime.version}`,
|
||||
`integrity=${target.integrity}`,
|
||||
`archive=${archive}`,
|
||||
`cache-key=${hash('sha256').update(target.integrity).digest('hex')}`,
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
NODE
|
||||
|
||||
- name: Cache verified OpenCode Runtime input
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/goodbuddy-opencode-runtime
|
||||
key: goodbuddy-agent-ci-opencode-${{ matrix.arch }}-${{ steps.opencode.outputs.cache-key }}
|
||||
|
||||
- name: Acquire and verify locked OpenCode Runtime
|
||||
shell: bash
|
||||
env:
|
||||
OPENCODE_ARCHIVE: ${{ steps.opencode.outputs.archive }}
|
||||
OPENCODE_INTEGRITY: ${{ steps.opencode.outputs.integrity }}
|
||||
OPENCODE_PACKAGE: ${{ steps.opencode.outputs.package }}
|
||||
OPENCODE_VERSION: ${{ steps.opencode.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
directory="$RUNNER_TEMP/goodbuddy-opencode-runtime"
|
||||
archive="$directory/$OPENCODE_ARCHIVE"
|
||||
mkdir -p "$directory"
|
||||
if [[ ! -f "$archive" ]]; then
|
||||
npm pack \
|
||||
--ignore-scripts \
|
||||
--registry https://registry.npmjs.org/ \
|
||||
--pack-destination "$directory" \
|
||||
"$OPENCODE_PACKAGE@$OPENCODE_VERSION"
|
||||
fi
|
||||
node - "$archive" "$OPENCODE_INTEGRITY" <<'NODE'
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync, statSync } = require('node:fs')
|
||||
const [archive, expected] = process.argv.slice(2)
|
||||
const size = statSync(archive).size
|
||||
if (size <= 0 || size > 536870912) {
|
||||
throw new Error('OpenCode Runtime archive exceeds its safety limit')
|
||||
}
|
||||
const actual = `sha512-${createHash('sha512')
|
||||
.update(readFileSync(archive))
|
||||
.digest('base64')}`
|
||||
if (actual !== expected) {
|
||||
throw new Error('OpenCode Runtime archive integrity mismatch')
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Run native Agent regressions
|
||||
run: |
|
||||
npm test -- \
|
||||
src/agent-daemon/model-bridge.test.ts \
|
||||
src/agent-daemon/lifecycle.test.ts
|
||||
|
||||
- name: Build and verify ephemeral signed compound Agent package twice
|
||||
id: agent
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
NODE_ARCHIVE: ${{ steps.runtime.outputs.archive }}
|
||||
OPENCODE_ARCHIVE: ${{ steps.opencode.outputs.archive }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
directory="$RUNNER_TEMP/goodbuddy-agent-ci"
|
||||
rm -rf "$directory"
|
||||
mkdir -p "$directory"
|
||||
agent_version="$(node -p "require('./agent-runtime-lock.json').agentVersion")"
|
||||
node build/agent-ci-bundle.cjs \
|
||||
--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" |
|
||||
tee "$directory/result.json"
|
||||
node - <<'NODE'
|
||||
const { appendFileSync, readFileSync } = require('node:fs')
|
||||
const result = JSON.parse(
|
||||
readFileSync(
|
||||
`${process.env.RUNNER_TEMP}/goodbuddy-agent-ci/result.json`,
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
if (
|
||||
result.architecture !== process.env.AGENT_ARCH ||
|
||||
!/^[a-f0-9]{64}$/u.test(result.archiveSha256)
|
||||
) {
|
||||
throw new Error('Agent CI result is invalid')
|
||||
}
|
||||
appendFileSync(
|
||||
process.env.GITHUB_OUTPUT,
|
||||
`sha256=${result.archiveSha256}\n`
|
||||
)
|
||||
NODE
|
||||
|
||||
- name: Record verified Agent build
|
||||
shell: bash
|
||||
env:
|
||||
AGENT_ARCH: ${{ matrix.arch }}
|
||||
AGENT_SHA256: ${{ steps.agent.outputs.sha256 }}
|
||||
run: |
|
||||
{
|
||||
echo "### Linux $AGENT_ARCH Agent"
|
||||
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."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.19.0
|
||||
cache: npm
|
||||
|
||||
- name: Verify release tag version
|
||||
@@ -50,7 +50,9 @@ jobs:
|
||||
run: npm run build:bundle
|
||||
|
||||
- name: Upload production bundle
|
||||
if: github.event_name == 'workflow_dispatch' || github.ref_type == 'tag'
|
||||
if: >-
|
||||
github.ref_type == 'tag' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: goodbuddy-production-bundle
|
||||
@@ -58,10 +60,67 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
authorize-release:
|
||||
name: Authorize desktop packaging
|
||||
if: >-
|
||||
github.ref_type == 'tag' ||
|
||||
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
authorized: ${{ steps.policy.outputs.authorized }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify immutable release ref policy
|
||||
id: policy
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
git merge-base --is-ancestor \
|
||||
"$GITHUB_SHA" refs/remotes/origin/main
|
||||
|
||||
case "$GITHUB_EVENT_NAME" in
|
||||
push)
|
||||
test "$GITHUB_REF_TYPE" = "tag"
|
||||
test "$(node -p "String(require(process.env.GITHUB_EVENT_PATH).created)")" = "true"
|
||||
test "$(node -p "String(require(process.env.GITHUB_EVENT_PATH).forced)")" = "false"
|
||||
test "$(node -p "String(require(process.env.GITHUB_EVENT_PATH).deleted)")" = "false"
|
||||
expected="v$(node -p "require('./package.json').version")"
|
||||
test "$GITHUB_REF" = "refs/tags/$expected"
|
||||
test "$GITHUB_REF_NAME" = "$expected"
|
||||
git fetch --no-tags origin \
|
||||
"+refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = "tag"
|
||||
test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA"
|
||||
;;
|
||||
workflow_dispatch)
|
||||
test "$GITHUB_REF" = "refs/heads/main"
|
||||
test "$GITHUB_REF_TYPE" = "branch"
|
||||
test "$GITHUB_SHA" = "$(git rev-parse refs/remotes/origin/main)"
|
||||
;;
|
||||
*)
|
||||
echo "Desktop packaging is limited to immutable version-tag pushes or a manual run of refs/heads/main." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "authorized=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
package:
|
||||
name: ${{ matrix.platform }} ${{ matrix.arch }}
|
||||
if: github.event_name == 'workflow_dispatch' || github.ref_type == 'tag'
|
||||
needs: validate
|
||||
if: >-
|
||||
needs.authorize-release.result == 'success' &&
|
||||
needs.authorize-release.outputs.authorized == 'true'
|
||||
needs:
|
||||
- validate
|
||||
- authorize-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -92,7 +151,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.19.0
|
||||
cache: npm
|
||||
|
||||
- name: Cache packaging toolsets
|
||||
@@ -115,6 +174,15 @@ jobs:
|
||||
name: goodbuddy-production-bundle
|
||||
path: out
|
||||
|
||||
- name: Install RPM packaging tools
|
||||
if: matrix.platform == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends --yes rpm
|
||||
rpmbuild --version
|
||||
|
||||
- name: Resolve macOS signing mode
|
||||
id: macos-signing
|
||||
if: matrix.platform == 'macos'
|
||||
@@ -229,8 +297,13 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Publish GitHub and OSS release
|
||||
if: github.event_name == 'push' && github.ref_type == 'tag'
|
||||
needs: package
|
||||
if: >-
|
||||
github.event_name == 'push' &&
|
||||
github.ref_type == 'tag' &&
|
||||
needs.authorize-release.outputs.authorized == 'true'
|
||||
needs:
|
||||
- authorize-release
|
||||
- package
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 35
|
||||
environment:
|
||||
@@ -247,7 +320,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 24.19.0
|
||||
|
||||
- name: Verify release tag
|
||||
shell: bash
|
||||
|
||||
@@ -4,6 +4,8 @@ dist/
|
||||
coverage/
|
||||
.vite/
|
||||
.runtime-resources/
|
||||
.remote-runtime-resources/
|
||||
.agent-resources/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
|
||||
@@ -8,6 +8,35 @@ GoodBuddy is a secure, cross-platform Electron desktop assistant. It uses
|
||||
Electron, React, TypeScript, Vite, Vitest, and SQLite. User-facing copy is
|
||||
primarily Simplified Chinese.
|
||||
|
||||
## Product Priorities
|
||||
|
||||
Use this order when requirements or implementation choices compete:
|
||||
|
||||
1. Working product functionality.
|
||||
2. Performance and responsiveness.
|
||||
3. Clear, low-friction user experience.
|
||||
4. Simple, conventional, maintainable implementation.
|
||||
5. Security controls proportional to a concrete, realistic risk.
|
||||
|
||||
Follow KISS and YAGNI. Do not add trust tiers, consent ceremonies, durable
|
||||
state machines, protocol phases, cryptographic identities, quotas, recovery
|
||||
machinery, or compatibility layers unless they are needed for an actual user
|
||||
workflow or a demonstrated failure mode. Prefer the normal platform mechanism
|
||||
and the smallest design that works. Test Hosts are test environments; do not
|
||||
turn their workflows into production compliance exercises.
|
||||
|
||||
When the user asks to implement a feature, deliver the complete usable user
|
||||
workflow unless the user explicitly requests a prototype, scaffold, staged
|
||||
landing, or partial implementation. Internal contracts, managers, protocols,
|
||||
disabled UI, unavailable catalog entries, placeholders, and tests around
|
||||
injected fakes do not count as completion when the real product path still
|
||||
cannot be used. If required metadata, dependencies, or integration details are
|
||||
missing, investigate and resolve them as part of the implementation. Do not
|
||||
turn missing implementation work into a product "security gate", permanent
|
||||
unavailable state, or acceptance criterion. If a genuine external blocker
|
||||
cannot be resolved, stop and ask the user rather than presenting the partial
|
||||
work as the implemented feature.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/main`: privileged Electron main process, runtimes, persistence, IPC,
|
||||
@@ -30,10 +59,17 @@ Keep Electron security boundaries intact:
|
||||
## Runtime Behavior
|
||||
|
||||
- Ask mode must remain read-only at the runtime boundary.
|
||||
- Execute mode may use tools only through the existing approval controls.
|
||||
- Execute mode is the user's full authorization for the selected SSH account.
|
||||
It may use all tools, processes, network access, and writable paths available
|
||||
to that account. Do not add T2/T3 trust tiers, separate consent checklists,
|
||||
per-tool approvals, or a second "controlled execution" concept.
|
||||
- Preserve cancellation, timeout, bounded-output, and shutdown behavior.
|
||||
- Treat OpenCode and Continue as untrusted child runtimes. Preserve environment
|
||||
allowlists, sandbox checks, and per-tool approval enforcement.
|
||||
- Keep model/provider credentials in Main, but do not otherwise reduce Execute
|
||||
permissions with extra product policy gates. Keep child-process cleanup
|
||||
straightforward and reliable.
|
||||
- On test Hosts, GoodBuddy may install and start its own test resources and
|
||||
create dedicated work directories. Never delete or overwrite unrelated Host
|
||||
files.
|
||||
- A successful image-model configuration check does not prove generation works.
|
||||
Only an actual generation request verifies the provider path.
|
||||
- Do not fetch provider-returned image URLs. Accept and validate bounded inline
|
||||
@@ -52,6 +88,14 @@ Keep Electron security boundaries intact:
|
||||
- Follow surrounding TypeScript and React patterns.
|
||||
- Reuse installed libraries and shared contracts before adding dependencies.
|
||||
- Keep changes focused. Do not add unrelated refactors or documentation.
|
||||
- Validate the real end-to-end path, not only schemas, mocks, injected test
|
||||
doubles, or isolated infrastructure. A feature is not complete until its
|
||||
normal UI action reaches the production implementation and produces the
|
||||
intended result.
|
||||
- Security work must remain proportional to a concrete risk and must not
|
||||
replace missing product functionality. Use established platform and
|
||||
repository mechanisms instead of inventing extra gates, ceremonies, or
|
||||
disabled states.
|
||||
- Add or update focused tests for behavioral changes and regressions.
|
||||
- After completing any functional change, inspect the affected product,
|
||||
architecture, design, feature, setup, and operational documentation and
|
||||
@@ -89,6 +133,11 @@ Keep Electron security boundaries intact:
|
||||
blocking page state, or an error with an immediate local recovery action.
|
||||
- Do not show the same event both inline and as an application notification.
|
||||
Preserve user input and actionable error context when an operation fails.
|
||||
- Treat renderer bundle budgets as regression guardrails, not UX targets. Do
|
||||
not lazy-load one lightweight peer tab solely to satisfy a byte ceiling.
|
||||
When a genuinely heavy view needs deferred loading, preserve peer
|
||||
interaction semantics, render a non-empty layout-stable local fallback,
|
||||
and test its first interaction rather than only its eventual appearance.
|
||||
|
||||
## Commit Messages and Release Notes
|
||||
|
||||
@@ -133,19 +182,69 @@ Release note: 修复左上角项目设置与消息通道项目设置不一致的
|
||||
|
||||
## Release Packaging
|
||||
|
||||
- Unless the user explicitly requests a local Windows package, never run local
|
||||
Windows packaging or launch probes as part of release preparation. In
|
||||
particular, do not run `npm run portable`, Windows
|
||||
`npm run release:package`, direct `electron-builder` packaging, or launch a
|
||||
locally packaged Windows app before publishing a release. Source validation
|
||||
and the native GitHub Actions jobs are the release authorities.
|
||||
- After a release is published, confirm only that the expected Windows assets
|
||||
exist in the public release metadata. Do not locally rebuild or launch the
|
||||
Windows package, download or hash published installers, attach browser
|
||||
automation to a packaged app, or perform exhaustive per-asset network probes
|
||||
unless the user explicitly asks for that additional validation.
|
||||
- `.github/workflows/packages.yml` is the canonical cross-platform packaging
|
||||
workflow. It validates and builds `out` once, then packages on six native
|
||||
runners: Windows, macOS, and Linux, each for x64 and arm64.
|
||||
- The experimental Linux LoongArch (`loong64`) preview is built separately
|
||||
through `build/loongarch-cross` by following
|
||||
`docs/development/loongarch-preview-build.md`. It is not part of
|
||||
`.github/workflows/packages.yml`, the six-runner GitHub Actions matrix,
|
||||
standard release manifests, GitHub Release asset expectations, or the
|
||||
production update index. Do not add it to those paths unless the user
|
||||
explicitly requests a future production-support migration.
|
||||
- Run the unified packager with
|
||||
`npm run release:package -- --platform <platform> --arch <arch>`. It only
|
||||
packages for the native host and writes to
|
||||
`dist/release/<platform>-<arch>`.
|
||||
- Default deliverables are NSIS and portable ZIP for Windows, DMG and ZIP for
|
||||
macOS, and AppImage and DEB for Linux. Every target includes
|
||||
macOS, and AppImage, DEB, and RPM for Linux. Every target includes
|
||||
`release-manifest.json` with SHA-256 hashes.
|
||||
- `build/build-release.cjs` verifies the unpacked application, `app.asar`,
|
||||
bundled Continue and OpenCode runtimes, executable architecture, and package
|
||||
signatures before atomically replacing a release directory.
|
||||
- All desktop build paths, including local `portable`, package the Agent and
|
||||
remote Runtime version locks plus the public signing-key registry without
|
||||
embedding installable Linux Agent or remote Runtime payloads. Managed SSH is
|
||||
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
|
||||
Agent daemon, pinned Node, and the desktop-maintained compatible OpenCode
|
||||
Runtime. Build both architectures 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
|
||||
the exact Agent source it expects. Do not create a separate Agent repository
|
||||
or long-lived Agent release branch unless the Agent becomes an independently
|
||||
released product serving multiple clients.
|
||||
- `.github/workflows/agents.yml` is the branch and pull-request build
|
||||
verification workflow for GoodBuddy Agent. It must build Linux x64 and
|
||||
arm64 on native runners from the checked-out commit, acquire the locked
|
||||
official Node archive and locked OpenCode npm archive, verify every locked
|
||||
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.
|
||||
- `.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
|
||||
GoodBuddy signing identity for every layer of the compound package and
|
||||
catalog, a non-Latest GitHub Agent Release, and synchronized immutable
|
||||
Beijing OSS objects plus the final signed latest-catalog pointer.
|
||||
- `.github/workflows/packages.yml` and `release:package` build only desktop
|
||||
products. Missing Agent packages, remote Runtime inputs, or GoodBuddy signing
|
||||
credentials must never block an ordinary desktop package or release.
|
||||
- Keep electron-builder invocations on `--publish never`. Main-branch builds
|
||||
run validation and build the production bundle without running the native
|
||||
package matrix. Manual builds upload 30-day GitHub Actions artifacts.
|
||||
@@ -162,13 +261,37 @@ Release note: 修复左上角项目设置与消息通道项目设置不一致的
|
||||
- Tag builds must use `v${package.version}`. The workflow also supports manual
|
||||
dispatch and main-branch changes to release tooling.
|
||||
|
||||
### Agent Release Process
|
||||
|
||||
- Agent releases use annotated `agent-v${agent-runtime-lock.agentVersion}` tags
|
||||
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 catalog job rejects missing architectures, changed bytes for an existing
|
||||
version/architecture identity, invalid prior signatures, and incompatible
|
||||
matrix metadata.
|
||||
- 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
|
||||
`agent-releases/v<agentVersion>/` in Beijing OSS, publish the GitHub Agent
|
||||
Release, then atomically update the single `agent-releases/latest.json`
|
||||
pointer to that version's immutable catalog and signature.
|
||||
- Do not report an Agent release complete until both public package
|
||||
architectures match the signed catalog by size and SHA-256, the GitHub and
|
||||
OSS catalog bytes/signatures are identical, the application can read the
|
||||
selected source, and GitHub's repository Latest tag remains the desktop tag.
|
||||
|
||||
### Tagged Release Process
|
||||
|
||||
Every version-tag release must follow this sequence. A branch-only push does
|
||||
not require release notes.
|
||||
|
||||
1. Confirm that the user wants a release tag and identify the exact release
|
||||
commit and the new `package.json` version.
|
||||
commit and the new `package.json` version. Separately ask whether this
|
||||
release should also receive an experimental LoongArch preview build; never
|
||||
infer that choice from an earlier release.
|
||||
2. Find the latest stable version tag reachable before the release commit and
|
||||
inspect the complete commit and file diff from that tag to the release
|
||||
commit. For the first tagged release, inspect the relevant repository
|
||||
@@ -191,6 +314,10 @@ not require release notes.
|
||||
`npm run release:notes:verify`, the required source validators, the
|
||||
production build, and any native candidate launch probe available on the
|
||||
current host. The six native CI jobs remain the cross-platform authority.
|
||||
If the user requested a LoongArch preview in step 1, build it separately
|
||||
from this exact candidate by following the LoongArch build document, record
|
||||
its size and SHA-256, and keep it outside the standard GitHub/OSS release
|
||||
asset set unless the user separately authorizes publishing it.
|
||||
7. Fetch both remotes immediately before tagging. Inspect any remote branch
|
||||
movement instead of overwriting or silently merging it. Confirm the working
|
||||
tree is clean, the candidate tag is unused locally and remotely, and the
|
||||
@@ -242,13 +369,13 @@ surface together before a new release.
|
||||
It must be able to write immutable version objects and the final latest
|
||||
pointer without granting unrelated administration privileges.
|
||||
- Upload release assets and `site-release.json` under the immutable
|
||||
`releases/<tag>/` prefix first. Verify all 12 installer URLs publicly before
|
||||
`releases/<tag>/` prefix first. Verify all 14 installer URLs publicly before
|
||||
creating or publishing the GitHub Release. Update
|
||||
`releases/latest.json` only after the GitHub Release is public and all prior
|
||||
checks succeeded.
|
||||
- The expected GitHub Release contains 20 assets: 12 installers (two formats
|
||||
for each of six platform/architecture targets), six renamed target
|
||||
manifests, one aggregate `release-manifest.json`, and one `SHA256SUMS`.
|
||||
- The expected GitHub Release contains 22 assets: 14 installers (two formats
|
||||
for each Windows/macOS target and three formats for each Linux target), six
|
||||
renamed target manifests, one aggregate `release-manifest.json`, and one `SHA256SUMS`.
|
||||
`site-release.json` is an OSS publication artifact, not a GitHub Release
|
||||
asset.
|
||||
|
||||
@@ -300,18 +427,18 @@ Do not report a release complete until all of the following are verified:
|
||||
2. The public GitHub Release is non-draft, non-prerelease, marked Latest, and
|
||||
uses the expected tag and title. Its body must exactly match the Markdown
|
||||
generated from the approved packaged bilingual notes.
|
||||
3. The GitHub asset set has exactly the expected 20 names and every asset is
|
||||
3. The GitHub asset set has exactly the expected 22 names and every asset is
|
||||
uploaded. Compare installer sizes and SHA-256 digests with the aggregate
|
||||
manifest and `SHA256SUMS`.
|
||||
4. The Beijing `releases/latest.json` returns HTTP 200, has the expected stable
|
||||
version, exact six targets and 12 installer entries, the trusted Beijing
|
||||
version, exact six targets and 14 installer entries, the trusted Beijing
|
||||
URLs, and the GitHub fallback URL. It must match the immutable
|
||||
`releases/<tag>/site-release.json`.
|
||||
5. All 12 public installer URLs accept `HEAD` without redirects and report the
|
||||
5. All 14 public installer URLs accept `HEAD` without redirects and report the
|
||||
declared size. For small JSON/checksum metadata, prefer a `GET` byte and
|
||||
digest comparison; OSS may gzip JSON responses and omit an uncompressed
|
||||
`Content-Length` on `HEAD`.
|
||||
6. The live website successfully fetches the index and produces the 12 correct
|
||||
6. The live website successfully fetches the index and produces the 14 correct
|
||||
platform/architecture/format links. Exercise the application's actual
|
||||
mirror checker against the public index for all six targets.
|
||||
7. Both remote `main` refs and both peeled tag refs still equal the approved
|
||||
@@ -346,8 +473,13 @@ npm run lint
|
||||
```
|
||||
|
||||
Run `npm run build` for production build changes. Use `npm run portable` only
|
||||
when a current Windows portable package is requested. Gated runtime tests may
|
||||
make paid or external calls, so run them only with explicit authorization.
|
||||
when a current Windows portable package is requested. The user has granted
|
||||
standing authorization in this repository for bounded real text-model calls
|
||||
needed to validate changed product paths; do not ask again for each such call.
|
||||
Keep each validation call minimal, disable tools and attachments where
|
||||
possible, and report the exact call count. This standing authorization does
|
||||
not cover bulk, high-cost, destructive, publishing, messaging, purchasing, or
|
||||
other consequential external actions.
|
||||
|
||||
Before committing or pushing, inspect `git status`, `git diff`, and
|
||||
`git diff --cached`. Do not commit secrets, local databases, logs, generated
|
||||
|
||||
@@ -127,13 +127,83 @@ npm run dist:linux:arm64
|
||||
每个 Linux 架构生成:
|
||||
|
||||
- `deb`:适用于麒麟、统信 UOS 等 Debian 系桌面。
|
||||
- `rpm`:适用于 Fedora、RHEL、Rocky Linux、openEuler 等 RPM 系桌面。
|
||||
- AppImage:适用于免安装验证和便携运行。
|
||||
|
||||
打包产物位于 `dist`。
|
||||
|
||||
以上 `dist*` 与 `portable` 命令用于普通本地桌面打包。它们会携带
|
||||
`agent-runtime-lock.json`、`remote-runtime-lock.json` 与公开的
|
||||
`agent-release-keys.json`,但所有桌面构建路径(包括本地 `portable`)都不会嵌入
|
||||
可安装的远端 Agent 或远端 Runtime payload。没有使用托管 SSH 时,无需下载任何额外
|
||||
组件,缺少远端包也不能阻塞普通桌面打包或发布。
|
||||
|
||||
“设置 > 平台功能 > 远程项目(技术预览)”列出用户数据目录中已经下载或导入的
|
||||
Linux x64/arm64 复合 Agent 包。首次打开和手动刷新会从“关于与更新”所选来源读取
|
||||
小型签名目录,显示本地版本、在线最新兼容版本以及 `有更新 / 已是最新 / 未下载 /
|
||||
校验失败`;此检查不会下载 `.gbagent`。只有用户点击“下载”或“更新到”后才传输包,
|
||||
用户也可导入或导出 `.gbagent` 离线包。
|
||||
Main 在写入缓存前校验签名目录、目录签名、大小、SHA-256、桌面最低版本、Agent 协议、
|
||||
内部 Agent/Runtime 签名和完整 payload。缓存位于 Electron `userData` 下的
|
||||
`remote-components/agent-packages`,不暴露给 Renderer。
|
||||
|
||||
公钥 registry 的信任来自严格 schema、唯一 key ID、规范 Base64、有效 Ed25519 key、
|
||||
production/test 环境和撤销列表,不来自 JSON 空白或换行。读取桌面资源、复合包和 Host
|
||||
已有 registry 时接受等价 UTF-8 JSON 格式;向 Host 写入前统一重新序列化为确定性 LF
|
||||
JSON。签名目录、包描述符、manifest、签名和 payload 的原始字节校验保持不变,包下载
|
||||
继续流式计算 SHA-256。
|
||||
|
||||
“SSH 主机 > 远程运行环境”只有一个按版本事实显示“安装远程环境”“更新远程环境”或
|
||||
“重新安装”的主按钮。次级 SegmentedControl 选择默认且不持久化的“自动”、“Host 下载”
|
||||
或“GoodBuddy 传输”:
|
||||
|
||||
- “Host 下载”使用签名目录中的固定 GitHub/北京镜像 URL、大小、SHA-256、
|
||||
有界重定向链和固定 SSH prepare channel。GoodBuddy 桌面控制面通过该通道发送有界
|
||||
one-shot installer;Host 校验目录绑定的归档大小和 SHA-256 后,由包内固定 Node 解码
|
||||
并运行 installer。
|
||||
- 显式“GoodBuddy 传输”在在线目录不可用,或本地已验证包版本不低于在线候选时优先使用
|
||||
本地包,包括版本更高的离线导入;线上存在更新且可用时仍取得线上新包。需要下载时,
|
||||
同一次用户操作会校验 SHA-256 与签名、缓存并取得 lease,再以有界流式 SFTP 上传一个
|
||||
compound archive 和归档内已验证的 bootstrap Node;约 294 MiB 的整包不会一次读入
|
||||
Main `Buffer`,Host 仍再次校验。
|
||||
- “自动”只在 operation/prepare 前执行 Host capability probe;直连明确可用才选择
|
||||
Host 下载,否则选择 GoodBuddy 传输。显式选择保持有效,prepare、commit 或 adoption
|
||||
失败后不跨 acquisition 自动 fallback。
|
||||
|
||||
两种 acquisition 都把同一签名 compound `.gbagent` 交付到固定 operation staging,随后
|
||||
共用 control-plane `prepare → commit → Agent activate/health → Runtime activate →
|
||||
finalize → cleanup`。commit 终态持久化,通道丢失时只用 `commit-status` 读取恢复而不
|
||||
重新下载或准备包;若终态文件不完整,`commit-status` 会从已认证准备状态幂等完成剩余
|
||||
rename/metadata 发布。prepare 完成后、commit 前快照 Agent/Runtime 五个 metadata 文件;任一 adoption 失败恢复
|
||||
原字节或原缺失状态,已发布 side-by-side payload 不成为 current,确认 adoption 后显式
|
||||
cleanup。Host 在 prepare 解包时完成一次完整 payload 校验;commit 只复核签名 metadata
|
||||
并原子发布,Agent/Runtime 注册后的 health、capabilities 和 prompt 启动不再遍历或哈希
|
||||
完整 payload。cleanup 失败保留为维护重试,不回滚已健康环境。
|
||||
|
||||
进入“主机与远程执行”设置页只读取本地 Host 列表,不逐台连接。用户点击某台 Host 的
|
||||
“刷新版本”后才执行远端版本和直连准备能力探测。项目切换只更新本地选择,不建立 SSH
|
||||
连接;首次实际使用远程 Workspace 或 Runtime 时才建立连接。当前进程会复用该 Host 已确认
|
||||
的 Agent/Runtime identity 和 Agent 连接,Host 编辑或环境更新会定向清空它们。
|
||||
直连安装本身只依赖 SSH,不依赖已有 Agent。显式选择 Host 下载时由实际安装流程重新探测
|
||||
并报告工具、权限、空间或来源不可达等原因;本次操作不会自动改用 GoodBuddy 传输。
|
||||
新建远程项目弹窗也只读本地 Host 验证记录;目录浏览或保存才连接所选 Host。Host 卡片
|
||||
在版本相同时仍允许显式重装;损坏的 GoodBuddy-owned 同 digest 目录会先隔离、替换并在
|
||||
失败时恢复。直连 bootstrap 使用固定短命令 `exec sh -s`,脚本和有界输入经 stdin 发送,
|
||||
避免 SSH command 长度限制。
|
||||
|
||||
新增 Host 只保存并探测,不提供自动安装开关;只有用户明确点击主按钮后才传输完整包。现有
|
||||
package format v1 归档已经包含固定 Agent 和 Node,可直接用于 Host 直连;归档无需携带
|
||||
`agent/lib/package-installer.cjs`,签名目录不提供也不检查额外的 bootstrap 能力元数据。
|
||||
首次 bootstrap 不依赖已安装 Agent daemon。目标契约见
|
||||
[SSH Host 远程环境准备与直连下载设计](docs/architecture/remote-host-environment-provisioning-design.md)。
|
||||
|
||||
删除 Host 或远程项目属于纯本地管理操作。Host 确认框列出全部关联项目记录并级联清理
|
||||
本地数据库,但不连接 Host、不检查远端目录,也不删除远端文件;Host 设置文件和项目事务
|
||||
的普通失败会回滚已写入的一侧。
|
||||
|
||||
## Runtime 资源
|
||||
|
||||
发布包会携带经过版本与完整性校验的 OpenCode、Continue 和 DSH 插件安装 Runtime:
|
||||
桌面包会携带经过版本与完整性校验的 OpenCode、Continue 和 DSH 插件安装 Runtime:
|
||||
|
||||
- OpenCode 平台二进制来自 `.runtime-resources/<arch>`。
|
||||
- Continue Runtime 来自锁定版本的 `@continuedev/cli`。
|
||||
@@ -143,12 +213,133 @@ npm run dist:linux:arm64
|
||||
|
||||
跨架构打包前,确认目标架构的 OpenCode 资源已经准备完成。不要用其他架构的二进制替代目标资源。
|
||||
|
||||
## GoodBuddy Agent 工件
|
||||
|
||||
面向用户发布的是按远端 Host 架构区分的复合 `.gbagent`,每个包同时包含签名
|
||||
Agent、固定 Node 和当前桌面源码维护并适配的签名 OpenCode Runtime。Runtime 不建立
|
||||
独立的用户版本流。远程直连所需的有界 one-shot installer 由桌面控制面打包并通过固定
|
||||
SSH prepare channel 发送,不是 `.gbagent` 的必需条目。Host 按签名目录校验完整归档
|
||||
大小和 SHA-256 后,包内固定 Node 解码并运行 installer。两种 acquisition 都在固定
|
||||
operation staging 中执行同一 prepare/commit;commit 终态持久化,`commit-status`
|
||||
可读取终态或从已认证准备状态幂等完成中断的发布。Agent/Runtime adoption 使用 commit 前五个 metadata 文件的原字节
|
||||
或原缺失状态回滚,已发布 side-by-side payload 不会因此成为 current。生产构建命令为:
|
||||
|
||||
```bash
|
||||
node build/agent-package.cjs build \
|
||||
--arch <x64|arm64> \
|
||||
--minimum-desktop-version <desktop-version> \
|
||||
--node-archive <locked-node-runtime.tar.gz> \
|
||||
--runtime-archive <locked-opencode-package.tgz> \
|
||||
--output goodbuddy-agent-<agent-version>-linux-<arch>.gbagent
|
||||
```
|
||||
|
||||
底层 `agent:build/import/verify` 与 `remote-runtime:build/import/verify` 命令继续用于
|
||||
开发、签名和复合包组装验证;其 `.agent-resources` 与
|
||||
`.remote-runtime-resources` 输出都是不提交的生成内容,也不会被任何桌面打包命令发现
|
||||
或嵌入。
|
||||
|
||||
### Agent 原生 CI 策略
|
||||
|
||||
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
|
||||
地址与 digest,并从 `remote-runtime-lock.json` 解析固定 OpenCode npm 包与 integrity;
|
||||
下载后再次校验,随后使用仅存在于进程内的临时 Ed25519 测试 key 构建两次,完成内外层
|
||||
测试签名验证、Agent 原生启动 smoke 和确定性 `.gbagent` 对比。
|
||||
|
||||
该验证 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
|
||||
与北京 OSS。该 Environment 只保护一组 GoodBuddy 通用发布变量与 Secret:
|
||||
|
||||
- `GOODBUDDY_SIGNING_KEY_ID` /
|
||||
`GOODBUDDY_SIGNING_PRIVATE_KEY`
|
||||
|
||||
该 GoodBuddy 身份统一签署内部 Agent、固定 Runtime manifest、外层 `.gbagent`
|
||||
描述符和累计目录,各层通过既有签名域区分用途,不再要求内部 Runtime 单独配置生产
|
||||
身份。私钥只注入签名步骤;预检只读取 key ID 并确认公钥已在
|
||||
`resources/agent-release-keys.json` 注册为 production 且未撤销。
|
||||
|
||||
累计目录使用 `agent-catalog.json` 与 `agent-catalog.sig`。每个条目绑定 Agent 版本、
|
||||
最低桌面版本、Agent 协议、远端 OpenCode 版本/digest、架构、文件名、大小、SHA-256
|
||||
以及固定 GitHub/OSS URL。同一版本和架构的字节不可改变;目录最多保留 200 个条目。
|
||||
OSS 先写 `agent-releases/v<version>/` 不可变对象,GitHub Release 公开且验证后才更新
|
||||
单一 `agent-releases/latest.json` 指针。该指针只引用同一不可变版本目录中的
|
||||
`agent-catalog.json` 与 `agent-catalog.sig`,避免客户端观察到跨版本组合。Agent Release 必须使用
|
||||
`--latest=false`,不得改变桌面 Release 的 Latest 标记。
|
||||
|
||||
### 远程 OpenCode Runtime 基础
|
||||
|
||||
`remote-runtime-lock.json` 独立锁定首个远程 Runtime:OpenCode 1.18.9 的 Linux x64
|
||||
baseline 与 arm64 官方包 integrity、`bin/opencode` 入口和固定 `acp` 参数。它不同于
|
||||
`agent-runtime-lock.json`,后者锁定 Agent 自带 Node 以及用于 Linux `SO_PEERCRED`
|
||||
的 Koffi 版本。Agent bundle 将 Koffi loader 作为 external module,并只携带目标
|
||||
Linux 架构的 glibc/musl 原生 binding;构建与导入都会校验其 ELF 架构和 MIT 许可证。
|
||||
|
||||
远程 Runtime 工件由以下命令独立管理:
|
||||
|
||||
```bash
|
||||
# 在对应 Linux 原生 Runner 上从 lock 指定的 npm 归档构建并签名
|
||||
npm run remote-runtime:build -- --arch <x64|arm64> \
|
||||
--runtime-archive <locked-opencode-package.tgz>
|
||||
|
||||
# 为复合 Agent 包组装导入并完整验证签名归档
|
||||
npm run remote-runtime:import -- --arch x64 --archive <runtime-x64.tar>
|
||||
npm run remote-runtime:import -- --arch arm64 --archive <runtime-arm64.tar>
|
||||
```
|
||||
|
||||
默认生成目录为 `.remote-runtime-resources/linux-<arch>/opencode/<digest>`,
|
||||
不提交到仓库。生产 workflow 从 `remote-runtime-lock.json` 解析固定包名、版本和
|
||||
integrity,以 `--ignore-scripts` 从官方 npm registry 获取归档;bundle 工具在签名前
|
||||
再次验证归档 SHA-512、包名、版本和 ELF 架构。
|
||||
|
||||
源码已经实现 Runtime bundle build/import/verify、Daemon 侧
|
||||
manifest/Ed25519/payload/ELF/lock 校验、digest registry、ACP v3、直接进程 ownership、
|
||||
Ask 的固定 `bwrap` 只读 profile 和 `runtime/model-bridge` v1。Agent 通过私有 Unix socket
|
||||
按需 detached 启动,不依赖 systemd、D-Bus 或 Linger。Execute 直接以所选 SSH 账号权限
|
||||
启动签名 Runtime;Ask 才使用只读 bubblewrap。
|
||||
|
||||
OpenCode 通过已签名 Agent helper 和每次 Prompt 的私有 Unix socket 使用 Main-only 模型
|
||||
网关;Provider URL、API Key 和真实 Provider 认证头不进入远端。helper 的 loopback
|
||||
HTTP 入口使用每个进程随机生成的路径 capability,Anthropic/OpenAI SDK 需要的固定非秘密
|
||||
本地兼容标记由 helper 核对后丢弃。GoodBuddy 自己维护会话标题,所以生成的 OpenCode
|
||||
配置必须禁用 title Agent;工具循环只使用 build Agent 的模型轮次。
|
||||
|
||||
Ask 模式的 ACP 权限中介只可为原生 `read` 选择 `allow_once`;其他工具种类和
|
||||
`allow_always` 请求必须拒绝,Workspace 的真正只读边界仍由 `bwrap` 执行。Unix 模型桥
|
||||
broker 必须按 socket 当前已缓冲字节增量读取长度帧,不能等待 `read(remaining)` 一次返回
|
||||
完整大响应。Agent 传输或权限实现变化后,除了普通测试,还要在原生 Linux 上运行包含
|
||||
至少 256 KiB 响应的模型桥回归。
|
||||
|
||||
`agent-runtime-lock.json` 维护独立于桌面应用的 Agent 版本,
|
||||
`remote-runtime-lock.json` 维护 OpenCode Runtime 版本。Agent 内容变化时必须更新
|
||||
`agentVersion`;Runtime 更新时使用 Runtime 自身版本。签名 manifest 或 bundle digest
|
||||
唯一标识对应版本的精确工件。变更后必须重新构建、签名并导入 Linux x64/arm64 两套工件;
|
||||
旧版本或旧 digest 会按设计校验失败。不要手工把本机 `.runtime-resources` 当作远程 bundle。
|
||||
|
||||
桌面应用更新后不会在项目切换时下载或安装 Agent。用户先在 SSH Host 的远程运行环境
|
||||
卡片显式选择直连下载或 GoodBuddy 传输。托管 SSH 项目只保存 Host ID、远端路径、
|
||||
Runtime 选择和默认模式;创建或保存时验证 Host 当前环境,普通项目切换只读取这些本地
|
||||
配置。首次实际使用 Workspace 或 Runtime 时解析 Host current identity;同一进程内后续
|
||||
项目复用已确认的 Agent/Runtime identity 和 Agent 连接。该流程不取得安装包、不重读完整
|
||||
payload,也不发布组件。
|
||||
|
||||
两条手动准备路径都支持阶段进度和取消;成功后定向失效该 Host 的连接与 Runtime 缓存,
|
||||
引用项目下一次打开自然使用新的 current 环境。验收应覆盖两种准备方式与项目当前环境
|
||||
验证入口,并确认失败或取消不会删除 Host 配置、凭据、项目设置、Workspace 文件、全局
|
||||
可信元数据或仍可用的旧安装。
|
||||
|
||||
## 跨平台 CI 与 GitHub Release
|
||||
|
||||
`.github/workflows/packages.yml` 是统一发布工作流。它先验证并生成一次
|
||||
`out` 生产 bundle,再在六个原生 Runner 上分别打包 Windows、macOS 和
|
||||
Linux 的 `x64`、`arm64` 版本。生产 bundle 仅作为短期 Actions artifact
|
||||
供打包任务复用,不会上传到 GitHub Release。
|
||||
供打包任务复用,不会上传到 GitHub Release。该工作流不构建、下载、签名或嵌入
|
||||
远端 Agent/OpenCode 复合包,也不依赖 `agent-signing` Environment;远端包缺失或
|
||||
Agent 发布失败不会阻塞任何桌面目标。
|
||||
|
||||
本地构建单个平台目标:
|
||||
|
||||
@@ -156,6 +347,12 @@ Linux 的 `x64`、`arm64` 版本。生产 bundle 仅作为短期 Actions artifac
|
||||
npm run release:package -- --platform <windows|macos|linux> --arch <x64|arm64>
|
||||
```
|
||||
|
||||
`release:package` 仅构建桌面产品。基础配置统一携带
|
||||
`agent-release-keys.json`、`agent-runtime-lock.json` 与 `remote-runtime-lock.json`
|
||||
供 Main 校验独立下载的复合包,但 release 配置不增加 `.agent-resources` 或
|
||||
`.remote-runtime-resources`。发布校验继续检查桌面自身的 OpenCode、Continue 与
|
||||
DeepSeek Harness Runtime、`app.asar`、目标架构和安装包签名。
|
||||
|
||||
在 macOS 上明确生成未签名、未公证的开发验证包:
|
||||
|
||||
```bash
|
||||
@@ -163,7 +360,8 @@ npm run release:package -- --platform macos --arch <x64|arm64> --unsigned
|
||||
```
|
||||
|
||||
默认发布产物为 Windows 的 NSIS 安装包与 portable ZIP、macOS 的 DMG 与
|
||||
ZIP,以及 Linux 的 AppImage 与 DEB。Windows portable ZIP 解压后可直接
|
||||
ZIP,以及 Linux 的 AppImage、DEB 与 RPM。Linux 原生 Runner 必须安装
|
||||
`rpm`/`rpmbuild` 工具后再调用 electron-builder。Windows portable ZIP 解压后可直接
|
||||
运行 `GoodBuddy.exe`,并包含启用便携数据目录的
|
||||
`.goodbuddy-portable.json`。每个目标目录都包含带文件大小和 SHA-256 的
|
||||
`release-manifest.json`。
|
||||
@@ -176,8 +374,8 @@ ZIP,以及 Linux 的 AppImage 与 DEB。Windows portable ZIP 解压后可直
|
||||
全部目标成功后,才会严格校验并聚合所有平台产物,生成按平台重命名的
|
||||
manifests、总 `release-manifest.json` 和 `SHA256SUMS`。随后工作流通过
|
||||
GitHub OIDC 获取短期 STS 凭据,将发布资产和 `site-release.json` 上传到
|
||||
北京 OSS 的不可变版本目录,并公开校验 12 个安装包。验证通过后才创建或
|
||||
更新 draft GitHub Release、上传 20 个 Release 资产并正式发布,最后原子
|
||||
北京 OSS 的不可变版本目录,并公开校验 14 个安装包。验证通过后才创建或
|
||||
更新 draft GitHub Release、上传 22 个 Release 资产并正式发布,最后原子
|
||||
切换官网 `latest.json`。任一步失败都不会提前切换官网最新版本。
|
||||
|
||||
同一标签重跑时,工作流会根据 `resources/release-notes.json` 重新生成并
|
||||
@@ -185,6 +383,19 @@ GitHub OIDC 获取短期 STS 凭据,将发布资产和 `site-release.json` 上
|
||||
附件。若源码与发布元数据未变化,应修正外部配置后重跑同一不可变标签;
|
||||
只有必须修改代码或元数据时才递增版本并创建新标签。
|
||||
|
||||
Agent 发布使用另一套标签和 workflow:
|
||||
|
||||
```bash
|
||||
agent_tag="agent-v$(node -p "require('./agent-runtime-lock.json').agentVersion")"
|
||||
git tag -a "$agent_tag" -m "GoodBuddy Agent $(node -p "require('./agent-runtime-lock.json').agentVersion")"
|
||||
```
|
||||
|
||||
实际创建或推送前同样必须获得用户确认。`.github/workflows/agent-release.yml`
|
||||
原生构建两个 `.gbagent`、签名累计目录、上传
|
||||
`agent-releases/v<agent-version>/`,创建 `--latest=false` GitHub Release,再切换
|
||||
OSS 签名目录指针。生产发布需要 `agent-signing` 与 `aliyun-oss-release` 两个受保护
|
||||
Environment;普通分支/PR 的 `agents.yml` 不使用其中任何 Secret。
|
||||
|
||||
中英文发布说明统一维护在 `resources/release-notes.json`。新版本按“本次
|
||||
亮点 / Highlights”“功能更新 / Features”“问题修复 / Bug Fixes”“使用前
|
||||
请留意 / Before You Start”四段组织,应用首次启动弹窗与 GitHub Release
|
||||
@@ -316,3 +527,15 @@ Windows 与 POSIX 构建输出。构建专用 module manifest 只记录项目相
|
||||
绝对路径;检查成功后会删除该诊断文件,检查失败时保留以便排查。标准 Vite
|
||||
manifest 会保留在输出中。预算以干净生产构建为基线并保留有限余量;若业务确需
|
||||
提高预算,必须先检查 manifest 闭包和产物差异,不能只为通过 CI 调大数值。
|
||||
|
||||
Bundle 大小是发现意外依赖和持续膨胀的粗粒度工程护栏,不是用户体验指标,也不能替代
|
||||
Electron 中的实际交互验证。Settings 的硬上限以 2026-08-23 验证构建
|
||||
`648.78 kB raw / 97.79 kB gzip` 为基线,增加约 15% 评审余量后取整为
|
||||
`750 kB raw / 115 kB gzip`。该上限用于拦截显著增长,不要求实现长期贴线运行。
|
||||
|
||||
调整 Renderer 拆包时遵守以下优先级:
|
||||
|
||||
1. 同级页签的交互一致性、首个绘制帧内反馈和输入保留优先于包体数字。
|
||||
2. 不得仅为通过预算,把一个原本轻量的同级页签单独改成首次点击才加载。
|
||||
3. 确需动态加载重型功能时,必须提供非空、无布局跳变的局部状态,并增加首次交互测试。
|
||||
4. 修改预算必须记录干净生产构建基线、增量闭包差异和 Electron 实测结果。
|
||||
|
||||
+11
-7
@@ -13,22 +13,25 @@
|
||||
|
||||
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
|
||||
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
|
||||
- [x] **Projects 与独立对话**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;项目选择器区分本地项目与远程通道,并在展开后显示本地目录或通道来源等辨认信息。
|
||||
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,并显示对应目录或来源。
|
||||
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
|
||||
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式和受控 Mermaid 图表;大图可缩放、拖动或查看源码,失败时保留原始图表代码。
|
||||
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
|
||||
- [ ] **通用助手工作栏与执行空间**(规划中):保留 Task Center 作为 Task 的单例索引,并把监督、Runtime、终端、进程、工作区、浏览器、成果和上下文作为始终可访问的应用级能力;除 Task Center 外的可绑定能力由用户选择跟随或固定目标,并逐步支持静态安全 HTML 预览、本机/SSH 执行空间和远程 Agent Runtime。详见 [Feature PRD](./docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。
|
||||
- [ ] **通用助手工作栏与执行空间**(规划中):保留 Task Center 作为 Task 的单例索引,并把监督、Runtime、终端、进程、工作区、浏览器和成果作为始终可访问的应用级能力;除 Task Center 外的可绑定能力由用户选择跟随或固定目标,并逐步支持静态安全 HTML 预览、本机/SSH 执行空间和远程 Agent Runtime。附件与知识库继续由会话输入区管理,未来的记忆与历史执行上下文归入关联 Task。详见 [Feature PRD](./docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。
|
||||
|
||||
### Agent Runtime 与模型连接
|
||||
|
||||
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。
|
||||
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Main 通过签名 Agent、私有 Unix socket、ACP v3、持久恢复 cursor 和 Main-only 模型桥维持长任务、重连与会话恢复,模型凭据不进入远端。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 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 可并行运行多个项目和对话;活动请求沿精确 resume/replay 恢复,controller 换代遗留的空闲 binding 只在 takeover 已证明且没有活动请求时换新 channel epoch 并恢复原 ACP session。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 已更新为 Agent 0.11.8。
|
||||
- [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 校验。通道丢失时 `commit-status` 读取终态或从已认证准备状态幂等完成中断发布;组件 adoption 失败恢复 Agent/Runtime 五个 metadata 文件的原字节或原缺失状态,已健康环境的 cleanup 失败不触发回滚。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/architecture/remote-host-environment-provisioning-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 运行已启用且受边界约束的工具。
|
||||
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权。
|
||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
|
||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。
|
||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。
|
||||
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
|
||||
- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。密钥随对应模型连接保存,修改服务地址或临时切换为无需认证不会要求重新输入;只有用户显式清除凭据或删除连接时才移除。
|
||||
- [x] **OpenCode Runtime 定制**:GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 Resources;Tools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用有总时限的原生 Compact;并发外部 Server 对话的提问使用请求级公开 ID 映射,回答不会串到其他会话。外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。
|
||||
@@ -51,7 +54,7 @@
|
||||
- [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。
|
||||
- [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。
|
||||
- [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;创建、保存和评论结果使用统一应用通知。
|
||||
- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。
|
||||
- [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。
|
||||
- [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。
|
||||
|
||||
@@ -79,20 +82,21 @@
|
||||
- [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、ZIP 或本地目录导入、切换和删除。
|
||||
- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。
|
||||
- [x] **版本检查与镜像节点**:在“关于与更新”中选择 GitHub(默认)或镜像节点;手动检查、启动时检查和下载页使用同一选择,并只读取固定可信的发布索引,不自动下载或安装。
|
||||
- [x] **应用内反馈**:在“关于与更新”中直接提交问题、建议或体验反馈,支持可选邮箱和单张截图;失败后保留草稿并按同一请求编号重试,不自动附加对话、日志、文件、配置或凭据。
|
||||
- [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。
|
||||
|
||||
### 开源、构建与发布
|
||||
|
||||
- [x] **0BSD 开源许可**:原创代码可自由使用、复制、修改、分发和商用;第三方组件和资源仍遵循各自许可证。
|
||||
- [x] **可复现依赖安装与源码构建**:使用锁定依赖、Node.js 24 和统一的测试、类型检查、Lint、生产构建命令。
|
||||
- [x] **六平台原生发布矩阵**:Windows、macOS、Linux 的 `x64`、`arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希。
|
||||
- [x] **六平台原生发布矩阵**:Windows、macOS、Linux 的 `x64`、`arm64` 目标由原生 Runner 构建,并提供发布清单和 SHA-256 哈希;Linux 同时生成 AppImage、DEB 和 RPM。
|
||||
|
||||
### 开放接口、团队协作与远程执行
|
||||
|
||||
- [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。
|
||||
- [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。
|
||||
- [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。
|
||||
- [ ] **SSH 主机与远程执行空间**(规划中):管理 Host Key 固定和 Main-only 加密凭据,通过版本化远程 Helper 提供有界工作区、终端、受管进程和 Agent Runtime;远程执行继续遵循 Ask/Execute、审批、取消、超时和审计边界。
|
||||
- [ ] **SSH 主机与远程执行空间发布验收**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP、Main-only 模型桥、Ask 只读、Execute 完整账号权限、取消、detached Agent 重连和 release-only 双架构资源校验已经接线。Windows 到 Linux x64 的既有真实 Host provider-free 安装、metadata-only Portable 复用与项目保存、桥接、断线恢复、输出重放、同 Session 续接与清理已经通过;Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 的真实模型 Ask 验收也已通过,单次用户操作只执行一个原生 read,两个 build 模型轮次均完成交付,没有 title 轮次或请求重放。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包,但仍需公开核对当前 Linux x64/arm64 正式签名工件矩阵,并完成 GitHub/北京镜像、双架构、取消和离线 GoodBuddy 传输的真实 Host 验收;门槛未满足时不宣称该路径已发布或通过验收。
|
||||
- [ ] **多云远程沙盒 Agent**(规划中):通过云厂商 API 和 SSH Agent 管理专用 Linux 沙盒;凭据留在 Main 进程,高风险控制面操作单独确认。
|
||||
|
||||
## 规划原则
|
||||
|
||||
+6
-1
@@ -15,6 +15,7 @@ A secure, cross-platform, local-first desktop AI assistant and Agent workspace.
|
||||
- **Knowledge workspace**: Import files, folders, and web pages, then search them with full-text, phrase, vector, and graph retrieval.
|
||||
- **Work management**: Organize projects, conversations, tasks, activity, artifacts, memory, Magic Notes, and Smart Heartbeat.
|
||||
- **Remote channels**: Connect WeChat ClawBot, WeCom, and DingTalk with separate remote sessions for each sender.
|
||||
- **Managed SSH projects (technical preview)**: Remote support is off by default and does not affect ordinary desktop use. After enabling it, manually download the independently signed package for the Linux Host architecture under Settings > Platform Features > Remote Projects, or import/export an offline `.gbagent`. Each package contains the Agent, pinned Node, and the GoodBuddy-adapted remote OpenCode Runtime; online downloads follow the GitHub or mirror source selected under About & Updates.
|
||||
- **Desktop context**: Add selected files, screenshots, application windows, clipboard content, and voice.
|
||||
- **Offline speech**: Use local SenseVoice, Paraformer, and Whisper models.
|
||||
- **Rich responses**: Render Markdown, LaTeX, and controlled Mermaid diagrams.
|
||||
@@ -39,7 +40,11 @@ Download a build from [GitHub Releases](https://github.com/mesalogo/goodbuddy/re
|
||||
| --- | --- | --- |
|
||||
| Windows | `x64`, `arm64` | NSIS, portable ZIP |
|
||||
| macOS | `x64`, `arm64` | DMG, ZIP |
|
||||
| Linux | `x64`, `arm64` | AppImage, DEB |
|
||||
| Linux | `x64`, `arm64` | AppImage, DEB, RPM |
|
||||
|
||||
Desktop installers do not contain remote Agent payloads. No Agent package is
|
||||
needed for local projects; managed SSH users install one explicitly in
|
||||
Settings.
|
||||
|
||||
Code signing and macOS notarization are not configured yet, so your operating system may display a security warning.
|
||||
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
[English](README.en.md) | 简体中文
|
||||
|
||||
安全、跨平台、本地优先的桌面 AI 助手与 Agent 工作空间。
|
||||
GoodBuddy是一个开箱即用的 AI 生产平台,安全、跨平台、本地优先的桌面 AI 助手与 Agent 工作空间。
|
||||
|
||||
产品官网:https://mesalogo.github.io/goodbuddy/
|
||||
|
||||
五大特点:
|
||||
1. 支持国产信创系统和硬件
|
||||
2. 免注册,不收集个人信息
|
||||
3. 完全开源和免费,友好的OBSD协议
|
||||
4. 不需要联网,纯私有网络也可以用
|
||||
5. 开放生态,内置主流好用的Agent Runtime,目前支持OpenCode、Continue和DeepSeek Harness,让小白用户也可以快速用起来最前沿的Agent工具
|
||||
|
||||

|
||||
|
||||
@@ -11,10 +20,11 @@
|
||||
- **安全执行**:`Ask` 保持只读;`Execute` 仅运行已启用且受边界约束的工具,并保留活动记录。
|
||||
- **本地优先**:会话、任务、成果、记忆、知识库和图谱保存在本地 SQLite;API Key 由系统安全存储加密。
|
||||
- **多 Runtime**:支持直连模型、OpenCode、Continue 和预览版 DeepSeek Harness,统一处理取消、超时、输出限制和进程退出;原生能力清单将 Tools 与 Commands、LSP、Formatters 分开,并显示来源及 Ask/Execute 可用性。内置 OpenCode 提供 Agent、Tool、Command 与原生 Compact,Continue 提供 Rules、Prompt 预设、结构化提问与 GoodBuddy 手动摘要压缩,并明确标记当前版本无法静态发现原生 Tools。
|
||||
- **开放连接**:支持 OpenAI Responses、OpenAI 兼容 Chat Completions、Anthropic Messages、OpenAI Images、Embeddings、跨 Runtime Skills 与自定义 MCP,以及默认关闭、由用户显式开启的 DeepSeek Harness npm 插件市场。MCP 测试可读取有界 Prompt/Resource 元数据,但不会读取 Resource 内容。
|
||||
- **开放连接**:支持 OpenAI Responses、OpenAI 兼容 Chat Completions、Anthropic Messages、OpenAI Images、Embeddings、跨 Runtime Skills 与自定义 MCP,以及默认关闭、由用户显式开启的 DeepSeek Harness npm 插件市场。Anthropic 连接可独立配置协议必填的单次最大输出,不再复用上下文窗口;OpenAI 直连请求不额外设置输出 Token 上限。MCP 测试可读取有界 Prompt/Resource 元数据,但不会读取 Resource 内容。
|
||||
- **知识工作区**:支持文件、目录和网页导入,以及全文、词组、向量和图谱混合检索。
|
||||
- **工作管理**:集中管理 Projects、对话、任务、活动、成果、记忆、魔法笔记和智能心跳。
|
||||
- **工作管理**:集中管理 Projects、对话、任务、活动、成果、记忆、魔法笔记和智能心跳;可从稳定的本地会话复制当前聊天内容并创建互不影响的会话分支。
|
||||
- **远程通道**:支持微信 ClawBot、企业微信和钉钉,每个发送者使用独立远程会话。
|
||||
- **托管 SSH 项目(技术预览)**:远程能力默认关闭且不影响普通桌面使用。添加 Host 只保存并探测,不自动安装;Host 卡片以一个按版本事实命名的主按钮准备环境,次级控件可选默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输。自动模式只在操作开始时择一,失败不跨路径重试;两种方式交付同一签名 compound `.gbagent` 后共用 prepare、commit、Agent/Runtime adoption 与 cleanup。项目创建与切换只使用已经准备好的 Host。GitHub/北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收仍待完成,当前不表示该路径已发布。
|
||||
- **桌面上下文**:可选择文件、截图、应用窗口、剪贴板和语音作为上下文。
|
||||
- **离线语音**:支持 SenseVoice、Paraformer 和 Whisper 本地模型。
|
||||
- **富文本回答**:支持 Markdown、LaTeX 公式和受控 Mermaid 图表。
|
||||
@@ -38,7 +48,10 @@
|
||||
| --- | --- | --- |
|
||||
| Windows | `x64`、`arm64` | NSIS、便携 ZIP |
|
||||
| macOS | `x64`、`arm64` | DMG、ZIP |
|
||||
| Linux | `x64`、`arm64` | AppImage、DEB |
|
||||
| Linux | `x64`、`arm64` | AppImage、DEB、RPM |
|
||||
|
||||
桌面安装包不包含远端 Agent payload。仅使用本地项目时无需下载任何
|
||||
Agent 包;托管 SSH 用户可在设置中按需管理。
|
||||
|
||||
当前尚未配置代码签名和 macOS notarization,系统可能显示安全提示。
|
||||
|
||||
@@ -74,11 +87,16 @@ npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## 为什么选择GoodBuddy
|
||||
|
||||
<img width="1024" height="1536" alt="6dd45371542dea9fb2d99474387708f5" src="https://github.com/user-attachments/assets/ca608646-ea48-4039-9177-d862be8396ac" />
|
||||
|
||||
|
||||
## 社区交流
|
||||
|
||||
目前采用微信群的方式供大家高效交流,大家可以微信扫码进入社区群:
|
||||
|
||||
<img width="1279" height="1306" alt="056bfac87840a95547f9805a8122fc2d" src="https://github.com/user-attachments/assets/b3342635-60a1-484c-959c-f90ba3c39d69" />
|
||||
<img width="1250" height="1743" alt="6ae8664c83b2f182aa81ae94afe691fe" src="https://github.com/user-attachments/assets/10bb7ac2-0bc1-41a9-978d-c2862923fcd6" />
|
||||
|
||||
|
||||
|
||||
|
||||
+66
-20
@@ -283,15 +283,28 @@
|
||||
- 当前导航项和当前会话必须同时使用至少三种信号中的两种:强调背景、可见边框、图标或文字强调。浅色主题的当前项优先使用更完整的蓝色选中表面和较高字重。
|
||||
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
|
||||
- 关联 Task 的 Conversation 在行最左侧显示独立展开按钮,父会话行不重复任务标签或数量;
|
||||
展开后的每个 Task 子项使用共享状态点,展开层级只到 Task,不展示 Job、Subjob 或 Run。
|
||||
- 会话标题保持单行且不挤压时间或操作按钮。标题实际溢出时,悬停会话行才在标题的固定裁切
|
||||
区域内平滑横向滑动以展示完整名称;移开后快速复位。未溢出标题不产生位移动效,
|
||||
`prefers-reduced-motion` 下禁用滑动并保留完整标题提示。
|
||||
展开后的每个 Task 子项左侧显示名称,模式、计划和共享状态标记收在右侧,与父会话的
|
||||
更新时间位置一致;展开层级只到 Task,不展示 Job、Subjob 或 Run。
|
||||
- 会话标题保持单行且不挤压更新时间。更新时间默认靠近行右侧;低频的更多操作按钮不常驻
|
||||
占位,只在悬停会话行时替换右侧时间显示。键盘焦点进入会话行或操作菜单展开时,按钮仍
|
||||
保持可见。标题实际溢出时,悬停会话行才在标题的固定裁切区域内平滑横向滑动以展示完整
|
||||
名称;移开后快速复位。未溢出标题不产生位移动效,`prefers-reduced-motion` 下禁用滑动
|
||||
并保留完整标题提示。
|
||||
- 分支会话在侧栏标题前和聊天顶栏标题旁显示同一个横向分叉徽标;徽标通过可访问名称和
|
||||
工具提示说明来源会话,不能只靠图形表达。分支不改成树状会话列表,也不挤压更新时间。
|
||||
- 新建 Task 后可以首次自动展开所属 Conversation;用户手动折叠后保持其选择,后台状态变化
|
||||
只更新状态提示,不强制展开或抢占焦点。
|
||||
- Task 子项复用任务中心的共享状态点:运行中使用脉冲强调色,已完成使用静态成功色,
|
||||
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;旁边同时显示
|
||||
本地化状态文字,不能只靠颜色或动效表达状态。
|
||||
- Task 子项右侧复用任务中心的共享状态标记:运行中使用脉冲强调色,已完成使用带勾成功点,
|
||||
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时
|
||||
显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠
|
||||
颜色或动效表达状态。
|
||||
- 左上角品牌区允许用户在“外观”设置中定制品牌名称、中英文副标题和单个 Logo。定制只
|
||||
影响该品牌区,不替换安装包名称、更新协议、Agent 标识或应用正文中的 GoodBuddy 产品
|
||||
身份。副标题按当前界面语言显示,留空时隐藏且不保留空行。
|
||||
- 品牌设置必须提供同布局预览、显式保存、默认 Logo 和整组恢复默认操作。自定义 Logo 仅
|
||||
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
|
||||
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
|
||||
单行截断,不能撑宽或挤压侧栏。
|
||||
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
|
||||
|
||||
### 6.9 助手工作栏
|
||||
@@ -300,7 +313,7 @@
|
||||
自动增删入口。产品契约见
|
||||
[通用助手工作栏与执行空间 PRD](./docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)。
|
||||
|
||||
- 默认固定提供任务中心、上下文、工作区、浏览器和成果五个标准栏目,不根据当前页面或
|
||||
- 默认固定提供任务中心、工作区、浏览器和成果四个标准栏目,不根据当前页面或
|
||||
Runtime 能力自动增删入口。
|
||||
- Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task
|
||||
只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,
|
||||
@@ -308,22 +321,46 @@
|
||||
- 各栏目读取当前会话或项目的对应内容;当前内容不可用时仍保留栏目入口并说明原因。
|
||||
- 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。
|
||||
- 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。
|
||||
- 五个栏目使用稳定标签并保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。
|
||||
- 四个栏目使用稳定标签并保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。
|
||||
- 下一次请求使用的附件和知识库由会话输入区展示与管理,不在工作栏重复提供“上下文”栏目;
|
||||
记忆功能实现前不展示占位内容,未来的记忆与历史执行上下文归入关联 Task 的任务中心详情。
|
||||
- 徽标可以提示未解决意见、等待审批、失败或连接状态,但不能成为唯一状态信号,也不能无条件抢占当前栏目。
|
||||
- 宽窗口可停靠并调整宽度,中等窗口可停靠或覆盖,窄窗口使用全屏或接近全屏抽屉;所有尺寸下均须保留全部栏目入口。
|
||||
- 覆盖和抽屉布局打开后焦点进入工作栏并限制在其中,Escape 或背景点击关闭,关闭后焦点
|
||||
返回触发按钮;覆盖期间背景内容必须从指针与辅助技术导航中隔离。宽窗口停靠布局不得
|
||||
获得对话框语义或隔离主工作区。
|
||||
- 所有窗口宽度下均停靠在主工作区右侧并参与水平布局回流;展开时压缩主工作区,不能覆盖、
|
||||
遮罩或隔离其内容。分栏计算排除最左侧主导航,主工作区与工作栏采用相同的 `300px`
|
||||
最小宽度;拖动保存两者在可用分栏宽度中的比例,任一侧到达最小值后都不得继续侵占。
|
||||
所有可容纳两侧最小值的尺寸均支持指针和键盘调整,并须保留全部栏目入口。
|
||||
- 工作栏开关固定在窗口关闭按钮正下方,同一个按钮负责展开与收起并持续暴露状态;图标中的
|
||||
箭头方向必须随当前动作切换,收起状态提示展开,展开状态提示收起。按钮保持普通图标按钮
|
||||
视觉,不通过持续高亮表达展开状态。工作栏内部不再重复关闭按钮;当前会话没有 Task 时,
|
||||
不得为了放置该开关而保留一条空上下文栏。
|
||||
- 停靠工作栏不得获得对话框语义、焦点陷阱或背景遮罩。关闭后焦点返回同一个工作栏开关;
|
||||
主工作区与工作栏在展开期间都保持可达。
|
||||
- 对话消息流、输入区和输入区元信息必须在主工作区内部保留对称的左右安全边距。工作栏
|
||||
分割线只能位于主工作区边界之外,不能覆盖、裁切或挤掉输入框边框、发送按钮或右侧边距。
|
||||
- 终端、宽日志和大型成果可以由用户切换到底部停靠或独立窗口,应用不得因内容变化自动改变用户已选布局。
|
||||
|
||||
### 6.10 应用顶栏与全局操作
|
||||
|
||||
应用顶栏用于窗口级状态、侧栏开关和低频全局操作,不承担页面标题或主要导航。顶栏必须保持紧凑,不能与页面内容争夺注意力。
|
||||
应用顶栏用于当前会话标题、范围、窗口级状态、侧栏开关和低频全局操作,不承担主要导航。
|
||||
顶栏必须保持紧凑,不能与页面内容争夺注意力。
|
||||
|
||||
- 顶栏高度默认为 `58px`,图标按钮使用 `34px × 34px` 点击区域。
|
||||
- 应用壳层在水平方向分为全高主侧栏和右侧内容框架。主侧栏从窗口顶部贯穿到底部,品牌区、
|
||||
项目切换器、导航和账户区保持同一连续表面;不能用全宽标题栏把整条侧栏向下推移。
|
||||
- 无边框窗口的紧凑标题栏只覆盖右侧内容框架,保留侧栏开关、当前会话标题、范围、Runtime
|
||||
状态、主题开关和窗口控制,不得为了新增工作栏入口把这一整组控件下移。普通图标按钮使用
|
||||
`34px × 34px` 点击区域。
|
||||
- 发布说明 Modal 与其他应用级 Modal 一样使用整窗遮罩;遮罩必须覆盖主侧栏、右侧内容和
|
||||
顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。
|
||||
- 当前会话存在 Task 时,Task 上下文栏位于标题栏下方并承载任务摘要与详情;没有 Task 时
|
||||
整行隐藏。折叠状态保持为 `46px` 单行表面,与右侧工作栏标签栏等高,左右分隔线必须水平
|
||||
对齐。该栏不重复放置新建入口。
|
||||
- “新建任务”只位于工作栏“任务索引”标题后方;任务状态筛选占满工作栏可用宽度,四个选项
|
||||
等分,不能保留内容宽度的短控件。
|
||||
- Runtime 状态、同步状态等短标签使用 `--font-caption`,不得放大为正文标题。
|
||||
- 浅色与深色切换属于持续可用的窗口级操作,直接显示太阳或月亮图标,并通过可访问名称说明将切换到的主题。选择必须持久化,切换不得改变布局。
|
||||
- 顶栏只直接显示当前任务所需的高频操作。已有侧栏账户设置入口时,不再重复显示 Runtime/设置入口;使用帮助优先放在相关操作附近,而不是为单个帮助项创建“更多”菜单。
|
||||
- 助手工作栏开关固定在上下文栏最右侧并与窗口关闭按钮纵向对齐;展开后该开关保持原位并
|
||||
负责收起,不在工作栏标题区域增加第二个关闭入口。
|
||||
- 只有存在至少两个无法由稳定入口承载的低频全局操作时才增加全局菜单,不为了容纳一个冗余入口而显示省略号按钮。
|
||||
- 窄窗口下优先压缩状态标签并保留图标按钮,不隐藏窗口控制、当前范围或进行中的风险状态。
|
||||
- 使用全局菜单时,菜单项使用 `--font-body`、`14px` 图标和约 `32px` 单项高度;标签使用短名称。菜单保留 `menu`、`menuitem` 语义,支持上下方向键、Home、End 和 Escape,关闭后焦点返回触发按钮。
|
||||
@@ -340,7 +377,7 @@
|
||||
- 点击或聚焦菜单外部时关闭;同一输入区内的模型、专家和模式菜单互斥展开。
|
||||
- 不可用选项保持可读并说明原因,键盘导航不得停留在不可选择项上。
|
||||
- 项目选择器属于富信息单选菜单。收起时保持当前项目名称紧凑可见;展开后每项至少显示项目名称和项目类型,并按数据可用性补充本地目录、远程来源、平台、连接状态或其他能帮助辨认项目的信息。
|
||||
- 项目菜单当前区分“本地项目 / 远程通道”。未来出现真正的远程项目时新增独立分组,不把消息通道命名为远程项目。分组、项目类型和连接状态均须显示文字,不只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`。
|
||||
- 项目菜单区分“本地项目 / 远程项目 / 远程通道”,不把消息通道命名为远程项目。“远程项目(技术预览)”关闭时完整移除远程项目分组,而不是显示不可操作的残留选项;本地项目和远程通道继续可用。分组、项目类型和连接状态均须显示文字,不只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`。
|
||||
|
||||
### 6.12 应用通知与就地反馈
|
||||
|
||||
@@ -506,8 +543,12 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
|
||||
- 使用 `reading` 壳层,消息流与输入区共享宽度。
|
||||
- 对话标题和当前项目范围位于 `PageHeader` 或对话上下文区,不在消息流中重复。
|
||||
- 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行
|
||||
或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态,
|
||||
使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。
|
||||
- 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。
|
||||
- 模型、专家角色、工作模式、OpenCode Agent、Continue 预设和 Runtime 快捷操作使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。
|
||||
- Runtime 选择菜单必须反映当前执行空间的真实能力。托管 SSH 项目当前只支持 OpenCode,因此输入区只渲染已配置的 OpenCode 选项和管理入口,不显示直连模型、Continue 或 DeepSeek Harness 的不可用入口;旧会话保存的其他 Runtime 选择在项目激活时恢复为当前 OpenCode 配置。
|
||||
- 输入区第一行工具栏只承载附件、语音、知识范围、专家角色、工作模式、Runtime 选择和发送等通用操作。OpenCode Agent、Continue 预设及 Runtime 快捷操作必须放入其下方独立的 Runtime 专属功能行,通过可见分组名称、顶部边界和差异化表面与通用操作分层;该行只承载对当前消息生效的高频选择,当前 Runtime 没有可选专属功能时不保留空行。
|
||||
- OpenCode、Continue 和 DeepSeek Harness 后续的 Task 级委派与取消、后台执行进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止及原生会话监督统一进入应用级助手工作栏固定的“Runtime”栏目,不加入 Composer。栏目入口始终存在;只选择 Conversation 或 Task,并按 Task 聚合执行状态,不显示 Job/Run 树或独立操作菜单。内部可选区域按用户所选目标和 Runtime 的真实能力显示,不为未支持能力渲染空卡片或成排禁用按钮。切换跟随目标时必须清理上一归属的监督状态,固定目标则保持不变。
|
||||
- 设置中心只管理持久 Runtime 配置、默认值和能力清单;右侧 Runtime 栏目管理用户当前跟随或固定目标的生命周期。两处不得复制同一实时操作,栏目中的高风险操作仍须就地确认并保留取消、权限、用量和活动审计。
|
||||
@@ -532,8 +573,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- 侧栏最近会话的更新时间在当天显示本地时间,非当天显示本地日期;非当年记录必须同时显示年份。悬停时间信息时提供完整日期和时间。
|
||||
- 侧栏中有关联 Task 的 Conversation 显示行首展开按钮,父行不显示任务标签或数量;展开后
|
||||
最多直接显示 3 个 Task,“查看全部 N 个任务”打开该 Conversation 的完整任务区。
|
||||
- Task 子项以共享状态点开头,并显示名称、本地化的 Ask/Execute、计划摘要和聚合状态。点击
|
||||
子项打开同一 Conversation 并定位 Task,不继续展开 Job/Run。
|
||||
- Task 子项左侧显示名称,右侧显示本地化的 Ask/Execute、计划摘要和共享状态标记,与父会话
|
||||
的更新时间位置一致;已完成使用带勾成功点,不重复显示完成文字。点击子项打开同一
|
||||
Conversation 并定位 Task,不继续展开 Job/Run。
|
||||
- 删除入口使用 `danger-ghost`,并按数据可恢复性执行确认或撤销策略。
|
||||
|
||||
### 13.3 知识库
|
||||
@@ -583,6 +625,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
|
||||
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
|
||||
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
|
||||
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
|
||||
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
|
||||
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
||||
- 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
|
||||
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
|
||||
- 标题或正文校验、删除确认、同步进度和可就地恢复的错误仍靠近对应编辑器或操作呈现。
|
||||
@@ -602,8 +647,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
|
||||
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
|
||||
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“魔法笔记”,默认进入通用设置。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查和下载页,不显示底层服务商名称。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64/arm64 Agent 包清单分成独立卡片;清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
|
||||
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图,失败时保留草稿并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
|
||||
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
||||
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
|
||||
- “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.11.8",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
},
|
||||
"node": {
|
||||
"version": "24.19.0",
|
||||
"source": "https://nodejs.org/dist/v24.19.0/",
|
||||
"targets": {
|
||||
"linux-x64": {
|
||||
"archive": "node-v24.19.0-linux-x64.tar.gz",
|
||||
"sha256": "f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4",
|
||||
"binaryPath": "node-v24.19.0-linux-x64/bin/node",
|
||||
"licensePath": "node-v24.19.0-linux-x64/LICENSE"
|
||||
},
|
||||
"linux-arm64": {
|
||||
"archive": "node-v24.19.0-linux-arm64.tar.gz",
|
||||
"sha256": "d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f",
|
||||
"binaryPath": "node-v24.19.0-linux-arm64/bin/node",
|
||||
"licensePath": "node-v24.19.0-linux-arm64/LICENSE"
|
||||
}
|
||||
}
|
||||
},
|
||||
"koffi": {
|
||||
"version": "3.1.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,914 @@
|
||||
const {
|
||||
createHash,
|
||||
createPublicKey,
|
||||
sign,
|
||||
verify
|
||||
} = require('node:crypto')
|
||||
const {
|
||||
closeSync,
|
||||
fstatSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
writeFileSync
|
||||
} = require('node:fs')
|
||||
const { basename, resolve } = require('node:path')
|
||||
const { crc32 } = require('node:zlib')
|
||||
const {
|
||||
productionSigningKey,
|
||||
publicKeySpkiBase64,
|
||||
readTrustedKeyRegistry,
|
||||
redactSecrets
|
||||
} = require('./agent-bundle.cjs')
|
||||
const {
|
||||
canonicalJson,
|
||||
descriptorContentDigest,
|
||||
signatureDomain: packageSignatureDomain
|
||||
} = require('./agent-package.cjs')
|
||||
const {
|
||||
localFileHeaderSignature,
|
||||
readExactZipBytes,
|
||||
readZipCentralDirectory
|
||||
} = require('./zip-central-directory.cjs')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
const catalogName = 'agent-catalog.json'
|
||||
const catalogSignatureName = 'agent-catalog.sig'
|
||||
const catalogSignatureDomain = Buffer.from(
|
||||
'GoodBuddy Agent Package Catalog Signature v1\0',
|
||||
'utf8'
|
||||
)
|
||||
const semanticVersionPattern =
|
||||
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*)))*)?$/u
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u
|
||||
const maximumPackageBytes = 512 * 1024 * 1024
|
||||
const maximumPackageEntries = 50_002
|
||||
const maximumPackageEntryBytes = 384 * 1024 * 1024
|
||||
const maximumExpandedPackageBytes = 1024 * 1024 * 1024
|
||||
const maximumMetadataBytes = 1024 * 1024
|
||||
const maximumCentralDirectoryBytes = 32 * 1024 * 1024
|
||||
const maximumCatalogEntries = 200
|
||||
const windowsReservedNamePattern =
|
||||
/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu
|
||||
|
||||
function canonicalBytes(value) {
|
||||
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function trustedProductionKey(registry, keyId) {
|
||||
const key = registry.keys.find(
|
||||
(candidate) =>
|
||||
candidate.keyId === keyId &&
|
||||
candidate.environment === 'production'
|
||||
)
|
||||
if (
|
||||
!key ||
|
||||
registry.revocations.some(
|
||||
(revocation) => revocation.keyId === keyId
|
||||
)
|
||||
) {
|
||||
throw new Error(`Agent signing key is not trusted: ${keyId}`)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function verifyDetachedSignature(
|
||||
bytes,
|
||||
signatureText,
|
||||
key,
|
||||
domain,
|
||||
label
|
||||
) {
|
||||
const signature = Buffer.from(
|
||||
signatureText.toString('utf8').trim(),
|
||||
'base64'
|
||||
)
|
||||
const publicKey = createPublicKey({
|
||||
key: Buffer.from(key.publicKeySpkiBase64, 'base64'),
|
||||
format: 'der',
|
||||
type: 'spki'
|
||||
})
|
||||
if (
|
||||
signature.length !== 64 ||
|
||||
!verify(
|
||||
null,
|
||||
Buffer.concat([domain, bytes]),
|
||||
publicKey,
|
||||
signature
|
||||
)
|
||||
) {
|
||||
throw new Error(`${label} signature verification failed`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseCanonicalJson(bytes, label) {
|
||||
const value = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
||||
)
|
||||
if (!canonicalBytes(value).equals(bytes)) {
|
||||
throw new Error(`${label} is not canonical`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function assertProtocol(value, label) {
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!Number.isSafeInteger(value.major) ||
|
||||
value.major < 1 ||
|
||||
!Number.isSafeInteger(value.minor) ||
|
||||
value.minor < 0
|
||||
) {
|
||||
throw new Error(`${label} is invalid`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertPackageDescriptor(descriptor) {
|
||||
if (
|
||||
typeof descriptor !== 'object' ||
|
||||
descriptor === null ||
|
||||
descriptor.format !== 'goodbuddy-agent-package' ||
|
||||
descriptor.formatVersion !== 1 ||
|
||||
descriptor.product !== 'GoodBuddy' ||
|
||||
descriptor.component !== 'agent' ||
|
||||
!semanticVersionPattern.test(descriptor.version ?? '') ||
|
||||
!semanticVersionPattern.test(
|
||||
descriptor.minimumDesktopVersion ?? ''
|
||||
) ||
|
||||
descriptor.platform !== 'linux' ||
|
||||
!['x64', 'arm64'].includes(descriptor.architecture) ||
|
||||
typeof descriptor.signingKeyId !== 'string' ||
|
||||
!/^sha256:[a-f0-9]{64}$/u.test(
|
||||
descriptor.contentDigest ?? ''
|
||||
) ||
|
||||
!Array.isArray(descriptor.files) ||
|
||||
descriptor.files.length === 0 ||
|
||||
descriptor.files.length > 50_000 ||
|
||||
typeof descriptor.remoteRuntime !== 'object' ||
|
||||
descriptor.remoteRuntime === null ||
|
||||
descriptor.remoteRuntime.runtimeId !== 'opencode' ||
|
||||
descriptor.remoteRuntime.provider !== 'opencode' ||
|
||||
!semanticVersionPattern.test(
|
||||
descriptor.remoteRuntime.version ?? ''
|
||||
) ||
|
||||
!/^sha256:[a-f0-9]{64}$/u.test(
|
||||
descriptor.remoteRuntime.bundleDigest ?? ''
|
||||
)
|
||||
) {
|
||||
throw new Error('Agent package descriptor is invalid')
|
||||
}
|
||||
assertProtocol(descriptor.agentProtocol, 'Agent protocol')
|
||||
assertProtocol(
|
||||
descriptor.remoteRuntime.protocol,
|
||||
'Remote Runtime protocol'
|
||||
)
|
||||
const paths = new Set()
|
||||
for (const file of descriptor.files) {
|
||||
if (
|
||||
typeof file !== 'object' ||
|
||||
file === null ||
|
||||
!safePackagePath(file.path) ||
|
||||
!Number.isSafeInteger(file.size) ||
|
||||
file.size < 0 ||
|
||||
!sha256Pattern.test(file.sha256 ?? '') ||
|
||||
!['0644', '0755'].includes(file.mode) ||
|
||||
paths.has(file.path)
|
||||
) {
|
||||
throw new Error('Agent package file inventory is invalid')
|
||||
}
|
||||
paths.add(file.path)
|
||||
}
|
||||
if (
|
||||
descriptor.contentDigest !==
|
||||
descriptorContentDigest(descriptor)
|
||||
) {
|
||||
throw new Error('Agent package content identity is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
function readPackageMetadata(archivePath, registry) {
|
||||
const archive = resolve(archivePath)
|
||||
const streamed = streamPackageMetadata(archive)
|
||||
const descriptorBytes = streamed.descriptor
|
||||
const signatureBytes = streamed.signature
|
||||
if (!descriptorBytes || !signatureBytes) {
|
||||
throw new Error('Agent package metadata is missing')
|
||||
}
|
||||
const descriptor = parseCanonicalJson(
|
||||
Buffer.from(descriptorBytes),
|
||||
'Agent package descriptor'
|
||||
)
|
||||
assertPackageDescriptor(descriptor)
|
||||
verifyPackageFileInventory(descriptor, streamed.files)
|
||||
const key = trustedProductionKey(
|
||||
registry,
|
||||
descriptor.signingKeyId
|
||||
)
|
||||
verifyDetachedSignature(
|
||||
Buffer.from(descriptorBytes),
|
||||
Buffer.from(signatureBytes),
|
||||
key,
|
||||
packageSignatureDomain,
|
||||
'Agent package'
|
||||
)
|
||||
const expectedArchive =
|
||||
`goodbuddy-agent-${descriptor.version}` +
|
||||
`-linux-${descriptor.architecture}.gbagent`
|
||||
if (basename(archive) !== expectedArchive) {
|
||||
throw new Error(
|
||||
`Agent package archive must be named ${expectedArchive}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
archive: expectedArchive,
|
||||
descriptor,
|
||||
size: streamed.size,
|
||||
sha256: streamed.sha256
|
||||
}
|
||||
}
|
||||
|
||||
function safePackagePath(path) {
|
||||
return (
|
||||
typeof path === 'string' &&
|
||||
path.length > 0 &&
|
||||
path.length <= 512 &&
|
||||
!path.startsWith('/') &&
|
||||
!path.endsWith('/') &&
|
||||
!path.includes('\\') &&
|
||||
path.split('/').every(
|
||||
(part) =>
|
||||
part.length > 0 &&
|
||||
part !== '.' &&
|
||||
part !== '..' &&
|
||||
!part.endsWith('.') &&
|
||||
!part.endsWith(' ') &&
|
||||
!part.includes(':') &&
|
||||
[...part].every(
|
||||
(character) => character.charCodeAt(0) > 0x1f
|
||||
) &&
|
||||
!windowsReservedNamePattern.test(part)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function verifyPackageFileInventory(descriptor, actualFiles) {
|
||||
const expected = new Set([
|
||||
...descriptor.files.map((file) => file.path),
|
||||
'agent-package.json',
|
||||
'agent-package.sig'
|
||||
])
|
||||
if (
|
||||
actualFiles.size !== expected.size ||
|
||||
[...actualFiles.keys()].some((path) => !expected.has(path))
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package contains undeclared or missing files'
|
||||
)
|
||||
}
|
||||
for (const file of descriptor.files) {
|
||||
const actual = actualFiles.get(file.path)
|
||||
if (
|
||||
!actual ||
|
||||
actual.size !== file.size ||
|
||||
actual.sha256 !== file.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`Agent package payload verification failed: ${file.path}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function centralDirectoryEntries(handle, archiveSize) {
|
||||
const parsed = readZipCentralDirectory(handle, archiveSize, {
|
||||
label: 'Agent package',
|
||||
minimumEntries: 1,
|
||||
maximumEntries: maximumPackageEntries,
|
||||
maximumCentralDirectoryBytes
|
||||
})
|
||||
const entries = []
|
||||
const seen = new Set()
|
||||
let expandedBytes = 0
|
||||
for (const entry of parsed.entries) {
|
||||
if (
|
||||
entry.flags !== 0x0800 ||
|
||||
entry.compression !== 0 ||
|
||||
entry.compressedSize === 0xffffffff ||
|
||||
entry.size === 0xffffffff ||
|
||||
entry.compressedSize !== entry.size ||
|
||||
entry.size > maximumPackageEntryBytes ||
|
||||
entry.diskStart !== 0 ||
|
||||
entry.localOffset === 0xffffffff
|
||||
) {
|
||||
throw new Error('Agent package ZIP entry is unsupported')
|
||||
}
|
||||
if (
|
||||
!safePackagePath(entry.name) ||
|
||||
seen.has(entry.name)
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package contains an unsafe or duplicate path'
|
||||
)
|
||||
}
|
||||
seen.add(entry.name)
|
||||
expandedBytes += entry.size
|
||||
if (expandedBytes > maximumExpandedPackageBytes) {
|
||||
throw new Error(
|
||||
'Agent package expanded payload exceeds its limit'
|
||||
)
|
||||
}
|
||||
const local = readExactZipBytes(
|
||||
handle,
|
||||
30,
|
||||
entry.localOffset,
|
||||
'Agent package local file header'
|
||||
)
|
||||
const localNameLength = local.readUInt16LE(26)
|
||||
const localExtraLength = local.readUInt16LE(28)
|
||||
if (
|
||||
local.readUInt32LE(0) !== localFileHeaderSignature ||
|
||||
local.readUInt16LE(6) !== entry.flags ||
|
||||
local.readUInt16LE(8) !== entry.compression ||
|
||||
local.readUInt32LE(14) !== entry.checksum ||
|
||||
local.readUInt32LE(18) !== entry.compressedSize ||
|
||||
local.readUInt32LE(22) !== entry.size ||
|
||||
localNameLength !== entry.nameBytes.length
|
||||
) {
|
||||
throw new Error('Agent package local file header is invalid')
|
||||
}
|
||||
const localName = readExactZipBytes(
|
||||
handle,
|
||||
localNameLength,
|
||||
entry.localOffset + 30,
|
||||
'Agent package local file name'
|
||||
)
|
||||
if (!localName.equals(entry.nameBytes)) {
|
||||
throw new Error('Agent package local file name is invalid')
|
||||
}
|
||||
const dataOffset =
|
||||
entry.localOffset +
|
||||
30 +
|
||||
localNameLength +
|
||||
localExtraLength
|
||||
const recordEnd = dataOffset + entry.compressedSize
|
||||
if (recordEnd > parsed.centralOffset) {
|
||||
throw new Error('Agent package entry exceeds its data region')
|
||||
}
|
||||
entries.push({
|
||||
checksum: entry.checksum,
|
||||
dataOffset,
|
||||
name: entry.name,
|
||||
recordStart: entry.localOffset,
|
||||
recordEnd
|
||||
})
|
||||
}
|
||||
entries.sort(
|
||||
(left, right) => left.recordStart - right.recordStart
|
||||
)
|
||||
for (let index = 1; index < entries.length; index += 1) {
|
||||
if (entries[index].recordStart < entries[index - 1].recordEnd) {
|
||||
throw new Error('Agent package ZIP entries overlap')
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function sameFileSnapshot(left, right) {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.size === right.size &&
|
||||
left.mtimeMs === right.mtimeMs &&
|
||||
left.ctimeMs === right.ctimeMs
|
||||
)
|
||||
}
|
||||
|
||||
function streamPackageMetadata(archivePath) {
|
||||
const handle = openSync(archivePath, 'r')
|
||||
const values = new Map()
|
||||
const files = new Map()
|
||||
try {
|
||||
const initialStatus = fstatSync(handle)
|
||||
if (
|
||||
!initialStatus.isFile() ||
|
||||
initialStatus.size <= 0 ||
|
||||
initialStatus.size > maximumPackageBytes
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package archive is not a bounded file'
|
||||
)
|
||||
}
|
||||
const entries = centralDirectoryEntries(
|
||||
handle,
|
||||
initialStatus.size
|
||||
)
|
||||
const states = entries.map((entry) => {
|
||||
const wanted =
|
||||
entry.name === 'agent-package.json' ||
|
||||
entry.name === 'agent-package.sig'
|
||||
const size = entry.recordEnd - entry.dataOffset
|
||||
if (wanted && size > maximumMetadataBytes) {
|
||||
throw new Error('Agent package metadata exceeds its limit')
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
bytes: 0,
|
||||
contents: wanted
|
||||
? Buffer.allocUnsafe(size)
|
||||
: undefined,
|
||||
crc32: 0,
|
||||
hash: createHash('sha256')
|
||||
}
|
||||
})
|
||||
const archiveHash = createHash('sha256')
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
||||
let position = 0
|
||||
let firstEntry = 0
|
||||
while (position < initialStatus.size) {
|
||||
const bytesRead = readSync(
|
||||
handle,
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, initialStatus.size - position),
|
||||
position
|
||||
)
|
||||
if (bytesRead <= 0) {
|
||||
throw new Error('Agent package archive is truncated')
|
||||
}
|
||||
const chunk = buffer.subarray(0, bytesRead)
|
||||
const chunkEnd = position + bytesRead
|
||||
archiveHash.update(chunk)
|
||||
while (
|
||||
firstEntry < states.length &&
|
||||
states[firstEntry].recordEnd <= position
|
||||
) {
|
||||
firstEntry += 1
|
||||
}
|
||||
for (
|
||||
let index = firstEntry;
|
||||
index < states.length &&
|
||||
states[index].dataOffset < chunkEnd;
|
||||
index += 1
|
||||
) {
|
||||
const state = states[index]
|
||||
const dataStart = Math.max(position, state.dataOffset)
|
||||
const dataEnd = Math.min(chunkEnd, state.recordEnd)
|
||||
if (dataStart >= dataEnd) {
|
||||
continue
|
||||
}
|
||||
const data = chunk.subarray(
|
||||
dataStart - position,
|
||||
dataEnd - position
|
||||
)
|
||||
state.hash.update(data)
|
||||
state.crc32 = crc32(data, state.crc32)
|
||||
if (state.contents) {
|
||||
data.copy(
|
||||
state.contents,
|
||||
dataStart - state.dataOffset
|
||||
)
|
||||
}
|
||||
state.bytes += data.byteLength
|
||||
}
|
||||
position = chunkEnd
|
||||
}
|
||||
if (!sameFileSnapshot(initialStatus, fstatSync(handle))) {
|
||||
throw new Error(
|
||||
'Agent package archive changed during verification'
|
||||
)
|
||||
}
|
||||
for (const state of states) {
|
||||
const size = state.recordEnd - state.dataOffset
|
||||
if (
|
||||
state.bytes !== size ||
|
||||
(state.crc32 >>> 0) !== state.checksum
|
||||
) {
|
||||
throw new Error(
|
||||
`Agent package ZIP checksum verification failed: ${state.name}`
|
||||
)
|
||||
}
|
||||
files.set(state.name, {
|
||||
size,
|
||||
sha256: state.hash.digest('hex')
|
||||
})
|
||||
if (state.contents) {
|
||||
values.set(state.name, state.contents)
|
||||
}
|
||||
}
|
||||
return {
|
||||
descriptor: values.get('agent-package.json'),
|
||||
signature: values.get('agent-package.sig'),
|
||||
files,
|
||||
sha256: archiveHash.digest('hex'),
|
||||
size: initialStatus.size
|
||||
}
|
||||
} finally {
|
||||
closeSync(handle)
|
||||
}
|
||||
}
|
||||
|
||||
function catalogEntry(metadata) {
|
||||
const descriptor = metadata.descriptor
|
||||
const {
|
||||
contentDigest: _contentDigest,
|
||||
files: _files,
|
||||
signingKeyId: _signingKeyId,
|
||||
...identity
|
||||
} = descriptor
|
||||
void _contentDigest
|
||||
void _files
|
||||
void _signingKeyId
|
||||
return {
|
||||
...identity,
|
||||
archive: metadata.archive,
|
||||
size: metadata.size,
|
||||
sha256: metadata.sha256,
|
||||
downloads: {
|
||||
github: {
|
||||
url:
|
||||
'https://github.com/mesalogo/goodbuddy/releases/download/' +
|
||||
`agent-v${descriptor.version}/${metadata.archive}`
|
||||
},
|
||||
mirror: {
|
||||
url:
|
||||
'https://goodbuddy.oss-cn-beijing.aliyuncs.com/' +
|
||||
`agent-releases/v${descriptor.version}/${metadata.archive}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertCatalog(catalog) {
|
||||
if (
|
||||
typeof catalog !== 'object' ||
|
||||
catalog === null ||
|
||||
catalog.formatVersion !== 1 ||
|
||||
catalog.product !== 'GoodBuddy' ||
|
||||
catalog.component !== 'agent' ||
|
||||
typeof catalog.signingKeyId !== 'string' ||
|
||||
typeof catalog.generatedAt !== 'string' ||
|
||||
!Number.isFinite(Date.parse(catalog.generatedAt)) ||
|
||||
new Date(catalog.generatedAt).toISOString() !==
|
||||
catalog.generatedAt ||
|
||||
!Array.isArray(catalog.entries) ||
|
||||
catalog.entries.length === 0 ||
|
||||
catalog.entries.length > maximumCatalogEntries
|
||||
) {
|
||||
throw new Error('Agent catalog is invalid')
|
||||
}
|
||||
const identities = new Set()
|
||||
for (const entry of catalog.entries) {
|
||||
if (
|
||||
typeof entry !== 'object' ||
|
||||
entry === null ||
|
||||
!semanticVersionPattern.test(entry.version ?? '') ||
|
||||
!semanticVersionPattern.test(
|
||||
entry.minimumDesktopVersion ?? ''
|
||||
) ||
|
||||
entry.platform !== 'linux' ||
|
||||
!['x64', 'arm64'].includes(entry.architecture) ||
|
||||
entry.archive !==
|
||||
`goodbuddy-agent-${entry.version}` +
|
||||
`-linux-${entry.architecture}.gbagent` ||
|
||||
!Number.isSafeInteger(entry.size) ||
|
||||
entry.size <= 0 ||
|
||||
!sha256Pattern.test(entry.sha256 ?? '') ||
|
||||
entry.downloads?.github?.url !==
|
||||
'https://github.com/mesalogo/goodbuddy/releases/download/' +
|
||||
`agent-v${entry.version}/${entry.archive}` ||
|
||||
entry.downloads?.mirror?.url !==
|
||||
'https://goodbuddy.oss-cn-beijing.aliyuncs.com/' +
|
||||
`agent-releases/v${entry.version}/${entry.archive}` ||
|
||||
entry.format !== 'goodbuddy-agent-package' ||
|
||||
entry.formatVersion !== 1 ||
|
||||
entry.product !== 'GoodBuddy' ||
|
||||
entry.component !== 'agent' ||
|
||||
entry.remoteRuntime?.runtimeId !== 'opencode' ||
|
||||
entry.remoteRuntime?.provider !== 'opencode' ||
|
||||
!semanticVersionPattern.test(
|
||||
entry.remoteRuntime?.version ?? ''
|
||||
) ||
|
||||
!/^sha256:[a-f0-9]{64}$/u.test(
|
||||
entry.remoteRuntime?.bundleDigest ?? ''
|
||||
)
|
||||
) {
|
||||
throw new Error('Agent catalog entry is invalid')
|
||||
}
|
||||
assertProtocol(entry.agentProtocol, 'Agent protocol')
|
||||
assertProtocol(
|
||||
entry.remoteRuntime.protocol,
|
||||
'Remote Runtime protocol'
|
||||
)
|
||||
const identity = `${entry.version}:${entry.architecture}`
|
||||
if (identities.has(identity)) {
|
||||
throw new Error('Agent catalog contains duplicate entries')
|
||||
}
|
||||
identities.add(identity)
|
||||
}
|
||||
}
|
||||
|
||||
function readVerifiedCatalog(catalogPath, signaturePath, registry) {
|
||||
const bytes = readFileSync(resolve(catalogPath))
|
||||
const catalog = parseCanonicalJson(bytes, 'Agent catalog')
|
||||
assertCatalog(catalog)
|
||||
const key = trustedProductionKey(
|
||||
registry,
|
||||
catalog.signingKeyId
|
||||
)
|
||||
verifyDetachedSignature(
|
||||
bytes,
|
||||
readFileSync(resolve(signaturePath)),
|
||||
key,
|
||||
catalogSignatureDomain,
|
||||
'Agent catalog'
|
||||
)
|
||||
return catalog
|
||||
}
|
||||
|
||||
function parseVersion(value) {
|
||||
const match =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/u
|
||||
.exec(value)
|
||||
if (!match) {
|
||||
throw new Error(`Invalid semantic version: ${value}`)
|
||||
}
|
||||
return [
|
||||
BigInt(match[1]),
|
||||
BigInt(match[2]),
|
||||
BigInt(match[3]),
|
||||
match[4]?.split('.')
|
||||
]
|
||||
}
|
||||
|
||||
function compareVersions(left, right) {
|
||||
const leftParts = parseVersion(left)
|
||||
const rightParts = parseVersion(right)
|
||||
for (const index of [0, 1, 2]) {
|
||||
if (leftParts[index] !== rightParts[index]) {
|
||||
return leftParts[index] > rightParts[index] ? 1 : -1
|
||||
}
|
||||
}
|
||||
const leftPre = leftParts[3]
|
||||
const rightPre = rightParts[3]
|
||||
if (!leftPre && !rightPre) return 0
|
||||
if (!leftPre) return 1
|
||||
if (!rightPre) return -1
|
||||
for (
|
||||
let index = 0;
|
||||
index < Math.max(leftPre.length, rightPre.length);
|
||||
index += 1
|
||||
) {
|
||||
const leftValue = leftPre[index]
|
||||
const rightValue = rightPre[index]
|
||||
if (leftValue === undefined) return -1
|
||||
if (rightValue === undefined) return 1
|
||||
if (leftValue === rightValue) continue
|
||||
const leftNumeric = /^\d+$/u.test(leftValue)
|
||||
const rightNumeric = /^\d+$/u.test(rightValue)
|
||||
if (leftNumeric && rightNumeric) {
|
||||
return BigInt(leftValue) > BigInt(rightValue) ? 1 : -1
|
||||
}
|
||||
if (leftNumeric !== rightNumeric) {
|
||||
return leftNumeric ? -1 : 1
|
||||
}
|
||||
return leftValue > rightValue ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function createCatalog(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
const signingIdentity =
|
||||
options.signingIdentity ??
|
||||
productionSigningKey(options.environment ?? process.env)
|
||||
const signingRecord = trustedProductionKey(
|
||||
registry,
|
||||
signingIdentity.keyId
|
||||
)
|
||||
if (
|
||||
publicKeySpkiBase64(signingIdentity.privateKey) !==
|
||||
signingRecord.publicKeySpkiBase64
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent catalog signing key does not match the trusted registry'
|
||||
)
|
||||
}
|
||||
const packages = [
|
||||
readPackageMetadata(options.x64Package, registry),
|
||||
readPackageMetadata(options.arm64Package, registry)
|
||||
]
|
||||
const [first, second] = packages
|
||||
const {
|
||||
bundleDigest: _firstRuntimeDigest,
|
||||
...firstRuntimeIdentity
|
||||
} = first.descriptor.remoteRuntime
|
||||
const {
|
||||
bundleDigest: _secondRuntimeDigest,
|
||||
...secondRuntimeIdentity
|
||||
} = second.descriptor.remoteRuntime
|
||||
void _firstRuntimeDigest
|
||||
void _secondRuntimeDigest
|
||||
if (
|
||||
first.descriptor.version !== second.descriptor.version ||
|
||||
first.descriptor.minimumDesktopVersion !==
|
||||
second.descriptor.minimumDesktopVersion ||
|
||||
canonicalJson(first.descriptor.agentProtocol) !==
|
||||
canonicalJson(second.descriptor.agentProtocol) ||
|
||||
canonicalJson(firstRuntimeIdentity) !==
|
||||
canonicalJson(secondRuntimeIdentity) ||
|
||||
new Set(packages.map(
|
||||
(metadata) => metadata.descriptor.architecture
|
||||
)).size !== 2
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package matrix does not describe one coherent release'
|
||||
)
|
||||
}
|
||||
const previous =
|
||||
options.previousCatalog && options.previousSignature
|
||||
? readVerifiedCatalog(
|
||||
options.previousCatalog,
|
||||
options.previousSignature,
|
||||
registry
|
||||
)
|
||||
: undefined
|
||||
if (
|
||||
Boolean(options.previousCatalog) !==
|
||||
Boolean(options.previousSignature)
|
||||
) {
|
||||
throw new Error(
|
||||
'Previous Agent catalog and signature must be provided together'
|
||||
)
|
||||
}
|
||||
const entries = new Map()
|
||||
for (const entry of previous?.entries ?? []) {
|
||||
entries.set(`${entry.version}:${entry.architecture}`, entry)
|
||||
}
|
||||
for (const metadata of packages) {
|
||||
const entry = catalogEntry(metadata)
|
||||
const identity = `${entry.version}:${entry.architecture}`
|
||||
const existing = entries.get(identity)
|
||||
if (
|
||||
existing &&
|
||||
canonicalJson(existing) !== canonicalJson(entry)
|
||||
) {
|
||||
throw new Error(
|
||||
`Agent release identity is immutable: ${identity}`
|
||||
)
|
||||
}
|
||||
entries.set(identity, entry)
|
||||
}
|
||||
const sortedEntries = [...entries.values()].sort(
|
||||
(left, right) =>
|
||||
compareVersions(right.version, left.version) ||
|
||||
left.architecture.localeCompare(right.architecture, 'en')
|
||||
)
|
||||
if (sortedEntries.length > maximumCatalogEntries) {
|
||||
throw new Error('Agent catalog exceeds its retention limit')
|
||||
}
|
||||
const catalog = {
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
component: 'agent',
|
||||
signingKeyId: signingIdentity.keyId,
|
||||
generatedAt: new Date(options.generatedAt).toISOString(),
|
||||
entries: sortedEntries
|
||||
}
|
||||
assertCatalog(catalog)
|
||||
const bytes = canonicalBytes(catalog)
|
||||
const signature = sign(
|
||||
null,
|
||||
Buffer.concat([catalogSignatureDomain, bytes]),
|
||||
signingIdentity.privateKey
|
||||
)
|
||||
writeFileSync(resolve(options.outputCatalog), bytes)
|
||||
writeFileSync(
|
||||
resolve(options.outputSignature),
|
||||
`${signature.toString('base64')}\n`,
|
||||
'utf8'
|
||||
)
|
||||
return {
|
||||
version: first.descriptor.version,
|
||||
catalog: basename(options.outputCatalog),
|
||||
signature: basename(options.outputSignature),
|
||||
entries: sortedEntries.length
|
||||
}
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const command = argv[0]
|
||||
if (!['create', 'verify'].includes(command)) {
|
||||
throw new Error(
|
||||
'Usage: agent-catalog.cjs <create|verify> [options]'
|
||||
)
|
||||
}
|
||||
const options = {}
|
||||
for (let index = 1; index < argv.length; index += 2) {
|
||||
const flag = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (
|
||||
![
|
||||
'--x64-package',
|
||||
'--arm64-package',
|
||||
'--previous-catalog',
|
||||
'--previous-signature',
|
||||
'--catalog',
|
||||
'--signature',
|
||||
'--generated-at'
|
||||
].includes(flag) ||
|
||||
!value ||
|
||||
value.startsWith('--') ||
|
||||
options[flag] !== undefined
|
||||
) {
|
||||
throw new Error('Invalid Agent catalog argument')
|
||||
}
|
||||
options[flag] = value
|
||||
}
|
||||
for (const flag of ['--catalog', '--signature']) {
|
||||
if (!options[flag]) {
|
||||
throw new Error(`Agent catalog argument is required: ${flag}`)
|
||||
}
|
||||
}
|
||||
if (command === 'verify') {
|
||||
return {
|
||||
command,
|
||||
catalog: resolve(options['--catalog']),
|
||||
signature: resolve(options['--signature'])
|
||||
}
|
||||
}
|
||||
for (const flag of [
|
||||
'--x64-package',
|
||||
'--arm64-package',
|
||||
'--generated-at'
|
||||
]) {
|
||||
if (!options[flag]) {
|
||||
throw new Error(`Agent catalog argument is required: ${flag}`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
command,
|
||||
x64Package: resolve(options['--x64-package']),
|
||||
arm64Package: resolve(options['--arm64-package']),
|
||||
...(options['--previous-catalog']
|
||||
? {
|
||||
previousCatalog: resolve(
|
||||
options['--previous-catalog']
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
...(options['--previous-signature']
|
||||
? {
|
||||
previousSignature: resolve(
|
||||
options['--previous-signature']
|
||||
)
|
||||
}
|
||||
: {}),
|
||||
outputCatalog: resolve(options['--catalog']),
|
||||
outputSignature: resolve(options['--signature']),
|
||||
generatedAt: options['--generated-at']
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArguments(argv)
|
||||
const registry = readTrustedKeyRegistry(root)
|
||||
const result =
|
||||
options.command === 'create'
|
||||
? createCatalog(options)
|
||||
: {
|
||||
entries: readVerifiedCatalog(
|
||||
options.catalog,
|
||||
options.signature,
|
||||
registry
|
||||
).entries.length
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
catalogName,
|
||||
catalogSignatureDomain,
|
||||
catalogSignatureName,
|
||||
canonicalBytes,
|
||||
catalogEntry,
|
||||
createCatalog,
|
||||
parseArguments,
|
||||
readPackageMetadata,
|
||||
readVerifiedCatalog
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${redactSecrets(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)}\n`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
const { generateKeyPairSync } = require('node:crypto')
|
||||
const {
|
||||
mkdirSync,
|
||||
rmSync
|
||||
} = require('node:fs')
|
||||
const {
|
||||
basename,
|
||||
dirname,
|
||||
join,
|
||||
resolve
|
||||
} = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const {
|
||||
buildAgentBundle,
|
||||
publicKeySpkiBase64,
|
||||
readRuntimeLock,
|
||||
supportedArchitectures
|
||||
} = require('./agent-bundle.cjs')
|
||||
const {
|
||||
buildRuntimeBundle,
|
||||
readRemoteRuntimeLock
|
||||
} = require('./remote-runtime-bundle.cjs')
|
||||
const {
|
||||
assembleAgentPackage
|
||||
} = require('./agent-package.cjs')
|
||||
const { sha256File } = require('./file-hash.cjs')
|
||||
|
||||
const ciSigningKeyId = 'goodbuddy-agent-ci-ephemeral'
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = {}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
if (
|
||||
![
|
||||
'--arch',
|
||||
'--node-archive',
|
||||
'--opencode-archive',
|
||||
'--output-directory',
|
||||
'--archive'
|
||||
].includes(argument)
|
||||
) {
|
||||
throw new Error(`Unknown Agent CI bundle argument: ${argument}`)
|
||||
}
|
||||
const value = argv[index + 1]
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${argument} requires a value`)
|
||||
}
|
||||
const key = argument.slice(2).replaceAll('-', '')
|
||||
if (options[key] !== undefined) {
|
||||
throw new Error(`${argument} may be provided only once`)
|
||||
}
|
||||
options[key] = value
|
||||
index += 1
|
||||
}
|
||||
if (!supportedArchitectures.includes(options.arch)) {
|
||||
throw new Error('--arch must be x64 or arm64')
|
||||
}
|
||||
for (const key of [
|
||||
'nodearchive',
|
||||
'opencodearchive',
|
||||
'outputdirectory',
|
||||
'archive'
|
||||
]) {
|
||||
if (!options[key]) {
|
||||
throw new Error(
|
||||
'Usage: agent-ci-bundle.cjs --arch <x64|arm64> --node-archive <path> --opencode-archive <path> --output-directory <path> --archive <path>'
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
arch: options.arch,
|
||||
nodeArchive: resolve(options.nodearchive),
|
||||
opencodeArchive: resolve(options.opencodearchive),
|
||||
outputDirectory: resolve(options.outputdirectory),
|
||||
archive: resolve(options.archive)
|
||||
}
|
||||
}
|
||||
|
||||
function ephemeralSigningIdentity() {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
return {
|
||||
privateKey,
|
||||
registry: {
|
||||
formatVersion: 1,
|
||||
keys: [
|
||||
{
|
||||
keyId: ciSigningKeyId,
|
||||
publicKeySpkiBase64: publicKeySpkiBase64(publicKey),
|
||||
environment: 'test'
|
||||
}
|
||||
],
|
||||
revocations: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildCiAgentBundle(options) {
|
||||
const projectRoot = resolve(__dirname, '..')
|
||||
const lock = readRuntimeLock(projectRoot)
|
||||
const runtimeLock = readRemoteRuntimeLock(projectRoot)
|
||||
const signing = ephemeralSigningIdentity()
|
||||
const firstArchiveRoot = resolve(
|
||||
join(
|
||||
dirname(options.archive),
|
||||
`.agent-ci-first-${process.pid}`
|
||||
)
|
||||
)
|
||||
mkdirSync(firstArchiveRoot, { recursive: true })
|
||||
const firstArchive = join(
|
||||
firstArchiveRoot,
|
||||
basename(options.archive)
|
||||
)
|
||||
const runtimeRoot = resolve(
|
||||
`${options.outputDirectory}-runtime`
|
||||
)
|
||||
try {
|
||||
const identity = {
|
||||
keyId: ciSigningKeyId,
|
||||
privateKey: signing.privateKey
|
||||
}
|
||||
rmSync(options.outputDirectory, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
buildAgentBundle({
|
||||
projectRoot,
|
||||
arch: options.arch,
|
||||
runtimeArchive: options.nodeArchive,
|
||||
outputDirectory: options.outputDirectory,
|
||||
registry: signing.registry,
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
const runtime = buildRuntimeBundle({
|
||||
projectRoot,
|
||||
architecture: options.arch,
|
||||
runtimeArchive: options.opencodeArchive,
|
||||
outputRoot: runtimeRoot,
|
||||
registry: signing.registry,
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
const assemble = (archive) =>
|
||||
assembleAgentPackage({
|
||||
projectRoot,
|
||||
architecture: options.arch,
|
||||
minimumDesktopVersion:
|
||||
require('../package.json').version,
|
||||
output: archive,
|
||||
agentBundle: options.outputDirectory,
|
||||
runtimeBundle: runtime.bundleDirectory,
|
||||
agentLock: lock,
|
||||
runtimeLock,
|
||||
registry: signing.registry,
|
||||
testSigningIdentity: identity
|
||||
})
|
||||
|
||||
assemble(firstArchive)
|
||||
const firstDigest = await sha256File(firstArchive)
|
||||
const second = assemble(options.archive)
|
||||
const archiveDigest = await sha256File(options.archive)
|
||||
if (firstDigest !== archiveDigest) {
|
||||
throw new Error('Agent CI archives are not deterministic')
|
||||
}
|
||||
|
||||
const smoke = spawnSync(
|
||||
resolve(options.outputDirectory, 'goodbuddy-agent'),
|
||||
['ci-invalid-command'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 10_000,
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (
|
||||
smoke.error ||
|
||||
smoke.status !== 2 ||
|
||||
!smoke.stderr.includes(
|
||||
'Expected daemon, attach-or-bootstrap, doctor'
|
||||
)
|
||||
) {
|
||||
throw new Error('Built Agent native launch smoke failed')
|
||||
}
|
||||
|
||||
return {
|
||||
agentVersion: lock.agentVersion,
|
||||
remoteRuntimeVersion:
|
||||
runtimeLock.runtimes.opencode.version,
|
||||
architecture: options.arch,
|
||||
archive: second.archive,
|
||||
archiveSha256: archiveDigest
|
||||
}
|
||||
} finally {
|
||||
rmSync(runtimeRoot, { recursive: true, force: true })
|
||||
rmSync(firstArchive, { force: true })
|
||||
rmSync(firstArchiveRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
const result = await buildCiAgentBundle(parseArguments(argv))
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(result)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`
|
||||
)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildCiAgentBundle,
|
||||
parseArguments
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
const {
|
||||
createHash,
|
||||
sign
|
||||
} = require('node:crypto')
|
||||
const {
|
||||
closeSync,
|
||||
copyFileSync,
|
||||
fsyncSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
readSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeSync,
|
||||
writeFileSync
|
||||
} = require('node:fs')
|
||||
const { basename, dirname, join, relative, resolve, sep } = require('node:path')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { crc32 } = require('node:zlib')
|
||||
const {
|
||||
buildAgentBundle,
|
||||
expectedManifestMode: expectedAgentManifestMode,
|
||||
preflightProductionSigningKey,
|
||||
productionSigningKey,
|
||||
publicKeySpkiBase64,
|
||||
readRuntimeLock,
|
||||
readTrustedKeyRegistry,
|
||||
redactSecrets,
|
||||
verifyLockedBundle
|
||||
} = require('./agent-bundle.cjs')
|
||||
const {
|
||||
buildRuntimeBundle,
|
||||
canonicalJson,
|
||||
readRemoteRuntimeLock,
|
||||
verifyBundleDirectory: verifyRuntimeBundle
|
||||
} = require('./remote-runtime-bundle.cjs')
|
||||
const {
|
||||
centralDirectoryHeaderSignature: zipCentralHeaderSignature,
|
||||
endOfCentralDirectorySignature: zipEndSignature,
|
||||
localFileHeaderSignature: zipLocalHeaderSignature
|
||||
} = require('./zip-central-directory.cjs')
|
||||
|
||||
const root = join(__dirname, '..')
|
||||
const packageDescriptorName = 'agent-package.json'
|
||||
const packageSignatureName = 'agent-package.sig'
|
||||
const signatureDomain = Buffer.from(
|
||||
'GoodBuddy Agent Package Descriptor Signature v1\0',
|
||||
'utf8'
|
||||
)
|
||||
const zipUtf8Flag = 0x0800
|
||||
const zipVersion = 20
|
||||
const zipMadeByUnix = (3 << 8) | zipVersion
|
||||
const zipDosDate = (1 << 5) | 1
|
||||
const semanticVersionPattern =
|
||||
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*)))*)?$/u
|
||||
|
||||
function descriptorBytes(descriptor) {
|
||||
return Buffer.from(`${JSON.stringify(descriptor, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
function descriptorContentDigest(descriptor) {
|
||||
const { contentDigest: _contentDigest, ...content } = descriptor
|
||||
void _contentDigest
|
||||
return `sha256:${createHash('sha256')
|
||||
.update(canonicalJson(content))
|
||||
.digest('hex')}`
|
||||
}
|
||||
|
||||
function listFiles(directory) {
|
||||
const rootPath = resolve(directory)
|
||||
const output = []
|
||||
const visit = (current) => {
|
||||
const currentStat = lstatSync(current)
|
||||
if (currentStat.isSymbolicLink()) {
|
||||
throw new Error(`Agent package source contains a symlink: ${current}`)
|
||||
}
|
||||
if (currentStat.isFile()) {
|
||||
output.push(current)
|
||||
return
|
||||
}
|
||||
if (!currentStat.isDirectory()) {
|
||||
throw new Error(
|
||||
`Agent package source contains an unsupported entry: ${current}`
|
||||
)
|
||||
}
|
||||
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
||||
visit(join(current, entry.name))
|
||||
}
|
||||
}
|
||||
visit(rootPath)
|
||||
return output.sort((left, right) =>
|
||||
Buffer.from(relative(rootPath, left).split(sep).join('/')).compare(
|
||||
Buffer.from(relative(rootPath, right).split(sep).join('/'))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function fileDigests(filePath) {
|
||||
const hash = createHash('sha256')
|
||||
const handle = openSync(filePath, 'r')
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
||||
let checksum = 0
|
||||
try {
|
||||
while (true) {
|
||||
const bytesRead = readSync(
|
||||
handle,
|
||||
buffer,
|
||||
0,
|
||||
buffer.length,
|
||||
null
|
||||
)
|
||||
if (bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
const chunk = buffer.subarray(0, bytesRead)
|
||||
hash.update(chunk)
|
||||
checksum = crc32(chunk, checksum)
|
||||
}
|
||||
} finally {
|
||||
closeSync(handle)
|
||||
}
|
||||
return {
|
||||
crc32: checksum >>> 0,
|
||||
sha256: hash.digest('hex')
|
||||
}
|
||||
}
|
||||
|
||||
function copyTree(source, destination) {
|
||||
for (const filePath of listFiles(source)) {
|
||||
const name = relative(source, filePath).split(sep).join('/')
|
||||
const target = join(destination, ...name.split('/'))
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
copyFileSync(filePath, target)
|
||||
}
|
||||
}
|
||||
|
||||
function packageFileMode(path) {
|
||||
if (path.startsWith('agent/')) {
|
||||
return expectedAgentManifestMode(path.slice('agent/'.length))
|
||||
}
|
||||
return /\/bin\/opencode$/u.test(path) ? '0755' : '0644'
|
||||
}
|
||||
|
||||
function stagePackagePayload(options) {
|
||||
const staging = options.staging
|
||||
copyTree(options.agentBundle, join(staging, 'agent'))
|
||||
copyTree(
|
||||
options.runtimeBundle,
|
||||
join(
|
||||
staging,
|
||||
'runtime',
|
||||
'opencode',
|
||||
basename(options.runtimeBundle)
|
||||
)
|
||||
)
|
||||
for (const [value, destination] of [
|
||||
[
|
||||
options.registry,
|
||||
'agent-release-keys.json'
|
||||
],
|
||||
[
|
||||
options.agentLock,
|
||||
'agent-runtime-lock.json'
|
||||
],
|
||||
[
|
||||
options.runtimeLock,
|
||||
'remote-runtime-lock.json'
|
||||
]
|
||||
]) {
|
||||
writeFileSync(
|
||||
join(staging, destination),
|
||||
`${JSON.stringify(value, null, 2)}\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function payloadFiles(staging) {
|
||||
const crc32ByPath = new Map()
|
||||
const files = listFiles(staging)
|
||||
.map((filePath) => {
|
||||
const path = relative(staging, filePath).split(sep).join('/')
|
||||
const size = statSync(filePath).size
|
||||
const digests = fileDigests(filePath)
|
||||
crc32ByPath.set(path, digests.crc32)
|
||||
return {
|
||||
path,
|
||||
size,
|
||||
sha256: digests.sha256,
|
||||
mode: packageFileMode(path)
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(file) =>
|
||||
file.path !== packageDescriptorName &&
|
||||
file.path !== packageSignatureName
|
||||
)
|
||||
return { crc32ByPath, files }
|
||||
}
|
||||
|
||||
function createPackageArchive(
|
||||
staging,
|
||||
output,
|
||||
knownCrc32 = new Map()
|
||||
) {
|
||||
mkdirSync(dirname(output), { recursive: true })
|
||||
const entries = listFiles(staging).map((filePath) => {
|
||||
const name = relative(staging, filePath).split(sep).join('/')
|
||||
const nameBytes = Buffer.from(name, 'utf8')
|
||||
const size = statSync(filePath).size
|
||||
if (
|
||||
nameBytes.length === 0 ||
|
||||
nameBytes.length > 0xffff ||
|
||||
size > 0xffffffff
|
||||
) {
|
||||
throw new Error(`Agent package ZIP entry is too large: ${name}`)
|
||||
}
|
||||
return {
|
||||
filePath,
|
||||
name,
|
||||
nameBytes,
|
||||
size,
|
||||
crc32:
|
||||
knownCrc32.get(name) ?? fileDigests(filePath).crc32,
|
||||
mode: packageFileMode(name),
|
||||
offset: 0
|
||||
}
|
||||
})
|
||||
if (entries.length === 0 || entries.length > 0xffff) {
|
||||
throw new Error('Agent package ZIP entry count is invalid')
|
||||
}
|
||||
const outputHandle = openSync(output, 'w')
|
||||
let outputOffset = 0
|
||||
const archiveHash = createHash('sha256')
|
||||
const writeBytes = (data) => {
|
||||
let offset = 0
|
||||
while (offset < data.byteLength) {
|
||||
const written = writeSync(
|
||||
outputHandle,
|
||||
data,
|
||||
offset,
|
||||
data.byteLength - offset
|
||||
)
|
||||
if (written <= 0) {
|
||||
throw new Error('Agent package archive write made no progress')
|
||||
}
|
||||
offset += written
|
||||
}
|
||||
archiveHash.update(data)
|
||||
outputOffset += data.byteLength
|
||||
}
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
entry.offset = outputOffset
|
||||
const header = Buffer.alloc(30)
|
||||
header.writeUInt32LE(zipLocalHeaderSignature, 0)
|
||||
header.writeUInt16LE(zipVersion, 4)
|
||||
header.writeUInt16LE(zipUtf8Flag, 6)
|
||||
header.writeUInt16LE(0, 8)
|
||||
header.writeUInt16LE(0, 10)
|
||||
header.writeUInt16LE(zipDosDate, 12)
|
||||
header.writeUInt32LE(entry.crc32, 14)
|
||||
header.writeUInt32LE(entry.size, 18)
|
||||
header.writeUInt32LE(entry.size, 22)
|
||||
header.writeUInt16LE(entry.nameBytes.length, 26)
|
||||
header.writeUInt16LE(0, 28)
|
||||
writeBytes(header)
|
||||
writeBytes(entry.nameBytes)
|
||||
|
||||
const inputHandle = openSync(entry.filePath, 'r')
|
||||
try {
|
||||
let position = 0
|
||||
while (position < entry.size) {
|
||||
const bytesRead = readSync(
|
||||
inputHandle,
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, entry.size - position),
|
||||
null
|
||||
)
|
||||
if (bytesRead <= 0) {
|
||||
throw new Error(
|
||||
`Agent package source read made no progress: ${entry.name}`
|
||||
)
|
||||
}
|
||||
position += bytesRead
|
||||
writeBytes(buffer.subarray(0, bytesRead))
|
||||
}
|
||||
} finally {
|
||||
closeSync(inputHandle)
|
||||
}
|
||||
}
|
||||
|
||||
const centralOffset = outputOffset
|
||||
for (const entry of entries) {
|
||||
const record = Buffer.alloc(46)
|
||||
record.writeUInt32LE(zipCentralHeaderSignature, 0)
|
||||
record.writeUInt16LE(zipMadeByUnix, 4)
|
||||
record.writeUInt16LE(zipVersion, 6)
|
||||
record.writeUInt16LE(zipUtf8Flag, 8)
|
||||
record.writeUInt16LE(0, 10)
|
||||
record.writeUInt16LE(0, 12)
|
||||
record.writeUInt16LE(zipDosDate, 14)
|
||||
record.writeUInt32LE(entry.crc32, 16)
|
||||
record.writeUInt32LE(entry.size, 20)
|
||||
record.writeUInt32LE(entry.size, 24)
|
||||
record.writeUInt16LE(entry.nameBytes.length, 28)
|
||||
record.writeUInt16LE(0, 30)
|
||||
record.writeUInt16LE(0, 32)
|
||||
record.writeUInt16LE(0, 34)
|
||||
record.writeUInt16LE(0, 36)
|
||||
const unixMode =
|
||||
0o100000 | Number.parseInt(entry.mode, 8)
|
||||
record.writeUInt32LE((unixMode << 16) >>> 0, 38)
|
||||
record.writeUInt32LE(entry.offset, 42)
|
||||
writeBytes(record)
|
||||
writeBytes(entry.nameBytes)
|
||||
}
|
||||
|
||||
const centralSize = outputOffset - centralOffset
|
||||
const end = Buffer.alloc(22)
|
||||
end.writeUInt32LE(zipEndSignature, 0)
|
||||
end.writeUInt16LE(0, 4)
|
||||
end.writeUInt16LE(0, 6)
|
||||
end.writeUInt16LE(entries.length, 8)
|
||||
end.writeUInt16LE(entries.length, 10)
|
||||
end.writeUInt32LE(centralSize, 12)
|
||||
end.writeUInt32LE(centralOffset, 16)
|
||||
end.writeUInt16LE(0, 20)
|
||||
writeBytes(end)
|
||||
fsyncSync(outputHandle)
|
||||
} finally {
|
||||
closeSync(outputHandle)
|
||||
}
|
||||
return {
|
||||
size: outputOffset,
|
||||
sha256: archiveHash.digest('hex')
|
||||
}
|
||||
}
|
||||
|
||||
function assembleAgentPackage(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const architecture = options.architecture
|
||||
if (
|
||||
!['x64', 'arm64'].includes(architecture) ||
|
||||
!semanticVersionPattern.test(options.minimumDesktopVersion ?? '') ||
|
||||
typeof options.output !== 'string'
|
||||
) {
|
||||
throw new Error('Agent package identity is invalid')
|
||||
}
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
const agentLock =
|
||||
options.agentLock ?? readRuntimeLock(projectRoot)
|
||||
const runtimeLock =
|
||||
options.runtimeLock ?? readRemoteRuntimeLock(projectRoot)
|
||||
const signingIdentity =
|
||||
options.testSigningIdentity ??
|
||||
options.signingIdentity ??
|
||||
productionSigningKey(options.environment ?? process.env)
|
||||
const signingRecord = options.testSigningIdentity
|
||||
? registry.keys.find(
|
||||
(key) => key.keyId === signingIdentity.keyId
|
||||
)
|
||||
: preflightProductionSigningKey({
|
||||
projectRoot,
|
||||
registry,
|
||||
keyId: signingIdentity.keyId
|
||||
})
|
||||
if (!signingRecord) {
|
||||
throw new Error('Agent package signing key is not registered')
|
||||
}
|
||||
if (
|
||||
publicKeySpkiBase64(signingIdentity.privateKey) !==
|
||||
signingRecord.publicKeySpkiBase64
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent package signing private key does not match the trusted registry'
|
||||
)
|
||||
}
|
||||
const agent = verifyLockedBundle(
|
||||
options.agentBundle,
|
||||
architecture,
|
||||
{
|
||||
projectRoot,
|
||||
registry,
|
||||
lock: agentLock,
|
||||
verificationEnvironment: options.testSigningIdentity
|
||||
? 'test'
|
||||
: 'production'
|
||||
}
|
||||
)
|
||||
const runtime = verifyRuntimeBundle(options.runtimeBundle, {
|
||||
projectRoot,
|
||||
architecture,
|
||||
registry,
|
||||
lock: runtimeLock,
|
||||
verificationEnvironment: options.testSigningIdentity
|
||||
? 'test'
|
||||
: 'production'
|
||||
})
|
||||
const expectedArchiveName =
|
||||
`goodbuddy-agent-${agent.manifest.agentVersion}` +
|
||||
`-linux-${architecture}.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}-`)
|
||||
)
|
||||
try {
|
||||
stagePackagePayload({
|
||||
staging,
|
||||
registry,
|
||||
agentLock,
|
||||
runtimeLock,
|
||||
agentBundle: resolve(options.agentBundle),
|
||||
runtimeBundle: runtime.bundleDirectory
|
||||
})
|
||||
const payload = payloadFiles(staging)
|
||||
const initial = {
|
||||
format: 'goodbuddy-agent-package',
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
component: 'agent',
|
||||
version: agent.manifest.agentVersion,
|
||||
minimumDesktopVersion: options.minimumDesktopVersion,
|
||||
platform: 'linux',
|
||||
architecture,
|
||||
signingKeyId: signingIdentity.keyId,
|
||||
agentProtocol: agent.manifest.protocol,
|
||||
remoteRuntime: {
|
||||
runtimeId: runtime.manifest.runtimeId,
|
||||
provider: runtime.manifest.provider,
|
||||
version: runtime.manifest.runtimeVersion,
|
||||
bundleDigest: runtime.manifest.bundleDigest,
|
||||
protocol: runtime.manifest.protocol
|
||||
},
|
||||
contentDigest: `sha256:${'0'.repeat(64)}`,
|
||||
files: payload.files
|
||||
}
|
||||
const descriptor = {
|
||||
...initial,
|
||||
contentDigest: descriptorContentDigest(initial)
|
||||
}
|
||||
const bytes = descriptorBytes(descriptor)
|
||||
const signature = sign(
|
||||
null,
|
||||
Buffer.concat([signatureDomain, bytes]),
|
||||
signingIdentity.privateKey
|
||||
)
|
||||
writeFileSync(join(staging, packageDescriptorName), bytes)
|
||||
writeFileSync(
|
||||
join(staging, packageSignatureName),
|
||||
`${signature.toString('base64')}\n`,
|
||||
'utf8'
|
||||
)
|
||||
const archive = createPackageArchive(
|
||||
staging,
|
||||
options.output,
|
||||
payload.crc32ByPath
|
||||
)
|
||||
return {
|
||||
descriptor,
|
||||
archive: basename(options.output),
|
||||
size: archive.size,
|
||||
sha256: archive.sha256
|
||||
}
|
||||
} finally {
|
||||
rmSync(staging, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function buildAgentPackage(options) {
|
||||
const projectRoot = options.projectRoot ?? root
|
||||
const registry =
|
||||
options.registry ?? readTrustedKeyRegistry(projectRoot)
|
||||
const signingIdentity =
|
||||
options.testSigningIdentity ??
|
||||
options.signingIdentity ??
|
||||
productionSigningKey(options.environment ?? process.env)
|
||||
const agentRoot = mkdtempSync(
|
||||
join(tmpdir(), `goodbuddy-agent-build-${options.architecture}-`)
|
||||
)
|
||||
const runtimeRoot = mkdtempSync(
|
||||
join(tmpdir(), `goodbuddy-runtime-build-${options.architecture}-`)
|
||||
)
|
||||
try {
|
||||
const agentBundle = buildAgentBundle({
|
||||
projectRoot,
|
||||
arch: options.architecture,
|
||||
runtimeArchive: options.nodeArchive,
|
||||
outputDirectory: join(agentRoot, 'agent'),
|
||||
registry,
|
||||
...(options.agentLock ? { lock: options.agentLock } : {}),
|
||||
...(options.testSigningIdentity
|
||||
? { testSigningIdentity: options.testSigningIdentity }
|
||||
: { signingIdentity })
|
||||
})
|
||||
const runtime = buildRuntimeBundle({
|
||||
projectRoot,
|
||||
architecture: options.architecture,
|
||||
runtimeArchive: options.runtimeArchive,
|
||||
outputRoot: runtimeRoot,
|
||||
registry,
|
||||
...(options.runtimeLock ? { lock: options.runtimeLock } : {}),
|
||||
...(options.testSigningIdentity
|
||||
? { testSigningIdentity: options.testSigningIdentity }
|
||||
: { signingIdentity })
|
||||
})
|
||||
return assembleAgentPackage({
|
||||
...options,
|
||||
projectRoot,
|
||||
registry,
|
||||
...(options.testSigningIdentity
|
||||
? {}
|
||||
: { signingIdentity }),
|
||||
agentBundle,
|
||||
runtimeBundle: runtime.bundleDirectory
|
||||
})
|
||||
} finally {
|
||||
rmSync(agentRoot, { recursive: true, force: true })
|
||||
rmSync(runtimeRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const command = argv[0]
|
||||
if (!['build', 'assemble'].includes(command)) {
|
||||
throw new Error(
|
||||
'Usage: agent-package.cjs <build|assemble> --arch <x64|arm64> --minimum-desktop-version <version> --output <archive>'
|
||||
)
|
||||
}
|
||||
const options = {}
|
||||
for (let index = 1; index < argv.length; index += 2) {
|
||||
const flag = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (
|
||||
![
|
||||
'--arch',
|
||||
'--minimum-desktop-version',
|
||||
'--node-archive',
|
||||
'--runtime-archive',
|
||||
'--agent-bundle',
|
||||
'--runtime-bundle',
|
||||
'--output'
|
||||
].includes(flag) ||
|
||||
!value
|
||||
) {
|
||||
throw new Error('Invalid Agent package argument')
|
||||
}
|
||||
options[
|
||||
flag.slice(2).replace(/-([a-z])/gu, (_match, valuePart) =>
|
||||
valuePart.toUpperCase()
|
||||
)
|
||||
] = value
|
||||
}
|
||||
if (
|
||||
!['x64', 'arm64'].includes(options.arch) ||
|
||||
!semanticVersionPattern.test(
|
||||
options.minimumDesktopVersion ?? ''
|
||||
) ||
|
||||
!options.output
|
||||
) {
|
||||
throw new Error('Agent package arguments are incomplete')
|
||||
}
|
||||
const common = {
|
||||
architecture: options.arch,
|
||||
minimumDesktopVersion: options.minimumDesktopVersion,
|
||||
output: resolve(options.output)
|
||||
}
|
||||
if (command === 'build') {
|
||||
if (!options.nodeArchive || !options.runtimeArchive) {
|
||||
throw new Error('Agent package build inputs are incomplete')
|
||||
}
|
||||
return {
|
||||
command,
|
||||
...common,
|
||||
nodeArchive: resolve(options.nodeArchive),
|
||||
runtimeArchive: resolve(options.runtimeArchive)
|
||||
}
|
||||
}
|
||||
if (!options.agentBundle || !options.runtimeBundle) {
|
||||
throw new Error('Agent package assembly inputs are incomplete')
|
||||
}
|
||||
return {
|
||||
command,
|
||||
...common,
|
||||
agentBundle: resolve(options.agentBundle),
|
||||
runtimeBundle: resolve(options.runtimeBundle)
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArguments(argv)
|
||||
const result =
|
||||
options.command === 'build'
|
||||
? buildAgentPackage(options)
|
||||
: assembleAgentPackage(options)
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assembleAgentPackage,
|
||||
buildAgentPackage,
|
||||
canonicalJson,
|
||||
descriptorBytes,
|
||||
descriptorContentDigest,
|
||||
parseArguments,
|
||||
signatureDomain
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${redactSecrets(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)}\n`
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@ const targetDefinitions = [
|
||||
{ platform: 'windows', arch: 'arm64', formats: ['nsis', 'portable'] },
|
||||
{ platform: 'macos', arch: 'x64', formats: ['dmg', 'zip'] },
|
||||
{ platform: 'macos', arch: 'arm64', formats: ['dmg', 'zip'] },
|
||||
{ platform: 'linux', arch: 'x64', formats: ['AppImage', 'deb'] },
|
||||
{ platform: 'linux', arch: 'arm64', formats: ['AppImage', 'deb'] }
|
||||
{ platform: 'linux', arch: 'x64', formats: ['AppImage', 'deb', 'rpm'] },
|
||||
{ platform: 'linux', arch: 'arm64', formats: ['AppImage', 'deb', 'rpm'] }
|
||||
]
|
||||
const allowedExtensions = {
|
||||
nsis: '.exe',
|
||||
@@ -30,7 +30,8 @@ const allowedExtensions = {
|
||||
dmg: '.dmg',
|
||||
zip: '.zip',
|
||||
AppImage: '.AppImage',
|
||||
deb: '.deb'
|
||||
deb: '.deb',
|
||||
rpm: '.rpm'
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
@@ -319,6 +320,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
module.exports = {
|
||||
aggregateRelease,
|
||||
assertSafeName,
|
||||
expectedFormatForFile,
|
||||
parseArguments,
|
||||
targetDefinitions
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
function detectElfArchitecture(buffer) {
|
||||
if (
|
||||
buffer.length < 20 ||
|
||||
buffer[0] !== 0x7f ||
|
||||
buffer.toString('ascii', 1, 4) !== 'ELF' ||
|
||||
![1, 2].includes(buffer[5])
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const machine =
|
||||
buffer[5] === 2
|
||||
? buffer.readUInt16BE(18)
|
||||
: buffer.readUInt16LE(18)
|
||||
if (machine === 62) {
|
||||
return 'x64'
|
||||
}
|
||||
if (machine === 183) {
|
||||
return 'arm64'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function detectBinaryArchitecture(buffer) {
|
||||
if (
|
||||
buffer.length >= 64 &&
|
||||
buffer[0] === 0x4d &&
|
||||
buffer[1] === 0x5a
|
||||
) {
|
||||
const peOffset = buffer.readUInt32LE(0x3c)
|
||||
if (
|
||||
peOffset + 6 <= buffer.length &&
|
||||
buffer.toString('ascii', peOffset, peOffset + 4) === 'PE\0\0'
|
||||
) {
|
||||
const machine = buffer.readUInt16LE(peOffset + 4)
|
||||
if (machine === 0x8664) {
|
||||
return 'x64'
|
||||
}
|
||||
if (machine === 0xaa64) {
|
||||
return 'arm64'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const elfArchitecture = detectElfArchitecture(buffer)
|
||||
if (elfArchitecture) {
|
||||
return elfArchitecture
|
||||
}
|
||||
|
||||
if (buffer.length >= 8) {
|
||||
const littleMagic = buffer.readUInt32LE(0)
|
||||
const bigMagic = buffer.readUInt32BE(0)
|
||||
const cpuType =
|
||||
littleMagic === 0xfeedfacf
|
||||
? buffer.readUInt32LE(4)
|
||||
: bigMagic === 0xfeedfacf
|
||||
? buffer.readUInt32BE(4)
|
||||
: undefined
|
||||
if (cpuType === 0x01000007) {
|
||||
return 'x64'
|
||||
}
|
||||
if (cpuType === 0x0100000c) {
|
||||
return 'arm64'
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectBinaryArchitecture,
|
||||
detectElfArchitecture
|
||||
}
|
||||
+104
-86
@@ -29,19 +29,6 @@ const portablePath = process.env.GOODBUDDY_OUT_DIR
|
||||
const markerName = '.goodbuddy-portable.json'
|
||||
const portableLocales = new Set(['zh-CN.pak', 'en-US.pak'])
|
||||
|
||||
if (process.platform !== 'win32' || process.arch !== 'x64') {
|
||||
throw new Error('Portable 目录当前必须在 Windows x64 上构建')
|
||||
}
|
||||
if (
|
||||
portablePath === parse(portablePath).root ||
|
||||
portablePath === root ||
|
||||
portablePath === outputRoot
|
||||
) {
|
||||
throw new Error(
|
||||
`拒绝使用不安全的输出目录:${portablePath}`
|
||||
)
|
||||
}
|
||||
|
||||
function assertOutputUnlocked(directory) {
|
||||
if (!existsSync(directory)) {
|
||||
return
|
||||
@@ -142,6 +129,9 @@ function portableRequiredPaths(directory) {
|
||||
return [
|
||||
join(directory, 'GoodBuddy.exe'),
|
||||
join(directory, 'resources', 'app.asar'),
|
||||
join(directory, 'resources', 'agent-runtime-lock.json'),
|
||||
join(directory, 'resources', 'remote-runtime-lock.json'),
|
||||
join(directory, 'resources', 'agent-release-keys.json'),
|
||||
join(directory, 'resources', 'icon.ico'),
|
||||
join(directory, 'resources', 'tray-icon.png'),
|
||||
join(
|
||||
@@ -219,7 +209,10 @@ function writePortableMarker(directory) {
|
||||
)
|
||||
}
|
||||
|
||||
function replacePortableOutput(source, destination) {
|
||||
function replacePortableOutput(
|
||||
source,
|
||||
destination
|
||||
) {
|
||||
const replacement = `${destination}.replacement-${process.pid}`
|
||||
const backup = `${destination}.previous-${process.pid}`
|
||||
mkdirSync(dirname(destination), { recursive: true })
|
||||
@@ -279,36 +272,76 @@ function replacePortableOutput(source, destination) {
|
||||
}
|
||||
}
|
||||
|
||||
const electronDist = ensureElectronRuntime()
|
||||
mkdirSync(outputRoot, { recursive: true })
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
function main() {
|
||||
if (process.platform !== 'win32' || process.arch !== 'x64') {
|
||||
throw new Error('Portable 目录当前必须在 Windows x64 上构建')
|
||||
}
|
||||
if (
|
||||
portablePath === parse(portablePath).root ||
|
||||
portablePath === root ||
|
||||
portablePath === outputRoot
|
||||
) {
|
||||
throw new Error(
|
||||
`拒绝使用不安全的输出目录:${portablePath}`
|
||||
)
|
||||
}
|
||||
const electronDist = ensureElectronRuntime()
|
||||
mkdirSync(outputRoot, { recursive: true })
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
|
||||
for (const [label, script, args] of [
|
||||
[
|
||||
'Node 类型检查',
|
||||
join(root, 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
['--noEmit', '-p', 'tsconfig.node.json']
|
||||
],
|
||||
[
|
||||
'Renderer 类型检查',
|
||||
join(root, 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
['--noEmit', '-p', 'tsconfig.web.json']
|
||||
],
|
||||
[
|
||||
'Production bundle',
|
||||
join(
|
||||
root,
|
||||
'node_modules',
|
||||
'electron-vite',
|
||||
'bin',
|
||||
'electron-vite.js'
|
||||
),
|
||||
['build']
|
||||
]
|
||||
]) {
|
||||
const buildResult = spawnSync(
|
||||
for (const [label, script, args] of [
|
||||
[
|
||||
'Node 类型检查',
|
||||
join(root, 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
['--noEmit', '-p', 'tsconfig.node.json']
|
||||
],
|
||||
[
|
||||
'Renderer 类型检查',
|
||||
join(root, 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
['--noEmit', '-p', 'tsconfig.web.json']
|
||||
],
|
||||
[
|
||||
'Production bundle',
|
||||
join(
|
||||
root,
|
||||
'node_modules',
|
||||
'electron-vite',
|
||||
'bin',
|
||||
'electron-vite.js'
|
||||
),
|
||||
['build']
|
||||
]
|
||||
]) {
|
||||
const buildResult = spawnSync(
|
||||
process.execPath,
|
||||
[script, ...args],
|
||||
{
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: 'inherit',
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (buildResult.error) {
|
||||
throw buildResult.error
|
||||
}
|
||||
if (buildResult.status !== 0) {
|
||||
throw new Error(
|
||||
`${label}失败(code ${buildResult.status ?? 1})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[script, ...args],
|
||||
[
|
||||
join(root, 'node_modules', 'electron-builder', 'cli.js'),
|
||||
'--dir',
|
||||
'--x64',
|
||||
`--config.directories.output=${stagingRoot}`,
|
||||
`--config.electronDist=${electronDist}`
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
@@ -317,55 +350,40 @@ for (const [label, script, args] of [
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (buildResult.error) {
|
||||
throw buildResult.error
|
||||
|
||||
if (result.error) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw result.error
|
||||
}
|
||||
if (buildResult.status !== 0) {
|
||||
if (result.status !== 0) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw new Error(
|
||||
`${label}失败(code ${buildResult.status ?? 1})`
|
||||
`Electron Builder 构建失败(code ${result.status ?? 1})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
join(root, 'node_modules', 'electron-builder', 'cli.js'),
|
||||
'--dir',
|
||||
'--x64',
|
||||
`--config.directories.output=${stagingRoot}`,
|
||||
`--config.electronDist=${electronDist}`
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
shell: false,
|
||||
stdio: 'inherit',
|
||||
windowsHide: true
|
||||
if (!statSync(unpackedPath, {
|
||||
throwIfNoEntry: false
|
||||
})?.isDirectory()) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw new Error('Electron Builder 未生成 portable 目录')
|
||||
}
|
||||
try {
|
||||
pruneLocales(unpackedPath)
|
||||
assertPortableOutput(unpackedPath)
|
||||
replacePortableOutput(unpackedPath, portablePath)
|
||||
} finally {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
}
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw result.error
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw new Error(
|
||||
`Electron Builder 构建失败(code ${result.status ?? 1})`
|
||||
)
|
||||
}
|
||||
if (!statSync(unpackedPath, { throwIfNoEntry: false })?.isDirectory()) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
throw new Error('Electron Builder 未生成 portable 目录')
|
||||
}
|
||||
try {
|
||||
pruneLocales(unpackedPath)
|
||||
assertPortableOutput(unpackedPath)
|
||||
replacePortableOutput(unpackedPath, portablePath)
|
||||
} finally {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
console.log(`Portable 目录构建完成:${portablePath}`)
|
||||
console.log(`启动文件:${join(portablePath, 'GoodBuddy.exe')}`)
|
||||
}
|
||||
|
||||
console.log(`Portable 目录构建完成:${portablePath}`)
|
||||
console.log(`启动文件:${join(portablePath, 'GoodBuddy.exe')}`)
|
||||
module.exports = {
|
||||
assertPortableOutput,
|
||||
portableRequiredPaths
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
}
|
||||
|
||||
+86
-156
@@ -5,6 +5,7 @@ const {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
closeSync,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
@@ -35,6 +36,12 @@ const {
|
||||
} = require('@electron/asar')
|
||||
const { Zip, ZipDeflate } = require('fflate')
|
||||
const { sha256File } = require('./file-hash.cjs')
|
||||
const {
|
||||
detectBinaryArchitecture
|
||||
} = require('./binary-architecture.cjs')
|
||||
const {
|
||||
readZipCentralDirectory
|
||||
} = require('./zip-central-directory.cjs')
|
||||
|
||||
const root = join(__dirname, '..')
|
||||
const packageJson = JSON.parse(
|
||||
@@ -45,17 +52,28 @@ const packageLock = JSON.parse(
|
||||
)
|
||||
const productName = packageJson.build?.productName ?? packageJson.name
|
||||
const releaseRoot = join(root, 'dist', 'release')
|
||||
const releaseBuilderConfig = join(
|
||||
root,
|
||||
'build',
|
||||
'electron-builder.release.cjs'
|
||||
)
|
||||
const manifestName = 'release-manifest.json'
|
||||
const portableMarkerName = '.goodbuddy-portable.json'
|
||||
const harnessHostEntry =
|
||||
'out/main/deepseek-harness-host-bootstrap.js'
|
||||
const harnessBundleManifest = 'out/main/package.json'
|
||||
const harnessPackageVersions = {
|
||||
'@deepseek-ai/dsh-agent': '0.1.0-rc.6',
|
||||
'@napi-rs/canvas': '1.0.3',
|
||||
'node-pty': '1.1.0'
|
||||
'@deepseek-ai/dsh-agent':
|
||||
packageLock.packages['node_modules/@deepseek-ai/dsh-agent']
|
||||
?.version,
|
||||
'@napi-rs/canvas':
|
||||
packageLock.packages['node_modules/@napi-rs/canvas']
|
||||
?.version,
|
||||
'node-pty':
|
||||
packageLock.packages['node_modules/node-pty']?.version
|
||||
}
|
||||
const koffiVersion = '3.1.4'
|
||||
const koffiVersion =
|
||||
packageLock.packages['node_modules/koffi']?.version
|
||||
const harnessLicenseFiles = [
|
||||
'agent-client-protocol-Apache-2.0.txt',
|
||||
'deepseek-cordis-MIT.txt',
|
||||
@@ -119,8 +137,8 @@ const platformDefinitions = {
|
||||
},
|
||||
linux: {
|
||||
builderFlag: '--linux',
|
||||
defaultFormats: ['AppImage', 'deb'],
|
||||
supportedFormats: ['AppImage', 'deb'],
|
||||
defaultFormats: ['AppImage', 'deb', 'rpm'],
|
||||
supportedFormats: ['AppImage', 'deb', 'rpm'],
|
||||
unpackedPattern: /^linux(?:-.+)?-unpacked$/u,
|
||||
executable: [packageJson.name],
|
||||
runtimeExecutable: 'opencode'
|
||||
@@ -132,7 +150,8 @@ const formatExtensions = {
|
||||
dmg: '.dmg',
|
||||
zip: '.zip',
|
||||
AppImage: '.AppImage',
|
||||
deb: '.deb'
|
||||
deb: '.deb',
|
||||
rpm: '.rpm'
|
||||
}
|
||||
|
||||
function normalizePlatform(value) {
|
||||
@@ -328,6 +347,8 @@ function buildElectronBuilderArguments(options, outputDirectory) {
|
||||
)]
|
||||
const builderArguments = [
|
||||
join(root, 'node_modules', 'electron-builder', 'cli.js'),
|
||||
'--config',
|
||||
releaseBuilderConfig,
|
||||
definition.builderFlag,
|
||||
...builderFormats,
|
||||
`--${options.arch}`,
|
||||
@@ -382,61 +403,6 @@ function electronBuilderEnvironment(
|
||||
return builderEnvironment
|
||||
}
|
||||
|
||||
function detectBinaryArchitecture(buffer) {
|
||||
if (
|
||||
buffer.length >= 64 &&
|
||||
buffer[0] === 0x4d &&
|
||||
buffer[1] === 0x5a
|
||||
) {
|
||||
const peOffset = buffer.readUInt32LE(0x3c)
|
||||
if (
|
||||
peOffset + 6 <= buffer.length &&
|
||||
buffer.toString('ascii', peOffset, peOffset + 4) === 'PE\0\0'
|
||||
) {
|
||||
const machine = buffer.readUInt16LE(peOffset + 4)
|
||||
if (machine === 0x8664) {
|
||||
return 'x64'
|
||||
}
|
||||
if (machine === 0xaa64) {
|
||||
return 'arm64'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
buffer.length >= 20 &&
|
||||
buffer[0] === 0x7f &&
|
||||
buffer.toString('ascii', 1, 4) === 'ELF'
|
||||
) {
|
||||
const machine =
|
||||
buffer[5] === 2
|
||||
? buffer.readUInt16BE(18)
|
||||
: buffer.readUInt16LE(18)
|
||||
if (machine === 62) {
|
||||
return 'x64'
|
||||
}
|
||||
if (machine === 183) {
|
||||
return 'arm64'
|
||||
}
|
||||
}
|
||||
if (buffer.length >= 8) {
|
||||
const littleMagic = buffer.readUInt32LE(0)
|
||||
const bigMagic = buffer.readUInt32BE(0)
|
||||
const cpuType =
|
||||
littleMagic === 0xfeedfacf
|
||||
? buffer.readUInt32LE(4)
|
||||
: bigMagic === 0xfeedfacf
|
||||
? buffer.readUInt32BE(4)
|
||||
: undefined
|
||||
if (cpuType === 0x01000007) {
|
||||
return 'x64'
|
||||
}
|
||||
if (cpuType === 0x0100000c) {
|
||||
return 'arm64'
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function binaryArchitecture(filePath) {
|
||||
return detectBinaryArchitecture(readChunk(filePath, 4096))
|
||||
}
|
||||
@@ -560,9 +526,10 @@ function targetHarnessPaths(options) {
|
||||
koffiPackage,
|
||||
koffiBinary,
|
||||
nodePtyBinary:
|
||||
options.platform === 'linux'
|
||||
? 'build/Release/pty.node'
|
||||
: `prebuilds/${platformName}-${options.arch}/pty.node`,
|
||||
`prebuilds/${platformName}-${options.arch}/` +
|
||||
(options.platform === 'windows'
|
||||
? 'conpty.node'
|
||||
: 'pty.node'),
|
||||
nodePtyDirectory: `${platformName}-${options.arch}`
|
||||
}
|
||||
}
|
||||
@@ -1038,7 +1005,7 @@ function verifyHarnessPackage(
|
||||
'DeepSeek Harness Canvas'
|
||||
)
|
||||
|
||||
if (options.platform === 'darwin') {
|
||||
if (options.platform === 'macos') {
|
||||
const helper = join(
|
||||
unpackedRoot,
|
||||
'node_modules',
|
||||
@@ -1086,6 +1053,18 @@ function verifyUnpackedOutput(directory, options) {
|
||||
assertFile(applicationExecutable, '应用主程序')
|
||||
assertFile(join(resources, 'app.asar'), '应用 ASAR')
|
||||
assertFile(join(resources, 'release-notes.json'), '版本更新说明')
|
||||
assertFile(
|
||||
join(resources, 'agent-release-keys.json'),
|
||||
'Agent 受信任签名密钥'
|
||||
)
|
||||
assertFile(
|
||||
join(resources, 'agent-runtime-lock.json'),
|
||||
'Agent Runtime 锁定清单'
|
||||
)
|
||||
assertFile(
|
||||
join(resources, 'remote-runtime-lock.json'),
|
||||
'Remote Runtime 锁定清单'
|
||||
)
|
||||
assertFile(runtimeExecutable, 'OpenCode Runtime')
|
||||
assertFile(
|
||||
join(resources, 'runtimes', 'continue', 'dist', 'index.js'),
|
||||
@@ -1300,98 +1279,42 @@ async function createPortableZip(
|
||||
}
|
||||
|
||||
function readZipEntryNames(filePath) {
|
||||
const fileSize = statSync(filePath).size
|
||||
if (fileSize < 22) {
|
||||
throw new Error('Portable ZIP 缺少中央目录')
|
||||
}
|
||||
const endChunkSize = Math.min(fileSize, 65_557)
|
||||
const endChunkStart = fileSize - endChunkSize
|
||||
const endChunk = readChunk(
|
||||
filePath,
|
||||
endChunkSize,
|
||||
endChunkStart
|
||||
)
|
||||
let endOffset = -1
|
||||
for (let index = endChunk.length - 22; index >= 0; index -= 1) {
|
||||
if (
|
||||
endChunk.readUInt32LE(index) === 0x06054b50 &&
|
||||
index + 22 + endChunk.readUInt16LE(index + 20) ===
|
||||
endChunk.length
|
||||
) {
|
||||
endOffset = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if (endOffset < 0) {
|
||||
throw new Error('Portable ZIP 缺少中央目录')
|
||||
}
|
||||
const diskNumber = endChunk.readUInt16LE(endOffset + 4)
|
||||
const centralDisk = endChunk.readUInt16LE(endOffset + 6)
|
||||
const diskEntryCount = endChunk.readUInt16LE(endOffset + 8)
|
||||
const entryCount = endChunk.readUInt16LE(endOffset + 10)
|
||||
const centralSize = endChunk.readUInt32LE(endOffset + 12)
|
||||
const centralOffset = endChunk.readUInt32LE(endOffset + 16)
|
||||
const absoluteEndOffset = endChunkStart + endOffset
|
||||
if (
|
||||
diskNumber !== 0 ||
|
||||
centralDisk !== 0 ||
|
||||
diskEntryCount !== entryCount ||
|
||||
entryCount === 0xffff ||
|
||||
centralSize === 0xffffffff ||
|
||||
centralOffset === 0xffffffff ||
|
||||
entryCount < portableRequiredFiles.length + 1 ||
|
||||
entryCount > maxPortableZipEntries ||
|
||||
centralSize < 46 ||
|
||||
centralSize > maxPortableCentralDirectoryBytes ||
|
||||
centralOffset + centralSize !== absoluteEndOffset
|
||||
) {
|
||||
throw new Error('Portable ZIP 中央目录无效')
|
||||
}
|
||||
const centralDirectory = readChunk(
|
||||
filePath,
|
||||
centralSize,
|
||||
centralOffset
|
||||
)
|
||||
const names = []
|
||||
let offset = 0
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (
|
||||
offset + 46 > centralDirectory.length ||
|
||||
centralDirectory.readUInt32LE(offset) !== 0x02014b50
|
||||
) {
|
||||
throw new Error('Portable ZIP 中央目录条目无效')
|
||||
}
|
||||
const nameLength = centralDirectory.readUInt16LE(offset + 28)
|
||||
const extraLength = centralDirectory.readUInt16LE(offset + 30)
|
||||
const commentLength = centralDirectory.readUInt16LE(offset + 32)
|
||||
const entryLength = 46 + nameLength + extraLength + commentLength
|
||||
if (offset + entryLength > centralDirectory.length) {
|
||||
throw new Error('Portable ZIP 中央目录条目越界')
|
||||
}
|
||||
const name = centralDirectory
|
||||
.subarray(offset + 46, offset + 46 + nameLength)
|
||||
.toString(
|
||||
centralDirectory.readUInt16LE(offset + 8) & 0x0800
|
||||
? 'utf8'
|
||||
: 'latin1'
|
||||
const handle = openSync(filePath, 'r')
|
||||
try {
|
||||
let parsed
|
||||
try {
|
||||
parsed = readZipCentralDirectory(
|
||||
handle,
|
||||
fstatSync(handle).size,
|
||||
{
|
||||
label: 'Portable ZIP',
|
||||
minimumEntries: portableRequiredFiles.length + 1,
|
||||
maximumEntries: maxPortableZipEntries,
|
||||
maximumCentralDirectoryBytes:
|
||||
maxPortableCentralDirectoryBytes
|
||||
}
|
||||
)
|
||||
.replaceAll('\\', '/')
|
||||
if (
|
||||
!name ||
|
||||
name.startsWith('/') ||
|
||||
/^[a-z]:\//iu.test(name) ||
|
||||
name.includes('\0') ||
|
||||
name.split('/').some((part) => part === '..')
|
||||
) {
|
||||
throw new Error(`Portable ZIP 包含不安全路径:${name}`)
|
||||
} catch (error) {
|
||||
throw new Error('Portable ZIP 中央目录无效', {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
names.push(name)
|
||||
offset += entryLength
|
||||
return parsed.entries.map((entry) => {
|
||||
const name = entry.name.replaceAll('\\', '/')
|
||||
if (
|
||||
!name ||
|
||||
name.startsWith('/') ||
|
||||
/^[a-z]:\//iu.test(name) ||
|
||||
name.includes('\0') ||
|
||||
name.split('/').some((part) => part === '..')
|
||||
) {
|
||||
throw new Error(`Portable ZIP 包含不安全路径:${name}`)
|
||||
}
|
||||
return name
|
||||
})
|
||||
} finally {
|
||||
closeSync(handle)
|
||||
}
|
||||
if (offset !== centralDirectory.length) {
|
||||
throw new Error('Portable ZIP 中央目录数量不一致')
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
function verifyPortableZip(filePath) {
|
||||
@@ -1482,6 +1405,12 @@ function verifyArtifactSignature(filePath, format, arch) {
|
||||
) {
|
||||
throw new Error('deb 产物不是有效的 ar 归档')
|
||||
}
|
||||
if (
|
||||
format === 'rpm' &&
|
||||
readChunk(filePath, 4).toString('hex') !== 'edabeedb'
|
||||
) {
|
||||
throw new Error('rpm 产物缺少 RPM lead magic')
|
||||
}
|
||||
}
|
||||
|
||||
async function writeManifest(directory, options) {
|
||||
@@ -1615,7 +1544,7 @@ function printHelp() {
|
||||
默认格式:
|
||||
windows: nsis, portable (ZIP)
|
||||
macos: dmg, zip
|
||||
linux: AppImage, deb`)
|
||||
linux: AppImage, deb, rpm`)
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
@@ -1718,6 +1647,7 @@ module.exports = {
|
||||
replaceOutput,
|
||||
stageTargetRuntimeDependencies,
|
||||
targetRuntimePackageNames,
|
||||
targetHarnessPaths,
|
||||
verifyHarnessPackage,
|
||||
verifyArchiveIntegrity,
|
||||
verifyUnpackedOutput,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { resolve } = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
|
||||
const maximumInstallerBytes = 256 * 1024
|
||||
|
||||
function verifyControlPlanePackageInstaller(
|
||||
filePath = resolve(
|
||||
'out',
|
||||
'main',
|
||||
'remote-package-installer.mjs'
|
||||
)
|
||||
) {
|
||||
const bytes = readFileSync(filePath)
|
||||
if (
|
||||
bytes.byteLength === 0 ||
|
||||
bytes.byteLength > maximumInstallerBytes
|
||||
) {
|
||||
throw new Error(
|
||||
'Control-plane package installer exceeds its transfer limit'
|
||||
)
|
||||
}
|
||||
const source = new TextDecoder('utf-8', {
|
||||
fatal: true,
|
||||
ignoreBOM: true
|
||||
}).decode(bytes)
|
||||
if (
|
||||
/(?:^|\n)\s*(?:import|export)\b[^\r\n]*\bfrom\s*['"]\.{1,2}\//u.test(
|
||||
source
|
||||
) ||
|
||||
/(?:^|\n)\s*import\s*['"]\.{1,2}\//u.test(source) ||
|
||||
/\bimport\s*\(\s*['"]\.{1,2}\//u.test(source)
|
||||
) {
|
||||
throw new Error(
|
||||
'Control-plane package installer is not a standalone bundle'
|
||||
)
|
||||
}
|
||||
const syntax = spawnSync(
|
||||
process.execPath,
|
||||
['--check', filePath],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (syntax.status !== 0) {
|
||||
throw new Error(
|
||||
`Control-plane package installer is not executable: ${
|
||||
syntax.stderr.trim() || syntax.stdout.trim()
|
||||
}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
size: bytes.byteLength,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const result = verifyControlPlanePackageInstaller(
|
||||
process.argv[2]
|
||||
? resolve(process.argv[2])
|
||||
: undefined
|
||||
)
|
||||
process.stdout.write(
|
||||
`Control-plane package installer: ${result.size} bytes, sha256 ${result.sha256}\n`
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
maximumInstallerBytes,
|
||||
verifyControlPlanePackageInstaller
|
||||
}
|
||||
@@ -2,13 +2,17 @@ const { existsSync, readFileSync, rmSync } = require('node:fs')
|
||||
const { gzipSync } = require('node:zlib')
|
||||
const { resolve } = require('node:path')
|
||||
|
||||
// These are hard regression ceilings, not user-experience targets. In
|
||||
// particular, do not lazy-load one peer tab solely to stay below a ceiling.
|
||||
// The Settings ceiling is the validated 2026-08-23 production baseline
|
||||
// (648.78 kB raw / 97.79 kB gzip), rounded up with about 15% review headroom.
|
||||
const rendererBundleBudgets = Object.freeze({
|
||||
initial: Object.freeze({ raw: 3_500_000, gzip: 720_000 }),
|
||||
knowledge: Object.freeze({ raw: 330_000, gzip: 50_000 }),
|
||||
graph: Object.freeze({ raw: 3_500_000, gzip: 720_000 }),
|
||||
activity: Object.freeze({ raw: 55_000, gzip: 9_000 }),
|
||||
magicNotes: Object.freeze({ raw: 630_000, gzip: 130_000 }),
|
||||
settings: Object.freeze({ raw: 650_000, gzip: 105_000 })
|
||||
settings: Object.freeze({ raw: 750_000, gzip: 115_000 })
|
||||
})
|
||||
|
||||
function normalizePath(value) {
|
||||
@@ -29,10 +33,21 @@ function findEntryKey(manifest) {
|
||||
|
||||
function findSourceKey(manifest, sourceSuffix) {
|
||||
const normalizedSuffix = normalizePath(sourceSuffix)
|
||||
const matches = Object.entries(manifest).filter(([key, item]) => {
|
||||
let matches = Object.entries(manifest).filter(([key, item]) => {
|
||||
const source = normalizePath(item.src || key)
|
||||
return source.endsWith(normalizedSuffix)
|
||||
})
|
||||
if (matches.length === 0) {
|
||||
const sourceName = normalizedSuffix
|
||||
.split('/')
|
||||
.at(-1)
|
||||
?.replace(/\.[^.]+$/u, '')
|
||||
matches = Object.entries(manifest).filter(
|
||||
([, item]) =>
|
||||
item?.isDynamicEntry &&
|
||||
item.name === sourceName
|
||||
)
|
||||
}
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected one manifest entry for ${sourceSuffix}, found ${matches.length}`
|
||||
|
||||
@@ -4,15 +4,17 @@ const {
|
||||
writeFileSync
|
||||
} = require('node:fs')
|
||||
const { dirname, resolve } = require('node:path')
|
||||
const {
|
||||
expectedFormatForFile,
|
||||
targetDefinitions
|
||||
} = require('./aggregate-release.cjs')
|
||||
|
||||
const supportedTargets = new Map([
|
||||
['windows-x64', ['nsis', 'portable']],
|
||||
['windows-arm64', ['nsis', 'portable']],
|
||||
['macos-x64', ['dmg', 'zip']],
|
||||
['macos-arm64', ['dmg', 'zip']],
|
||||
['linux-x64', ['AppImage', 'deb']],
|
||||
['linux-arm64', ['AppImage', 'deb']]
|
||||
])
|
||||
const supportedTargets = new Map(
|
||||
targetDefinitions.map((target) => [
|
||||
`${target.platform}-${target.arch}`,
|
||||
target
|
||||
])
|
||||
)
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = {}
|
||||
@@ -81,32 +83,6 @@ function assertFile(file, targetKey) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatForFile(fileName, platform) {
|
||||
if (platform === 'windows') {
|
||||
if (/-setup\.exe$/u.test(fileName)) {
|
||||
return 'nsis'
|
||||
}
|
||||
if (/-portable\.zip$/u.test(fileName)) {
|
||||
return 'portable'
|
||||
}
|
||||
} else if (platform === 'macos') {
|
||||
if (fileName.endsWith('.dmg')) {
|
||||
return 'dmg'
|
||||
}
|
||||
if (fileName.endsWith('.zip')) {
|
||||
return 'zip'
|
||||
}
|
||||
} else if (platform === 'linux') {
|
||||
if (fileName.endsWith('.AppImage')) {
|
||||
return 'AppImage'
|
||||
}
|
||||
if (fileName.endsWith('.deb')) {
|
||||
return 'deb'
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function createSiteRelease(manifest, baseUrlValue) {
|
||||
if (
|
||||
!manifest ||
|
||||
@@ -123,7 +99,8 @@ function createSiteRelease(manifest, baseUrlValue) {
|
||||
const targets = {}
|
||||
for (const target of manifest.targets) {
|
||||
const key = `${target?.platform}-${target?.arch}`
|
||||
const expectedFormats = supportedTargets.get(key)
|
||||
const expectedTarget = supportedTargets.get(key)
|
||||
const expectedFormats = expectedTarget?.formats
|
||||
if (
|
||||
!expectedFormats ||
|
||||
seenTargets.has(key) ||
|
||||
@@ -140,7 +117,10 @@ function createSiteRelease(manifest, baseUrlValue) {
|
||||
const files = {}
|
||||
for (const file of target.files) {
|
||||
assertFile(file, key)
|
||||
const format = formatForFile(file.name, target.platform)
|
||||
const format = expectedFormatForFile(
|
||||
file.name,
|
||||
expectedTarget
|
||||
)
|
||||
if (!format || !expectedFormats.includes(format) || files[format]) {
|
||||
throw new Error(`发布文件格式无效:${file.name}`)
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ async function run() {
|
||||
provider: 'goodbuddy',
|
||||
model: 'qwen-plus',
|
||||
supportsImageInput: false,
|
||||
harnessVersion: '0.1.0-rc.6',
|
||||
harnessVersion: '0.1.0-rc.8',
|
||||
credentialRefs: ['GOODBUDDY_HARNESS_MODEL_API_KEY'],
|
||||
skillPackages: [],
|
||||
extensionPackages: [],
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('../package.json').build
|
||||
@@ -0,0 +1 @@
|
||||
results
|
||||
@@ -0,0 +1,54 @@
|
||||
FROM ubuntu:26.04
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG RUST_TOOLCHAIN=1.93.1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends \
|
||||
binutils-loongarch64-linux-gnu \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
cmake \
|
||||
dpkg-dev \
|
||||
fakeroot \
|
||||
file \
|
||||
g++-loongarch64-linux-gnu \
|
||||
gcc-loongarch64-linux-gnu \
|
||||
libc6-dev-loong64-cross \
|
||||
make \
|
||||
ninja-build \
|
||||
node-gyp \
|
||||
nodejs \
|
||||
npm \
|
||||
pkg-config \
|
||||
python3 \
|
||||
qemu-user \
|
||||
rustup \
|
||||
tar \
|
||||
xz-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN rustup set profile minimal \
|
||||
&& rustup toolchain install "${RUST_TOOLCHAIN}" \
|
||||
--target loongarch64-unknown-linux-gnu
|
||||
|
||||
ENV CC=loongarch64-linux-gnu-gcc \
|
||||
CXX=loongarch64-linux-gnu-g++ \
|
||||
AR=loongarch64-linux-gnu-ar \
|
||||
RANLIB=loongarch64-linux-gnu-ranlib \
|
||||
STRIP=loongarch64-linux-gnu-strip \
|
||||
CARGO_BUILD_TARGET=loongarch64-unknown-linux-gnu \
|
||||
CARGO_TARGET_LOONGARCH64_UNKNOWN_LINUX_GNU_LINKER=loongarch64-linux-gnu-gcc \
|
||||
RUSTUP_TOOLCHAIN=${RUST_TOOLCHAIN}
|
||||
|
||||
COPY toolchain.cmake /opt/goodbuddy/loongarch64-toolchain.cmake
|
||||
COPY probe.sh /usr/local/bin/goodbuddy-loongarch-cross-probe
|
||||
COPY preview-deb.sh /usr/local/bin/goodbuddy-loongarch-preview-deb
|
||||
|
||||
RUN chmod 0755 \
|
||||
/usr/local/bin/goodbuddy-loongarch-cross-probe \
|
||||
/usr/local/bin/goodbuddy-loongarch-preview-deb
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/goodbuddy-loongarch-cross-probe"]
|
||||
@@ -0,0 +1,78 @@
|
||||
# LoongArch cross-compilation probe
|
||||
|
||||
This directory defines an isolated Ubuntu 26.04 amd64 environment for probing
|
||||
GoodBuddy's Linux `loong64` prerequisites. It does not build Electron or
|
||||
OpenCode.
|
||||
|
||||
The image includes:
|
||||
|
||||
- GCC and G++ targeting `loongarch64-linux-gnu`
|
||||
- A LoongArch glibc development sysroot
|
||||
- QEMU user-mode execution
|
||||
- CMake and Ninja
|
||||
- Rust's `loongarch64-unknown-linux-gnu` target
|
||||
- Node.js, npm, and node-gyp
|
||||
|
||||
The probe validates:
|
||||
|
||||
- C, C++, CMake, and Rust LoongArch ELF output
|
||||
- C, C++, and Rust execution through QEMU
|
||||
- A minimal NAPI-RS Rust addon cross-build
|
||||
- A Debian package with `Architecture: loong64`
|
||||
- Koffi 3.1.4's published LoongArch glibc binding and the absence of a musl
|
||||
binding
|
||||
- The current npm availability of an `@napi-rs/canvas` LoongArch package
|
||||
- A source cross-build of node-pty 1.1.0
|
||||
|
||||
The node-pty probe uses its upstream node-gyp 11.4.2 dependency. Ubuntu's
|
||||
distribution node-gyp unconditionally links Linux addons with `-lnode`, which
|
||||
is appropriate for Ubuntu's shared Node.js package but not for Electron native
|
||||
addons.
|
||||
|
||||
Build and run it from this directory on an amd64 Docker host:
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
--tag goodbuddy-loongarch-cross:ubuntu-26.04 \
|
||||
.
|
||||
|
||||
mkdir -p results
|
||||
docker run --rm \
|
||||
--volume "$PWD/results:/results" \
|
||||
goodbuddy-loongarch-cross:ubuntu-26.04
|
||||
```
|
||||
|
||||
Each run writes to a new UTC timestamped directory below `results`. A passing
|
||||
run contains `summary.txt`, `SHA256SUMS`, the generated ELF files, and the
|
||||
probe DEB.
|
||||
|
||||
QEMU only checks user-mode execution against the cross sysroot. Native Electron
|
||||
loading, PTY behavior, desktop integration, GPU, audio, and packaging must
|
||||
still be validated on a real Debian `loong64` host.
|
||||
|
||||
Koffi 3.1.4 currently publishes only `linux_loong64/koffi.node`, not a
|
||||
`musl_loong64` binding. That is sufficient for a Debian desktop package but
|
||||
does not satisfy GoodBuddy Agent's current dual-glibc/musl bundle contract.
|
||||
|
||||
The NAPI-RS probe confirms that a Rust Node addon can be cross-compiled. It
|
||||
does not build Canvas itself, whose Skia dependency requires a separate port
|
||||
and native validation.
|
||||
|
||||
## Experimental desktop preview
|
||||
|
||||
`prepare-input.cjs` creates the architecture-neutral GoodBuddy input archive.
|
||||
`preview-deb.sh` combines it with the verified community Electron 42.3.0
|
||||
LoongArch shell and produces an isolated `goodbuddy-loongarch-preview` DEB.
|
||||
|
||||
The preview:
|
||||
|
||||
- uses a separate application name and user-data directory;
|
||||
- includes the direct model path and Continue runtime;
|
||||
- cross-builds the input lockfile's node-pty version against Electron 42.3.0;
|
||||
- includes Koffi's LoongArch glibc binding;
|
||||
- does not include GoodBuddy Agent, Remote Runtime, OpenCode, or native Canvas;
|
||||
- is not part of the production release matrix.
|
||||
|
||||
See
|
||||
[`docs/development/loongarch-preview-build.md`](../../docs/development/loongarch-preview-build.md)
|
||||
for the complete build and validation procedure.
|
||||
@@ -0,0 +1,108 @@
|
||||
const {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync
|
||||
} = require('node:fs')
|
||||
const { dirname, join, resolve } = require('node:path')
|
||||
const tar = require('tar')
|
||||
|
||||
const root = join(__dirname, '..', '..')
|
||||
const defaultOutput = join(
|
||||
root,
|
||||
'dist',
|
||||
'loongarch-cross',
|
||||
'goodbuddy-loongarch-cross-input.tgz'
|
||||
)
|
||||
const inputPaths = [
|
||||
'LICENSE',
|
||||
'agent-runtime-lock.json',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'remote-runtime-lock.json',
|
||||
'out',
|
||||
'resources/agent-release-keys.json',
|
||||
'resources/release-notes.json',
|
||||
'resources/skills',
|
||||
'build/icon.png',
|
||||
'build/icon-tray.png',
|
||||
'node_modules/@continuedev/cli/package.json',
|
||||
'node_modules/@continuedev/cli/dist/cn.js',
|
||||
'node_modules/@continuedev/cli/dist/index.js',
|
||||
'node_modules/@continuedev/cli/dist/xhr-sync-worker.js',
|
||||
'node_modules/npm'
|
||||
]
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = { output: defaultOutput }
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
if (argument !== '--output') {
|
||||
throw new Error(`未知参数:${argument}`)
|
||||
}
|
||||
const value = argv[index + 1]
|
||||
if (!value) {
|
||||
throw new Error('--output 缺少值')
|
||||
}
|
||||
options.output = resolve(value)
|
||||
index += 1
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function verifyInputs(projectRoot = root) {
|
||||
for (const relativePath of inputPaths) {
|
||||
if (!existsSync(join(projectRoot, relativePath))) {
|
||||
throw new Error(`龙芯交叉构建输入缺失:${relativePath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createInputArchive(outputPath, projectRoot = root) {
|
||||
verifyInputs(projectRoot)
|
||||
const resolvedOutput = resolve(outputPath)
|
||||
const temporaryOutput = `${resolvedOutput}.partial-${process.pid}`
|
||||
mkdirSync(dirname(resolvedOutput), { recursive: true })
|
||||
rmSync(temporaryOutput, { force: true })
|
||||
try {
|
||||
await tar.c(
|
||||
{
|
||||
cwd: projectRoot,
|
||||
file: temporaryOutput,
|
||||
gzip: true,
|
||||
portable: true,
|
||||
noMtime: true
|
||||
},
|
||||
inputPaths
|
||||
)
|
||||
if (!statSync(temporaryOutput).isFile()) {
|
||||
throw new Error('龙芯交叉构建输入归档未生成')
|
||||
}
|
||||
rmSync(resolvedOutput, { force: true })
|
||||
renameSync(temporaryOutput, resolvedOutput)
|
||||
} finally {
|
||||
rmSync(temporaryOutput, { force: true })
|
||||
}
|
||||
return resolvedOutput
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArguments(argv)
|
||||
const output = await createInputArchive(options.output)
|
||||
console.log(`龙芯交叉构建输入已生成:${output}`)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createInputArchive,
|
||||
inputPaths,
|
||||
parseArguments,
|
||||
verifyInputs
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
input_archive="${1:?missing GoodBuddy input archive}"
|
||||
electron_archive="${2:?missing Electron loong64 archive}"
|
||||
output_root="${3:-/preview-output}"
|
||||
|
||||
electron_version="42.3.0"
|
||||
electron_sha256="92b0ca0c9c18ed90166918a4ac1970266c4fa967aee9277031b3b250b905526e"
|
||||
koffi_version="3.1.4"
|
||||
node_gyp_version="11.4.2"
|
||||
preview_iteration="1"
|
||||
application_directory="GoodBuddy-LoongArch-Preview"
|
||||
executable_name="goodbuddy-loongarch-preview"
|
||||
|
||||
assert_file() {
|
||||
if ! test -f "$1"; then
|
||||
echo "missing required file: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_loongarch_elf() {
|
||||
local file_path="$1"
|
||||
assert_file "${file_path}"
|
||||
file "${file_path}"
|
||||
readelf -h "${file_path}" | grep -Eq \
|
||||
'Machine:[[:space:]]+(LoongArch|Loongson Loongarch)'
|
||||
}
|
||||
|
||||
assert_file "${input_archive}"
|
||||
assert_file "${electron_archive}"
|
||||
mkdir -p "${output_root}"
|
||||
|
||||
actual_electron_sha256="$(
|
||||
sha256sum "${electron_archive}" | cut -d ' ' -f 1
|
||||
)"
|
||||
if test "${actual_electron_sha256}" != "${electron_sha256}"; then
|
||||
echo "Electron loong64 archive digest mismatch" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_id="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
work_root="${output_root}/.work-${run_id}"
|
||||
input_root="${work_root}/input"
|
||||
package_root="${work_root}/package"
|
||||
application_root="${package_root}/opt/${application_directory}"
|
||||
resources_root="${application_root}/resources"
|
||||
app_root="${resources_root}/app"
|
||||
artifact_root="${output_root}/${run_id}"
|
||||
|
||||
if test -e "${work_root}" || test -e "${artifact_root}"; then
|
||||
echo "refusing to reuse preview build directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
rm -rf "${work_root}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "${input_root}" "${app_root}" "${artifact_root}"
|
||||
tar -xzf "${input_archive}" -C "${input_root}"
|
||||
|
||||
project_version="$(
|
||||
node -p \
|
||||
"require('${input_root}/package.json').version"
|
||||
)"
|
||||
node_pty_version="$(
|
||||
node -p \
|
||||
"require('${input_root}/package-lock.json').packages['node_modules/node-pty'].version"
|
||||
)"
|
||||
preview_version="${project_version}-loong64-preview.${preview_iteration}"
|
||||
deb_version="${project_version}~loong64preview${preview_iteration}"
|
||||
artifact_name="GoodBuddy-${project_version}-linux-loong64-preview.deb"
|
||||
|
||||
echo "== extract verified Electron shell =="
|
||||
python3 -m zipfile -e "${electron_archive}" "${application_root}"
|
||||
assert_loongarch_elf "${application_root}/electron"
|
||||
mv "${application_root}/electron" \
|
||||
"${application_root}/${executable_name}"
|
||||
chmod 0755 "${application_root}/${executable_name}"
|
||||
rm -f "${resources_root}/default_app.asar"
|
||||
if test -f "${application_root}/chrome-sandbox"; then
|
||||
chmod 4755 "${application_root}/chrome-sandbox"
|
||||
fi
|
||||
|
||||
echo "== install production JavaScript dependencies =="
|
||||
cp "${input_root}/package.json" "${app_root}/package.json"
|
||||
cp "${input_root}/package-lock.json" "${app_root}/package-lock.json"
|
||||
(
|
||||
cd "${app_root}"
|
||||
npm ci \
|
||||
--ignore-scripts \
|
||||
--omit=dev \
|
||||
--cpu=loong64 \
|
||||
--os=linux
|
||||
)
|
||||
|
||||
rm -rf \
|
||||
"${app_root}/node_modules/@esbuild" \
|
||||
"${app_root}/node_modules/@img" \
|
||||
"${app_root}/node_modules/@rollup"
|
||||
find "${app_root}/node_modules/@koromix" \
|
||||
-mindepth 1 \
|
||||
-maxdepth 1 \
|
||||
-type d \
|
||||
-exec rm -rf -- {} + 2>/dev/null || true
|
||||
find "${app_root}/node_modules/@napi-rs" \
|
||||
-mindepth 1 \
|
||||
-maxdepth 1 \
|
||||
-type d \
|
||||
-name 'canvas-*' \
|
||||
-exec rm -rf -- {} + 2>/dev/null || true
|
||||
|
||||
node - "${input_root}" "${app_root}/package.json" \
|
||||
"${preview_version}" <<'NODE'
|
||||
const { readFileSync, writeFileSync } = require('node:fs')
|
||||
const [inputRoot, outputPath, version] = process.argv.slice(2)
|
||||
const metadata = JSON.parse(
|
||||
readFileSync(`${inputRoot}/package.json`, 'utf8')
|
||||
)
|
||||
metadata.name = 'goodbuddy-loongarch-preview'
|
||||
metadata.productName = 'GoodBuddy LoongArch Preview'
|
||||
metadata.desktopName = 'GoodBuddy LoongArch Preview'
|
||||
metadata.version = version
|
||||
metadata.main = './out/main/index.js'
|
||||
delete metadata.build
|
||||
delete metadata.devDependencies
|
||||
delete metadata.optionalDependencies
|
||||
writeFileSync(outputPath, `${JSON.stringify(metadata, null, 2)}\n`)
|
||||
NODE
|
||||
rm -f "${app_root}/package-lock.json"
|
||||
|
||||
echo "== stage target Koffi binding =="
|
||||
koffi_stage="${work_root}/koffi"
|
||||
mkdir -p "${koffi_stage}"
|
||||
koffi_pack_json="$(
|
||||
npm pack \
|
||||
"@koromix/koffi-linux-loong64@${koffi_version}" \
|
||||
--ignore-scripts \
|
||||
--json \
|
||||
--pack-destination "${koffi_stage}"
|
||||
)"
|
||||
koffi_archive="$(
|
||||
node -e \
|
||||
'const value=JSON.parse(process.argv[1]); process.stdout.write(value[0].filename)' \
|
||||
"${koffi_pack_json}"
|
||||
)"
|
||||
koffi_integrity="$(
|
||||
node -p \
|
||||
"require('${input_root}/package-lock.json').packages['node_modules/@koromix/koffi-linux-loong64'].integrity"
|
||||
)"
|
||||
node - "${koffi_stage}/${koffi_archive}" \
|
||||
"${koffi_integrity}" <<'NODE'
|
||||
const { createHash } = require('node:crypto')
|
||||
const { readFileSync } = require('node:fs')
|
||||
const [filePath, integrity] = process.argv.slice(2)
|
||||
const [algorithm, expected] = integrity.split('-', 2)
|
||||
const actual = createHash(algorithm)
|
||||
.update(readFileSync(filePath))
|
||||
.digest('base64')
|
||||
if (actual !== expected) {
|
||||
throw new Error('Koffi archive integrity mismatch')
|
||||
}
|
||||
NODE
|
||||
koffi_target="${app_root}/node_modules/@koromix/koffi-linux-loong64"
|
||||
mkdir -p "${koffi_target}"
|
||||
tar \
|
||||
-xzf "${koffi_stage}/${koffi_archive}" \
|
||||
-C "${koffi_target}" \
|
||||
--strip-components 1
|
||||
koffi_binding="${koffi_target}/linux_loong64/koffi.node"
|
||||
assert_loongarch_elf "${koffi_binding}"
|
||||
|
||||
echo "== cross-build node-pty ${node_pty_version} for Electron ${electron_version} =="
|
||||
node_gyp_root="${work_root}/node-gyp"
|
||||
npm install \
|
||||
--ignore-scripts \
|
||||
--prefix "${node_gyp_root}" \
|
||||
"node-gyp@${node_gyp_version}"
|
||||
node_pty_root="${app_root}/node_modules/node-pty"
|
||||
test "$(
|
||||
node -p "require('${node_pty_root}/package.json').version"
|
||||
)" = "${node_pty_version}"
|
||||
rm -rf "${node_pty_root}/build" "${node_pty_root}/prebuilds"
|
||||
(
|
||||
cd "${node_pty_root}"
|
||||
CC=loongarch64-linux-gnu-gcc \
|
||||
CXX=loongarch64-linux-gnu-g++ \
|
||||
npm_config_arch=loong64 \
|
||||
npm_config_target_arch=loong64 \
|
||||
npm_config_runtime=electron \
|
||||
npm_config_target="${electron_version}" \
|
||||
npm_config_disturl=https://electronjs.org/headers \
|
||||
"${node_gyp_root}/node_modules/.bin/node-gyp" rebuild \
|
||||
--arch=loong64
|
||||
)
|
||||
node_pty_binding="${node_pty_root}/build/Release/pty.node"
|
||||
assert_loongarch_elf "${node_pty_binding}"
|
||||
|
||||
echo "== stage GoodBuddy application and architecture-neutral runtimes =="
|
||||
cp -a "${input_root}/out" "${app_root}/out"
|
||||
mkdir -p \
|
||||
"${resources_root}/licenses" \
|
||||
"${resources_root}/runtimes" \
|
||||
"${resources_root}/skills"
|
||||
cp -a "${input_root}/resources/skills/." "${resources_root}/skills/"
|
||||
cp "${input_root}/resources/release-notes.json" \
|
||||
"${resources_root}/release-notes.json"
|
||||
cp "${input_root}/resources/agent-release-keys.json" \
|
||||
"${resources_root}/agent-release-keys.json"
|
||||
cp "${input_root}/agent-runtime-lock.json" \
|
||||
"${resources_root}/agent-runtime-lock.json"
|
||||
cp "${input_root}/remote-runtime-lock.json" \
|
||||
"${resources_root}/remote-runtime-lock.json"
|
||||
cp "${input_root}/build/icon.png" "${resources_root}/icon.png"
|
||||
cp "${input_root}/build/icon-tray.png" \
|
||||
"${resources_root}/tray-icon.png"
|
||||
cp "${input_root}/LICENSE" \
|
||||
"${resources_root}/licenses/GoodBuddy-0BSD.txt"
|
||||
mkdir -p "${resources_root}/runtimes/continue/dist"
|
||||
cp "${input_root}/node_modules/@continuedev/cli/package.json" \
|
||||
"${resources_root}/runtimes/continue/package.json"
|
||||
cp "${input_root}/node_modules/@continuedev/cli/dist/cn.js" \
|
||||
"${resources_root}/runtimes/continue/dist/cn.js"
|
||||
cp "${input_root}/node_modules/@continuedev/cli/dist/index.js" \
|
||||
"${resources_root}/runtimes/continue/dist/index.js"
|
||||
cp "${input_root}/node_modules/@continuedev/cli/dist/xhr-sync-worker.js" \
|
||||
"${resources_root}/runtimes/continue/dist/xhr-sync-worker.js"
|
||||
cp -a "${input_root}/node_modules/npm" \
|
||||
"${resources_root}/runtimes/npm"
|
||||
|
||||
echo "== reject foreign native addons =="
|
||||
while IFS= read -r -d '' native_addon; do
|
||||
assert_loongarch_elf "${native_addon}"
|
||||
done < <(
|
||||
find "${app_root}/node_modules" \
|
||||
-type f \
|
||||
-name '*.node' \
|
||||
-print0
|
||||
)
|
||||
|
||||
if test -e "${resources_root}/agents" ||
|
||||
test -e "${resources_root}/remote-runtimes" ||
|
||||
test -e "${resources_root}/runtimes/opencode"
|
||||
then
|
||||
echo "preview package unexpectedly contains Agent or OpenCode payload" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== build isolated preview DEB =="
|
||||
mkdir -p \
|
||||
"${package_root}/DEBIAN" \
|
||||
"${package_root}/usr/bin" \
|
||||
"${package_root}/usr/share/applications" \
|
||||
"${package_root}/usr/share/icons/hicolor/512x512/apps"
|
||||
ln -s \
|
||||
"/opt/${application_directory}/${executable_name}" \
|
||||
"${package_root}/usr/bin/${executable_name}"
|
||||
cp "${input_root}/build/icon.png" \
|
||||
"${package_root}/usr/share/icons/hicolor/512x512/apps/${executable_name}.png"
|
||||
cat > "${package_root}/usr/share/applications/${executable_name}.desktop" <<EOF
|
||||
[Desktop Entry]
|
||||
Name=GoodBuddy LoongArch Preview
|
||||
Comment=GoodBuddy LoongArch 主体预览版
|
||||
Exec=/opt/${application_directory}/${executable_name} %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=${executable_name}
|
||||
Categories=Utility;
|
||||
StartupWMClass=GoodBuddy LoongArch Preview
|
||||
EOF
|
||||
cat > "${package_root}/DEBIAN/control" <<EOF
|
||||
Package: goodbuddy-loongarch-preview
|
||||
Version: ${deb_version}
|
||||
Architecture: loong64
|
||||
Maintainer: MesaLogo
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Depends: libasound2t64, libatspi2.0-0t64, libgbm1, libgtk-3-0t64, libnotify4, libnss3, libpulse0, libsecret-1-0, libuuid1, libxss1, libxtst6, xdg-utils
|
||||
Description: GoodBuddy LoongArch desktop preview
|
||||
Experimental LoongArch desktop build without bundled Agent, Remote Runtime,
|
||||
OpenCode, or native Canvas image decoding.
|
||||
EOF
|
||||
|
||||
artifact="${artifact_root}/${artifact_name}"
|
||||
dpkg-deb --root-owner-group --build "${package_root}" "${artifact}"
|
||||
|
||||
echo "== verify preview package =="
|
||||
test "$(dpkg-deb --field "${artifact}" Architecture)" = "loong64"
|
||||
verify_root="${work_root}/verify"
|
||||
mkdir "${verify_root}"
|
||||
dpkg-deb --extract "${artifact}" "${verify_root}"
|
||||
assert_loongarch_elf \
|
||||
"${verify_root}/opt/${application_directory}/${executable_name}"
|
||||
assert_loongarch_elf \
|
||||
"${verify_root}/opt/${application_directory}/resources/app/${node_pty_binding#${app_root}/}"
|
||||
assert_loongarch_elf \
|
||||
"${verify_root}/opt/${application_directory}/resources/app/${koffi_binding#${app_root}/}"
|
||||
|
||||
(
|
||||
cd "${artifact_root}"
|
||||
sha256sum "${artifact_name}" > SHA256SUMS
|
||||
)
|
||||
cat > "${artifact_root}/preview-manifest.json" <<EOF
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"product": "GoodBuddy LoongArch Preview",
|
||||
"goodBuddyVersion": "${project_version}",
|
||||
"previewVersion": "${preview_version}",
|
||||
"electronVersion": "${electron_version}",
|
||||
"architecture": "loong64",
|
||||
"artifact": "${artifact_name}",
|
||||
"bundledAgent": false,
|
||||
"bundledRemoteRuntime": false,
|
||||
"bundledOpenCode": false,
|
||||
"nativeCanvas": false,
|
||||
"continueRuntime": true,
|
||||
"nodePty": true,
|
||||
"koffiGlibc": true,
|
||||
"nativeLaunchVerified": false
|
||||
}
|
||||
EOF
|
||||
|
||||
cat "${artifact_root}/preview-manifest.json"
|
||||
echo "artifact=${artifact}"
|
||||
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
results_root="${1:-/results}"
|
||||
run_id="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
results="${results_root}/${run_id}"
|
||||
mkdir -p "${results}"
|
||||
|
||||
log="${results}/probe.log"
|
||||
exec > >(tee "${log}") 2>&1
|
||||
|
||||
assert_loongarch_elf() {
|
||||
local file_path="$1"
|
||||
file "${file_path}"
|
||||
readelf -h "${file_path}" | grep -Eq \
|
||||
'Machine:[[:space:]]+(LoongArch|Loongson Loongarch)'
|
||||
}
|
||||
|
||||
echo "== tool versions =="
|
||||
loongarch64-linux-gnu-gcc --version | head -n 1
|
||||
loongarch64-linux-gnu-g++ --version | head -n 1
|
||||
loongarch64-linux-gnu-ld --version | head -n 1
|
||||
qemu-loongarch64 --version | head -n 1
|
||||
node --version
|
||||
npm --version
|
||||
node-gyp --version
|
||||
rustc --version
|
||||
cargo --version
|
||||
cmake --version | head -n 1
|
||||
|
||||
echo "== C probe =="
|
||||
cat > "${results}/hello.c" <<'EOF'
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
puts("goodbuddy-loongarch-c-ok");
|
||||
return 0;
|
||||
}
|
||||
EOF
|
||||
|
||||
loongarch64-linux-gnu-gcc \
|
||||
-O2 \
|
||||
-Wall \
|
||||
-Wextra \
|
||||
-Werror \
|
||||
"${results}/hello.c" \
|
||||
-o "${results}/hello-c"
|
||||
assert_loongarch_elf "${results}/hello-c"
|
||||
test "$(
|
||||
qemu-loongarch64 \
|
||||
-L /usr/loongarch64-linux-gnu \
|
||||
"${results}/hello-c"
|
||||
)" = "goodbuddy-loongarch-c-ok"
|
||||
|
||||
echo "== C++ probe =="
|
||||
cat > "${results}/hello.cpp" <<'EOF'
|
||||
#include <iostream>
|
||||
#include <string_view>
|
||||
|
||||
int main() {
|
||||
constexpr std::string_view message{"goodbuddy-loongarch-cpp-ok"};
|
||||
std::cout << message << '\n';
|
||||
return 0;
|
||||
}
|
||||
EOF
|
||||
|
||||
loongarch64-linux-gnu-g++ \
|
||||
-std=c++20 \
|
||||
-O2 \
|
||||
-Wall \
|
||||
-Wextra \
|
||||
-Werror \
|
||||
"${results}/hello.cpp" \
|
||||
-o "${results}/hello-cpp"
|
||||
assert_loongarch_elf "${results}/hello-cpp"
|
||||
test "$(
|
||||
qemu-loongarch64 \
|
||||
-L /usr/loongarch64-linux-gnu \
|
||||
"${results}/hello-cpp"
|
||||
)" = "goodbuddy-loongarch-cpp-ok"
|
||||
|
||||
echo "== CMake probe =="
|
||||
mkdir "${results}/cmake-source"
|
||||
cat > "${results}/cmake-source/CMakeLists.txt" <<'EOF'
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(goodbuddy_loongarch_probe LANGUAGES C)
|
||||
add_executable(goodbuddy-cmake-probe main.c)
|
||||
target_compile_options(
|
||||
goodbuddy-cmake-probe
|
||||
PRIVATE -Wall -Wextra -Werror
|
||||
)
|
||||
EOF
|
||||
cp "${results}/hello.c" "${results}/cmake-source/main.c"
|
||||
cmake \
|
||||
-S "${results}/cmake-source" \
|
||||
-B "${results}/cmake-build" \
|
||||
-G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_TOOLCHAIN_FILE=/opt/goodbuddy/loongarch64-toolchain.cmake
|
||||
cmake --build "${results}/cmake-build"
|
||||
assert_loongarch_elf \
|
||||
"${results}/cmake-build/goodbuddy-cmake-probe"
|
||||
|
||||
echo "== Rust probe =="
|
||||
mkdir -p "${results}/rust-probe/src"
|
||||
cat > "${results}/rust-probe/Cargo.toml" <<'EOF'
|
||||
[package]
|
||||
name = "goodbuddy-loongarch-rust-probe"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
EOF
|
||||
cat > "${results}/rust-probe/src/main.rs" <<'EOF'
|
||||
fn main() {
|
||||
println!("goodbuddy-loongarch-rust-ok");
|
||||
}
|
||||
EOF
|
||||
cargo build \
|
||||
--release \
|
||||
--manifest-path "${results}/rust-probe/Cargo.toml"
|
||||
rust_binary="$(
|
||||
find "${results}/rust-probe/target" \
|
||||
-type f \
|
||||
-name goodbuddy-loongarch-rust-probe \
|
||||
-print \
|
||||
-quit
|
||||
)"
|
||||
test -n "${rust_binary}"
|
||||
assert_loongarch_elf "${rust_binary}"
|
||||
test "$(
|
||||
qemu-loongarch64 \
|
||||
-L /usr/loongarch64-linux-gnu \
|
||||
"${rust_binary}"
|
||||
)" = "goodbuddy-loongarch-rust-ok"
|
||||
|
||||
echo "== NAPI-RS Rust addon probe =="
|
||||
mkdir -p "${results}/napi-rs-probe/src"
|
||||
cat > "${results}/napi-rs-probe/Cargo.toml" <<'EOF'
|
||||
[package]
|
||||
name = "goodbuddy-loongarch-napi-probe"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
napi = { version = "3", default-features = false, features = ["napi8"] }
|
||||
napi-derive = "3"
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2"
|
||||
EOF
|
||||
cat > "${results}/napi-rs-probe/build.rs" <<'EOF'
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
EOF
|
||||
cat > "${results}/napi-rs-probe/src/lib.rs" <<'EOF'
|
||||
use napi_derive::napi;
|
||||
|
||||
#[napi]
|
||||
pub fn architecture_probe() -> &'static str {
|
||||
"loong64"
|
||||
}
|
||||
EOF
|
||||
cargo build \
|
||||
--release \
|
||||
--manifest-path "${results}/napi-rs-probe/Cargo.toml"
|
||||
napi_binding="$(
|
||||
find "${results}/napi-rs-probe/target" \
|
||||
-type f \
|
||||
-name 'libgoodbuddy_loongarch_napi_probe.so' \
|
||||
-print \
|
||||
-quit
|
||||
)"
|
||||
test -n "${napi_binding}"
|
||||
assert_loongarch_elf "${napi_binding}"
|
||||
|
||||
echo "== Debian package probe =="
|
||||
package_root="${results}/deb-root"
|
||||
mkdir -p \
|
||||
"${package_root}/DEBIAN" \
|
||||
"${package_root}/usr/lib/goodbuddy-loongarch-probe"
|
||||
cp "${results}/hello-c" \
|
||||
"${package_root}/usr/lib/goodbuddy-loongarch-probe/hello"
|
||||
cat > "${package_root}/DEBIAN/control" <<'EOF'
|
||||
Package: goodbuddy-loongarch-cross-probe
|
||||
Version: 1.0.0
|
||||
Architecture: loong64
|
||||
Maintainer: MesaLogo
|
||||
Description: GoodBuddy LoongArch cross-compilation probe
|
||||
EOF
|
||||
dpkg-deb --build \
|
||||
"${package_root}" \
|
||||
"${results}/goodbuddy-loongarch-cross-probe_1.0.0_loong64.deb"
|
||||
test "$(
|
||||
dpkg-deb \
|
||||
--field \
|
||||
"${results}/goodbuddy-loongarch-cross-probe_1.0.0_loong64.deb" \
|
||||
Architecture
|
||||
)" = "loong64"
|
||||
|
||||
echo "== Koffi prebuilt probe =="
|
||||
mkdir "${results}/koffi"
|
||||
npm pack \
|
||||
"@koromix/koffi-linux-loong64@3.1.4" \
|
||||
--ignore-scripts \
|
||||
--pack-destination "${results}/koffi"
|
||||
koffi_archive="$(
|
||||
find "${results}/koffi" \
|
||||
-maxdepth 1 \
|
||||
-type f \
|
||||
-name '*.tgz' \
|
||||
-print \
|
||||
-quit
|
||||
)"
|
||||
test -n "${koffi_archive}"
|
||||
mkdir "${results}/koffi/package"
|
||||
tar \
|
||||
-xzf "${koffi_archive}" \
|
||||
-C "${results}/koffi/package" \
|
||||
--strip-components 1
|
||||
koffi_binding="${results}/koffi/package/linux_loong64/koffi.node"
|
||||
assert_loongarch_elf "${koffi_binding}"
|
||||
if test -e "${results}/koffi/package/musl_loong64/koffi.node"; then
|
||||
echo "Unexpected Koffi musl binding is present"
|
||||
exit 1
|
||||
fi
|
||||
echo "Koffi loong64 currently provides a glibc binding only"
|
||||
|
||||
echo "== Canvas package availability probe =="
|
||||
canvas_package_status=unavailable
|
||||
if npm view \
|
||||
"@napi-rs/canvas-linux-loong64-gnu" \
|
||||
version \
|
||||
> "${results}/canvas-package-version.txt" \
|
||||
2> "${results}/canvas-package-error.txt"
|
||||
then
|
||||
canvas_package_status=available
|
||||
fi
|
||||
echo "@napi-rs/canvas loong64 package: ${canvas_package_status}"
|
||||
|
||||
echo "== node-pty cross-build probe =="
|
||||
mkdir "${results}/node-pty"
|
||||
npm pack \
|
||||
"node-pty@1.1.0" \
|
||||
--ignore-scripts \
|
||||
--pack-destination "${results}/node-pty"
|
||||
node_pty_archive="$(
|
||||
find "${results}/node-pty" \
|
||||
-maxdepth 1 \
|
||||
-type f \
|
||||
-name '*.tgz' \
|
||||
-print \
|
||||
-quit
|
||||
)"
|
||||
test -n "${node_pty_archive}"
|
||||
mkdir "${results}/node-pty/package"
|
||||
tar \
|
||||
-xzf "${node_pty_archive}" \
|
||||
-C "${results}/node-pty/package" \
|
||||
--strip-components 1
|
||||
(
|
||||
cd "${results}/node-pty/package"
|
||||
npm install --ignore-scripts --omit=dev
|
||||
npm install \
|
||||
--ignore-scripts \
|
||||
--no-save \
|
||||
"node-gyp@11.4.2"
|
||||
CC=loongarch64-linux-gnu-gcc \
|
||||
CXX=loongarch64-linux-gnu-g++ \
|
||||
npm_config_arch=loong64 \
|
||||
npm_config_target_arch=loong64 \
|
||||
npm_config_runtime=electron \
|
||||
npm_config_target=43.2.0 \
|
||||
npm_config_disturl=https://electronjs.org/headers \
|
||||
./node_modules/.bin/node-gyp rebuild \
|
||||
--arch=loong64
|
||||
)
|
||||
node_pty_binding="${results}/node-pty/package/build/Release/pty.node"
|
||||
assert_loongarch_elf "${node_pty_binding}"
|
||||
|
||||
echo "== summary =="
|
||||
sha256sum \
|
||||
"${results}/hello-c" \
|
||||
"${results}/hello-cpp" \
|
||||
"${rust_binary}" \
|
||||
"${napi_binding}" \
|
||||
"${results}/goodbuddy-loongarch-cross-probe_1.0.0_loong64.deb" \
|
||||
"${koffi_binding}" \
|
||||
"${node_pty_binding}" \
|
||||
> "${results}/SHA256SUMS"
|
||||
|
||||
cat > "${results}/summary.txt" <<EOF
|
||||
result=pass
|
||||
target=loong64
|
||||
c=pass
|
||||
cpp=pass
|
||||
cmake=pass
|
||||
rust=pass
|
||||
napi_rs=pass
|
||||
qemu=pass
|
||||
deb=pass
|
||||
koffi=pass
|
||||
koffi_musl=unavailable
|
||||
canvas_package=${canvas_package_status}
|
||||
node_pty=pass
|
||||
EOF
|
||||
|
||||
cat "${results}/summary.txt"
|
||||
echo "results=${results}"
|
||||
@@ -0,0 +1,14 @@
|
||||
set(CMAKE_SYSTEM_NAME Linux)
|
||||
set(CMAKE_SYSTEM_PROCESSOR loongarch64)
|
||||
|
||||
set(CMAKE_C_COMPILER loongarch64-linux-gnu-gcc)
|
||||
set(CMAKE_CXX_COMPILER loongarch64-linux-gnu-g++)
|
||||
set(CMAKE_AR loongarch64-linux-gnu-ar)
|
||||
set(CMAKE_RANLIB loongarch64-linux-gnu-ranlib)
|
||||
set(CMAKE_STRIP loongarch64-linux-gnu-strip)
|
||||
|
||||
set(CMAKE_FIND_ROOT_PATH /usr/loongarch64-linux-gnu)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -88,7 +88,7 @@ async function main() {
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: '@deepseek-ai/dsh-llm',
|
||||
version: '0.1.0-rc.6',
|
||||
version: '0.1.0-rc.8',
|
||||
private: true,
|
||||
type: 'module'
|
||||
},
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
function preflightRegisteredProductionKey(options) {
|
||||
const keyId = options.keyId
|
||||
if (!keyId) {
|
||||
throw new Error(options.missingKeyIdMessage)
|
||||
}
|
||||
const signingRecord = options.registry.keys.find(
|
||||
(key) => key.keyId === keyId
|
||||
)
|
||||
if (!signingRecord) {
|
||||
throw new Error(
|
||||
`Production ${options.component} public signing key ID "${keyId}" is absent from resources/agent-release-keys.json; provision the matching public key before building a release`
|
||||
)
|
||||
}
|
||||
if (signingRecord.environment !== 'production') {
|
||||
throw new Error(
|
||||
`Production ${options.component} signing key ID "${keyId}" is not registered for production`
|
||||
)
|
||||
}
|
||||
if (
|
||||
options.registry.revocations.some(
|
||||
(revocation) => revocation.keyId === keyId
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Production ${options.component} signing key ID "${keyId}" is revoked`
|
||||
)
|
||||
}
|
||||
return signingRecord
|
||||
}
|
||||
|
||||
module.exports = { preflightRegisteredProductionKey }
|
||||
@@ -1,5 +1,13 @@
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { resolve } = require('node:path')
|
||||
const {
|
||||
targetDefinitions
|
||||
} = require('./aggregate-release.cjs')
|
||||
|
||||
const expectedInstallerCount = targetDefinitions.reduce(
|
||||
(count, target) => count + target.formats.length,
|
||||
0
|
||||
)
|
||||
|
||||
function parseArguments(argv) {
|
||||
if (argv.length !== 2 || argv[0] !== '--manifest' || !argv[1]) {
|
||||
@@ -12,7 +20,7 @@ async function verifySiteRelease(manifest, request = fetch) {
|
||||
const files = Object.values(manifest?.targets ?? {}).flatMap(
|
||||
(target) => Object.values(target?.files ?? {})
|
||||
)
|
||||
if (files.length !== 12) {
|
||||
if (files.length !== expectedInstallerCount) {
|
||||
throw new Error(`官网发布文件数量错误:${files.length}`)
|
||||
}
|
||||
const urls = new Set()
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
const { readSync } = require('node:fs')
|
||||
|
||||
const localFileHeaderSignature = 0x04034b50
|
||||
const centralDirectoryHeaderSignature = 0x02014b50
|
||||
const endOfCentralDirectorySignature = 0x06054b50
|
||||
|
||||
function readExactZipBytes(handle, length, position, label) {
|
||||
const output = Buffer.allocUnsafe(length)
|
||||
let offset = 0
|
||||
while (offset < length) {
|
||||
const bytesRead = readSync(
|
||||
handle,
|
||||
output,
|
||||
offset,
|
||||
length - offset,
|
||||
position + offset
|
||||
)
|
||||
if (bytesRead <= 0) {
|
||||
throw new Error(`${label} is truncated`)
|
||||
}
|
||||
offset += bytesRead
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
function readZipCentralDirectory(handle, archiveSize, limits) {
|
||||
if (archiveSize < 22) {
|
||||
throw new Error(`${limits.label} central directory is missing`)
|
||||
}
|
||||
const endSize = Math.min(archiveSize, 65_557)
|
||||
const endStart = archiveSize - endSize
|
||||
const end = readExactZipBytes(
|
||||
handle,
|
||||
endSize,
|
||||
endStart,
|
||||
`${limits.label} end record`
|
||||
)
|
||||
let endOffset = -1
|
||||
for (let index = end.length - 22; index >= 0; index -= 1) {
|
||||
if (
|
||||
end.readUInt32LE(index) === endOfCentralDirectorySignature &&
|
||||
index + 22 + end.readUInt16LE(index + 20) === end.length
|
||||
) {
|
||||
endOffset = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if (endOffset < 0) {
|
||||
throw new Error(`${limits.label} central directory is missing`)
|
||||
}
|
||||
const diskNumber = end.readUInt16LE(endOffset + 4)
|
||||
const centralDisk = end.readUInt16LE(endOffset + 6)
|
||||
const diskEntryCount = end.readUInt16LE(endOffset + 8)
|
||||
const entryCount = end.readUInt16LE(endOffset + 10)
|
||||
const centralSize = end.readUInt32LE(endOffset + 12)
|
||||
const centralOffset = end.readUInt32LE(endOffset + 16)
|
||||
const absoluteEndOffset = endStart + endOffset
|
||||
if (
|
||||
diskNumber !== 0 ||
|
||||
centralDisk !== 0 ||
|
||||
diskEntryCount !== entryCount ||
|
||||
entryCount === 0 ||
|
||||
entryCount === 0xffff ||
|
||||
entryCount < limits.minimumEntries ||
|
||||
entryCount > limits.maximumEntries ||
|
||||
centralSize === 0xffffffff ||
|
||||
centralSize < 46 ||
|
||||
centralSize > limits.maximumCentralDirectoryBytes ||
|
||||
centralOffset === 0xffffffff ||
|
||||
centralOffset + centralSize !== absoluteEndOffset
|
||||
) {
|
||||
throw new Error(`${limits.label} central directory is invalid`)
|
||||
}
|
||||
const central = readExactZipBytes(
|
||||
handle,
|
||||
centralSize,
|
||||
centralOffset,
|
||||
`${limits.label} central directory`
|
||||
)
|
||||
const entries = []
|
||||
let offset = 0
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (
|
||||
offset + 46 > central.length ||
|
||||
central.readUInt32LE(offset) !==
|
||||
centralDirectoryHeaderSignature
|
||||
) {
|
||||
throw new Error(
|
||||
`${limits.label} central directory entry is invalid`
|
||||
)
|
||||
}
|
||||
const nameLength = central.readUInt16LE(offset + 28)
|
||||
const extraLength = central.readUInt16LE(offset + 30)
|
||||
const commentLength = central.readUInt16LE(offset + 32)
|
||||
const entryLength =
|
||||
46 + nameLength + extraLength + commentLength
|
||||
if (offset + entryLength > central.length) {
|
||||
throw new Error(
|
||||
`${limits.label} central directory entry exceeds its bounds`
|
||||
)
|
||||
}
|
||||
const flags = central.readUInt16LE(offset + 8)
|
||||
const nameBytes = central.subarray(
|
||||
offset + 46,
|
||||
offset + 46 + nameLength
|
||||
)
|
||||
const name = new TextDecoder(
|
||||
flags & 0x0800 ? 'utf-8' : 'latin1',
|
||||
{ fatal: true }
|
||||
).decode(nameBytes)
|
||||
entries.push({
|
||||
name,
|
||||
nameBytes: Buffer.from(nameBytes),
|
||||
flags,
|
||||
compression: central.readUInt16LE(offset + 10),
|
||||
checksum: central.readUInt32LE(offset + 16),
|
||||
compressedSize: central.readUInt32LE(offset + 20),
|
||||
size: central.readUInt32LE(offset + 24),
|
||||
diskStart: central.readUInt16LE(offset + 34),
|
||||
localOffset: central.readUInt32LE(offset + 42)
|
||||
})
|
||||
offset += entryLength
|
||||
}
|
||||
if (offset !== central.length) {
|
||||
throw new Error(
|
||||
`${limits.label} central directory count is invalid`
|
||||
)
|
||||
}
|
||||
return {
|
||||
centralOffset,
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
centralDirectoryHeaderSignature,
|
||||
endOfCentralDirectorySignature,
|
||||
localFileHeaderSignature,
|
||||
readExactZipBytes,
|
||||
readZipCentralDirectory
|
||||
}
|
||||
+7
-2
@@ -33,10 +33,14 @@ GoodBuddy 文档按“文档类型 → 功能域”组织。新增文档应先
|
||||
- [本地文本向量模型与连接设计](./architecture/local-text-embedding-model-design.md)
|
||||
- [全双工实时语音交互设计](./architecture/full-duplex-voice-design.md)
|
||||
- [DeepSeek Harness Runtime 设计](./architecture/deepseek-harness-runtime-design.md)
|
||||
- [应用内反馈系统设计与对接](./architecture/in-app-feedback-integration-design.md)
|
||||
- [SSH 远程主机与 GoodBuddy Agent 稳定终态设计](./architecture/remote-host-and-goodbuddy-agent-design.md)
|
||||
- [SSH Host 远程环境准备与控制面直连设计](./architecture/remote-host-environment-provisioning-design.md)
|
||||
- [跨平台助手产品设计](./design/cross-platform-assistant-product-design.md)
|
||||
- [长期助手路线图](./roadmap/long-term-assistant-roadmap.md)
|
||||
- [电脑控制实施状态](./status/computer-control-implementation-status.md)
|
||||
- [知识检索评估](./quality/knowledge-retrieval-evaluation.md)
|
||||
- [GoodBuddy 龙芯(LoongArch)预览版构建与功能列表](./development/loongarch-preview-build.md)
|
||||
- [统一界面设计系统](../UI-DESIGN.md)
|
||||
|
||||
## 目录规则
|
||||
@@ -45,6 +49,7 @@ GoodBuddy 文档按“文档类型 → 功能域”组织。新增文档应先
|
||||
2. 跨功能技术总纲放在 `docs/architecture/`。
|
||||
3. 产品级设计放在 `docs/design/`,路线图和实施状态分别放在 `roadmap`、`status`。
|
||||
4. 测试方法、评估协议和质量报告放在 `docs/quality/`。
|
||||
5. 一个概念只能有一份权威定义;其他文档链接到它,不复制另一套术语。
|
||||
6. Task、Job、Run、Subagent 和 Conversation 的含义以
|
||||
5. 开发环境、实验性构建和移植说明放在 `docs/development/`。
|
||||
6. 一个概念只能有一份权威定义;其他文档链接到它,不复制另一套术语。
|
||||
7. Task、Job、Run、Subagent 和 Conversation 的含义以
|
||||
[Task 与 Job 统一领域模型](./prd/task-and-job/task-and-job-model.md) 为准。
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| 文档状态 | 实现与发布验收基线 |
|
||||
| 设计目标 | 将 DeepSeek Harness 作为 GoodBuddy 的第三个 Agent Runtime |
|
||||
| Runtime 标识 | `deepseek-harness` |
|
||||
| 首版依赖基线 | 实际使用的 `@deepseek-ai/dsh-*` 底层库,精确锁定 `0.1.0-rc.6` |
|
||||
| 当前依赖基线 | 实际使用的 `@deepseek-ai/dsh-*` 底层库,精确锁定 `0.1.0-rc.8` |
|
||||
| 上游状态 | Developer Preview,允许出现破坏性变更 |
|
||||
| 上游许可证 | MIT |
|
||||
| GoodBuddy 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
|
||||
@@ -446,7 +446,7 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
|
||||
- 文本模型收到图片时必须在启动 Host 或发起模型网络调用前返回明确错误,不能静默丢弃图片。
|
||||
- 图片模型只接受内联 JPEG/PNG,不接受 URL、文件路径、ACP `uri` 或其他媒体类型。
|
||||
- Main 已通过 `nativeImage` 解码用户选择的图片并生成有界模型输入;Utility 仍须独立执行严格 base64、签名、容器结构、CRC(PNG)、完整解码、尺寸和摘要校验,不能把 Main 校验当作跨进程信任替代。
|
||||
- 每条消息最多 8 张图,单图编码后最多 1 MiB,图片合计最多 2 MiB,单图最多 1,600 万像素,累计解码像素最多 3,200 万(重复引用也计入预算)。进程内 Store 另设 32 MiB、256 个唯一对象的总上限。
|
||||
- 每条消息最多 8 张图,单图编码后最多 1 MiB,图片合计最多 2 MiB,单边最长 8,192 px,单图最多 1,600 万像素,累计解码像素最多 3,200 万(重复引用也计入预算)。进程内 Store 另设 32 MiB、256 个唯一对象的总上限。
|
||||
- Attachment Store 只服务当前非持久 Harness Session;引用按 Session 释放,Host 退出时清空,不写入磁盘或 GoodBuddy 第二份会话日志。
|
||||
- GoodBuddy 历史、Prompt、系统指令分别保持不同信任层。
|
||||
- 任何用户文本都不能进入 Cordis 配置表达式或模块名。
|
||||
@@ -459,7 +459,7 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
|
||||
| --- | --- |
|
||||
| 单个 JSON-RPC 帧 | 8 MiB |
|
||||
| 单图 / 单条消息图片 | 1 MiB / 8 张且合计 2 MiB |
|
||||
| 单图 / 单条消息解码像素 | 1,600 万 / 3,200 万 |
|
||||
| 单图尺寸 / 单条消息解码像素 | 单边最长 8,192 px 且 1,600 万像素 / 3,200 万像素 |
|
||||
| Host 临时图片存储 | 32 MiB 且最多 256 个唯一对象 |
|
||||
| 单个文本或推理事件 | 64 KiB |
|
||||
| 单次请求累计协议输出 | 4 MiB |
|
||||
@@ -497,7 +497,7 @@ Runtime: GoodBuddy 内置 DeepSeek Harness
|
||||
模型配置: 跟随 GoodBuddy · 企业网关(qwen-plus)
|
||||
状态: 已就绪
|
||||
路径: <受控 Host 路径>
|
||||
版本: 0.1.0-rc.6
|
||||
版本: 0.1.0-rc.8
|
||||
执行权限: 当前用户权限
|
||||
|
||||
Host 始终由当前 GoodBuddy 版本提供,不存在自定义 Host 入口。
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
# GoodBuddy 应用内反馈系统设计与对接
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 文档类型 | 跨功能产品与技术架构 |
|
||||
| 状态 | 已实施并完成生产联调,待发布 |
|
||||
| 版本 | 1.0 |
|
||||
| 日期 | 2026-08-25 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
|
||||
| 相关基线 | [统一界面设计系统](../../UI-DESIGN.md)、[GoodBuddy 文档导航](../README.md) |
|
||||
|
||||
本文定义 GoodBuddy 桌面端的应用内反馈入口、数据边界、Renderer / Preload /
|
||||
Main 进程职责、公共 API 对接、失败处理和验收方法。反馈平台服务端、管理后台、
|
||||
数据库、附件存储、部署和运营流程由独立项目维护,不在本文复制其内部设计。
|
||||
|
||||
本文是 **GoodBuddy 客户端实现的权威设计**。服务端字段、状态码和限制以反馈平台
|
||||
发布的版本化公共 API 契约为准;两边的同名契约必须通过兼容性测试保持一致,不能各自
|
||||
扩展后继续沿用相同的 `schemaVersion`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 摘要与核心决策
|
||||
|
||||
1. 用户在“设置 → 关于与更新”内直接提交问题、功能建议、体验意见或其他反馈,不再跳转
|
||||
GitHub Issue 页面。
|
||||
2. Renderer 只负责表单、截图预览和状态展示。所有网络请求、安装标识、应用版本和系统
|
||||
环境组装都在 Main 中完成。
|
||||
3. Preload 只暴露用途明确的 `feedback.submit()` 方法,不暴露任意 URL、通用
|
||||
`fetch`、Electron API、文件路径或服务端管理能力。
|
||||
4. 公共请求固定使用版本化接口 `POST /api/v1/feedback`,GoodBuddy 的
|
||||
`productKey` 固定为 `goodbuddy`。
|
||||
5. 反馈服务的生产 HTTPS Origin 是客户端源代码中的受信产品常量,不是用户设置,不从
|
||||
Renderer、项目、Runtime、环境变量、远程配置或服务端重定向中获取。
|
||||
6. GoodBuddy 当前的全局 TLS 策略允许模型和内网服务使用自签名或过期证书。反馈可能包含
|
||||
用户输入和截图,因此 **反馈客户端不得复用全局不校验证书的 Dispatcher 或
|
||||
`globalThis.fetch`**,必须使用独立的 Undici Dispatcher 并显式启用正常证书校验。
|
||||
7. 首版允许一张可选截图。截图只能由用户主动选择或粘贴,发送前必须预览并允许移除;
|
||||
GoodBuddy 不自动截屏。原始输入和 PNG 标准化结果都不能超过 5 MiB。
|
||||
8. Main 在发送前解码并重编码截图为 PNG,确保原始 EXIF/XMP 等元数据不会离开设备;
|
||||
服务端仍独立执行自己的图片验证和标准化。
|
||||
9. 一次新反馈生成一个 `clientRequestId`。网络失败后的手动重试必须复用原 ID,服务端
|
||||
以此保证幂等;创建另一条反馈时才生成新 ID。
|
||||
10. GoodBuddy 使用单独的随机安装 UUID 做服务端限流。该 UUID 不得复用 Agent
|
||||
installation ID、硬件 ID、机器名、用户名、项目 ID 或任何设备指纹。
|
||||
11. 默认不发送对话、提示词、模型回复、日志、文件、项目或工作区路径、知识库、模型配置、
|
||||
Runtime 配置、API Key、Token、Cookie、SSH 信息、剪贴板或屏幕内容。
|
||||
12. 反馈提交失败时保留全部表单内容和截图,成功后显示可复制的服务端反馈编号。
|
||||
13. 首版不提供反馈历史、状态查询、编辑、撤回、自动日志收集或服务端管理入口。
|
||||
14. 生产接口固定为 `https://imp.mesalogo.com/api/v1/feedback`。域名健康检查、
|
||||
TLS、v1 请求校验和客户端调用链必须在发布前一起通过。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标与非目标
|
||||
|
||||
### 2.1 用户目标
|
||||
|
||||
- 不离开 GoodBuddy 即可报告问题或提出建议。
|
||||
- 在发送前明确知道哪些字段会离开设备。
|
||||
- 只在主动选择后上传截图,并能检查、替换或移除截图。
|
||||
- 网络失败后无需重新填写内容。
|
||||
- 提交成功后获得稳定编号,便于后续沟通。
|
||||
|
||||
### 2.2 产品目标
|
||||
|
||||
- 让 GoodBuddy 成为独立多产品反馈平台的第一个客户端。
|
||||
- 保持 Electron 上下文隔离和 Main-only 网络边界。
|
||||
- 使用小而稳定的版本化契约,不把服务端工单、数据库和管理员概念泄漏到客户端。
|
||||
- 对 Windows、macOS、Linux 的 x64 与 arm64 使用相同行为。
|
||||
- 以数据最小化为默认,不把应用内反馈演变为隐式遥测或日志上传。
|
||||
|
||||
### 2.3 非目标
|
||||
|
||||
首版不包含:
|
||||
|
||||
- GitHub Issue 创建、GitHub Token 或 GitHub 网页附件上传。
|
||||
- GoodBuddy 内的管理员后台、处理状态修改或内部备注。
|
||||
- 用户账户、登录、反馈列表、远程状态查询或推送通知。
|
||||
- 自动附加当前对话、日志、错误堆栈、项目、文件、模型或 Runtime 信息。
|
||||
- 自动截取全屏、当前窗口、浏览器、远程桌面或其他应用窗口。
|
||||
- 多截图、视频、录屏、任意文件附件或压缩包。
|
||||
- 离线队列、后台自动重试、指数退避、跨重启草稿或发送历史。
|
||||
- 用户可编辑的反馈 Endpoint、证书豁免、代理凭据或服务端 API Key。
|
||||
- 由 GoodBuddy 推断优先级、自动分类、自动生成描述或代表用户提交。
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户体验
|
||||
|
||||
### 3.1 入口
|
||||
|
||||
入口放在现有“关于与更新”页面,不增加新的设置一级分类。
|
||||
|
||||
在当前版本与更新卡片之后增加“帮助改进 GoodBuddy”能力卡:
|
||||
|
||||
- 标题:`帮助改进 GoodBuddy`
|
||||
- 说明:`报告问题、提出建议或反馈使用体验,我们会在独立反馈系统中处理。`
|
||||
- 主操作:`提交反馈`
|
||||
|
||||
该卡片不显示反馈平台内部状态、管理员信息或 GitHub 链接。服务不可用时仍允许用户打开
|
||||
表单和保留输入;提交失败后提供重试,而不是把用户重定向到另一个渠道。
|
||||
|
||||
### 3.2 反馈对话框
|
||||
|
||||
对话框字段按以下顺序排列:
|
||||
|
||||
1. **反馈类型**,必填:
|
||||
- 问题:`bug`
|
||||
- 功能建议:`feature`
|
||||
- 使用体验:`experience`
|
||||
- 其他:`other`
|
||||
2. **标题**,必填,1 至 120 字符。
|
||||
3. **详细描述**,必填,去除首尾空白后 10 至 5,000 字符。
|
||||
4. **联系邮箱**,可选,最多 254 字符。
|
||||
5. **截图**,可选,最多一张,支持选择 PNG/JPEG/WebP 或在对话框中粘贴图片。
|
||||
6. **将发送的信息**,只读显示应用版本、系统、架构和界面语言。
|
||||
7. **隐私说明**,紧邻提交操作。
|
||||
|
||||
隐私说明使用明确文案:
|
||||
|
||||
> 将发送反馈类型、标题、描述、可选邮箱、GoodBuddy 版本、操作系统、架构、界面语言,
|
||||
> 以及你主动添加的截图。不会自动发送对话、日志、文件、项目路径、模型配置或凭据。
|
||||
|
||||
截图区域必须包含:
|
||||
|
||||
- 缩略预览。
|
||||
- 尺寸和所选文件大小;Main 仍在发送前独立执行 PNG 标准化和大小检查。
|
||||
- `替换截图` 与 `移除截图`。
|
||||
- `截图可能包含画面中可见的个人信息,请在发送前检查。`
|
||||
|
||||
### 3.3 提交状态
|
||||
|
||||
| 状态 | 行为 |
|
||||
| --- | --- |
|
||||
| 未填写 | 字段保持可编辑,提交按钮按字段有效性启用 |
|
||||
| 校验失败 | 在对应字段旁显示错误并把焦点移到第一个无效字段 |
|
||||
| 提交中 | 禁止重复提交、关闭和修改字段,按钮显示“正在提交…” |
|
||||
| 网络或服务失败 | 保留字段和截图,在表单内显示可操作错误及“重试” |
|
||||
| 成功 | 对话框切换为确认面板,显示并允许复制反馈编号 |
|
||||
|
||||
提交成功属于必须保留编号的上下文结果,不再同时发送一条重复的全局成功通知。异步失败也
|
||||
保留在对话框内,因为错误与当前草稿和重试操作直接关联。
|
||||
|
||||
### 3.4 可访问性
|
||||
|
||||
- 对话框使用 `role="dialog"`、`aria-modal="true"` 和可见标题。
|
||||
- 复用 `activateModalFocus()` 与 `trapTabFocus()`,打开后聚焦反馈类型,关闭后恢复入口
|
||||
按钮焦点。
|
||||
- 非提交状态支持 Escape 关闭;提交中保持对话框打开,直到请求完成或到达有界超时。
|
||||
- 字段使用原生 `label`、`select`、`input` 和 `textarea`。
|
||||
- 图片预览提供说明性替代文本,删除和替换按钮有明确可访问名称。
|
||||
- 加载、失败和成功状态使用文字,不依赖颜色。
|
||||
- 浅色、深色和 `prefers-reduced-motion` 行为遵循 `UI-DESIGN.md`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据与隐私边界
|
||||
|
||||
### 4.1 明确发送的数据
|
||||
|
||||
| 数据 | 来源 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `schemaVersion` | Main 常量 | 选择公共契约版本 |
|
||||
| `productKey` | Main 常量 `goodbuddy` | 服务端产品路由 |
|
||||
| `category` | 用户选择 | 分流反馈 |
|
||||
| `title` | 用户输入 | 摘要 |
|
||||
| `description` | 用户输入 | 问题和建议详情 |
|
||||
| `contactEmail` | 用户可选输入 | 必要时联系用户 |
|
||||
| `environment.appVersion` | `app.getVersion()` | 判断受影响版本 |
|
||||
| `environment.platform` | `process.platform` 映射 | 判断受影响平台 |
|
||||
| `environment.architecture` | `process.arch` 映射 | 判断受影响架构 |
|
||||
| `environment.locale` | 当前 GoodBuddy UI 语言 | 理解用户语言环境 |
|
||||
| `installationId` | Main 本地随机 UUID | 服务端安装实例限流 |
|
||||
| `clientRequestId` | 当前草稿随机 UUID | 幂等和安全重试 |
|
||||
| `screenshot` | 用户主动选择或粘贴 | 复现视觉问题 |
|
||||
|
||||
客户端不主动发送来源 IP;IP 是 HTTPS 请求到达服务端时产生的网络元数据。服务端如何保存、
|
||||
散列和保留该元数据由独立反馈平台的隐私与运维策略负责。
|
||||
|
||||
用户填写联系邮箱后,邮箱会作为业务字段通过 HTTPS 发送并由反馈平台保存,不属于端到端
|
||||
加密内容。用户不希望提供联系方式时应留空,GoodBuddy 不尝试从系统账户或其他设置推断
|
||||
邮箱。
|
||||
|
||||
### 4.2 默认不发送的数据
|
||||
|
||||
以下数据不能因为“有助于复现”而隐式加入请求:
|
||||
|
||||
- 当前或历史对话、提示词、模型回复和 Supervisor 内容。
|
||||
- GoodBuddy、Runtime、Agent、SSH、消息通道、浏览器或操作系统日志。
|
||||
- 文件内容、附件、知识库、Magic Notes、成果和工作区改动。
|
||||
- 文件名、项目名、本地路径、远程路径、主机名、用户名或 IP 配置。
|
||||
- LLM、向量模型、语音模型、OCR、MCP、Skill、Runtime 或通道配置。
|
||||
- API Key、Access Token、Cookie、SSH 密码、私钥、代理凭据和其他秘密。
|
||||
- 剪贴板、屏幕、摄像头、麦克风或其他应用内容。
|
||||
- Remote Agent installation ID、Runtime ID、Conversation ID、Task ID 或 Project ID。
|
||||
|
||||
未来若增加日志、诊断包或其他附件,必须单独设计字段、预览、大小限制、用户选择和服务端
|
||||
保留策略,不能复用截图字段静默上传。
|
||||
|
||||
### 4.3 本地数据生命周期
|
||||
|
||||
- 表单草稿、邮箱和截图只保存在当前 Renderer 内存中。
|
||||
- 首版不把草稿、失败请求、成功响应或截图写入 GoodBuddy 数据库和设置文件。
|
||||
- Main 只在请求期间持有标准化图片字节,请求结束后释放引用,不创建临时图片文件。
|
||||
- 成功后关闭确认面板时清空草稿。
|
||||
- `installationId` 单独保存在用户数据目录中的
|
||||
`feedback-identity.json`,格式为版本化 JSON,文件权限沿用私有原子设置文件机制。
|
||||
- “清除本地数据”必须中止进行中的反馈请求并删除该身份文件;下次提交会生成新 UUID。
|
||||
|
||||
---
|
||||
|
||||
## 5. 公共 API 对接
|
||||
|
||||
### 5.1 外部契约所有权
|
||||
|
||||
反馈平台负责维护公共 API 的权威定义。GoodBuddy 只固定客户端实际需要的版本 1 子集:
|
||||
|
||||
```text
|
||||
POST https://imp.mesalogo.com/api/v1/feedback
|
||||
```
|
||||
|
||||
当前生产 Endpoint 已作为 Main-only 产品常量固定。实现和发布验证必须持续保证:
|
||||
|
||||
- 使用 `https:`。
|
||||
- URL 不含用户名、密码、查询参数或 fragment。
|
||||
- Path 精确为 `/api/v1/feedback`。
|
||||
- 对应产品 `goodbuddy` 已在服务端启用。
|
||||
- 桌面端所在网络能够直接访问该 Origin。
|
||||
|
||||
Renderer、Preload、用户设置、环境变量和远程配置都不能覆盖该 Endpoint。域名迁移必须
|
||||
同时修改客户端受信常量、测试、服务端 Origin 配置和部署文档。
|
||||
|
||||
### 5.2 请求 JSON
|
||||
|
||||
无截图时发送 `application/json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"productKey": "goodbuddy",
|
||||
"category": "bug",
|
||||
"title": "魔法笔记页签不可见",
|
||||
"description": "平台功能中的魔法笔记页签被布局压缩,无法看到或点击。",
|
||||
"contactEmail": "user@example.com",
|
||||
"environment": {
|
||||
"appVersion": "0.11.0",
|
||||
"platform": "windows",
|
||||
"architecture": "x64",
|
||||
"locale": "zh-CN"
|
||||
},
|
||||
"installationId": "00000000-0000-4000-8000-000000000101",
|
||||
"clientRequestId": "00000000-0000-4000-8000-000000000102"
|
||||
}
|
||||
```
|
||||
|
||||
有截图时发送 `multipart/form-data`:
|
||||
|
||||
- `payload`:同一 JSON 的字符串形式。
|
||||
- `screenshot`:一张标准化 PNG,文件名固定为 `feedback.png`。
|
||||
|
||||
`contactEmail` 为空时省略,不发送空字符串。请求对象不附加服务端未声明字段。
|
||||
|
||||
### 5.3 成功响应
|
||||
|
||||
HTTP 201 表示首次创建,HTTP 200 表示相同 `clientRequestId` 的幂等结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"reference": "GOODBUDDY-000001",
|
||||
"duplicate": false
|
||||
}
|
||||
```
|
||||
|
||||
客户端对两种成功状态使用相同确认界面。`duplicate: true` 不能显示为失败,它表示先前请求
|
||||
已经成功落库。
|
||||
|
||||
### 5.4 错误映射
|
||||
|
||||
| HTTP / 错误 | 用户结果 |
|
||||
| --- | --- |
|
||||
| 400 | 提示提交内容无效,保留草稿;同时记录为客户端契约缺陷 |
|
||||
| 403 | 提示客户端与反馈服务配置不匹配 |
|
||||
| 404 | 提示反馈服务暂不可用 |
|
||||
| 413 | 提示截图或请求过大,允许移除截图后重试 |
|
||||
| 429 | 提示提交过于频繁,保留草稿并让用户稍后手动重试 |
|
||||
| 500–599 | 提示服务暂时异常,保留草稿 |
|
||||
| 超时 / DNS / TLS / 离线 | 提示无法连接反馈服务,保留草稿 |
|
||||
| 响应格式错误 | 提示服务返回无效结果,不能猜测成功 |
|
||||
|
||||
首版不自动重试。自动重试容易让用户无法判断是否已经发送,也会在服务持续异常时制造额外
|
||||
流量。用户点击“重试”时复用同一个 `clientRequestId`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 进程与模块设计
|
||||
|
||||
### 6.1 调用链
|
||||
|
||||
```text
|
||||
UpdateSettingsSection / FeedbackDialog
|
||||
→ window.goodbuddy.feedback.submit(input)
|
||||
→ preload: ipcRenderer.invoke(feedbackSubmit)
|
||||
→ Main IPC: trusted sender + Zod parse
|
||||
→ FeedbackService: identity + environment + screenshot normalization
|
||||
→ StrictFeedbackHttpClient
|
||||
→ POST /api/v1/feedback
|
||||
→ response schema parse
|
||||
→ Renderer confirmation or inline retry state
|
||||
```
|
||||
|
||||
### 6.2 Shared
|
||||
|
||||
新增 `src/shared/feedback-contracts.ts`,包含:
|
||||
|
||||
- `feedbackCategorySchema`
|
||||
- `feedbackDraftSchema`
|
||||
- `feedbackScreenshotInputSchema`
|
||||
- `feedbackSubmitInputSchema`
|
||||
- `feedbackSubmitResultSchema`
|
||||
- `feedbackPublicPayloadSchema`
|
||||
- 公共字段长度和截图限制常量
|
||||
|
||||
推荐客户端 IPC 输入:
|
||||
|
||||
```ts
|
||||
type FeedbackSubmitInput = {
|
||||
category: 'bug' | 'feature' | 'experience' | 'other'
|
||||
title: string
|
||||
description: string
|
||||
contactEmail?: string
|
||||
locale: 'zh-CN' | 'en-US'
|
||||
clientRequestId: string
|
||||
screenshot?: {
|
||||
data: Uint8Array
|
||||
mimeType: 'image/png' | 'image/jpeg' | 'image/webp'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Renderer 不能传入 `productKey`、`appVersion`、`platform`、`architecture` 或
|
||||
`installationId`。这些字段由 Main 从受信运行环境生成。
|
||||
|
||||
### 6.3 IPC 与 Preload
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
ipcChannels.feedbackSubmit = "feedback:submit"
|
||||
```
|
||||
|
||||
`DesktopApi` 新增必选窄接口:
|
||||
|
||||
```ts
|
||||
feedback: {
|
||||
submit: (input: FeedbackSubmitInput) => Promise<FeedbackSubmitResult>
|
||||
}
|
||||
```
|
||||
|
||||
Main handler 必须:
|
||||
|
||||
1. 调用现有 `assertTrustedSender(event, window)`。
|
||||
2. 使用 Shared Zod schema 解析 `unknown` 输入。
|
||||
3. 不接收 URL、文件路径、Cookie、Header 或任意请求选项。
|
||||
4. 由 `FeedbackService` 跟踪活动 Promise,应用关闭或清除数据时先取消再有界等待。
|
||||
|
||||
Preload 只转发已声明输入,不做网络请求或图片文件读取。
|
||||
|
||||
反馈 handler 独立放在 `feedback/feedback-ipc.ts`,复用共享的
|
||||
`assertTrustedSender()`,避免继续扩展通用 `registerIpcHandlers()` 的位置参数列表。
|
||||
|
||||
### 6.4 FeedbackIdentityStore
|
||||
|
||||
新增 Main-only `FeedbackIdentityStore`:
|
||||
|
||||
- 文件:`<userData>/feedback-identity.json`
|
||||
- Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"installationId": "00000000-0000-4000-8000-000000000101"
|
||||
}
|
||||
```
|
||||
|
||||
- 缺失时使用 `crypto.randomUUID()` 创建。
|
||||
- 使用 `writeJsonFileAtomically()` 写入 0600 临时文件并原子替换。
|
||||
- 读取时使用严格 schema。
|
||||
- 损坏时隔离原文件并生成新身份,不读取或复用 Remote Agent 身份。
|
||||
- 并发首次读取必须合并为同一个 Promise,不能生成两个 UUID。
|
||||
- `clear()` 删除文件和内存缓存,供“清除本地数据”调用。
|
||||
|
||||
该 UUID 是可重置的应用安装级随机标识,不声称代表真实设备或用户。
|
||||
|
||||
### 6.5 FeedbackService
|
||||
|
||||
Main-only `FeedbackService` 负责:
|
||||
|
||||
- 读取或创建安装 UUID。
|
||||
- 映射应用版本、平台和架构。
|
||||
- 验证并标准化可选截图。
|
||||
- 组装严格的公共 payload。
|
||||
- 调用 HTTP Client。
|
||||
- 维持单次提交的 AbortController,并在 dispose / 清除本地数据时取消。
|
||||
|
||||
平台映射固定为:
|
||||
|
||||
| Node 值 | API 值 |
|
||||
| --- | --- |
|
||||
| `win32` | `windows` |
|
||||
| `darwin` | `macos` |
|
||||
| `linux` | `linux` |
|
||||
| 其他 | `unknown` |
|
||||
|
||||
架构只保留 `x64` 和 `arm64`,其他值映射为 `unknown`。
|
||||
|
||||
### 6.6 严格 HTTPS Client
|
||||
|
||||
反馈客户端使用 `undici` 的独立 `Agent`:
|
||||
|
||||
```ts
|
||||
new Agent({
|
||||
connect: {
|
||||
rejectUnauthorized: true
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
这是必要边界,不是额外配置。GoodBuddy 的 `GlobalTlsPolicy` 会给模型、MCP 和内网服务
|
||||
安装不校验证书的全局 Dispatcher;反馈内容不能经过该 Dispatcher。
|
||||
|
||||
每次请求必须满足:
|
||||
|
||||
- 固定、预先校验的 HTTPS URL。
|
||||
- 使用 Undici 原始 `request()`,不安装重定向处理器;任何 3xx 都按服务不可用处理。
|
||||
- 不使用 Cookie Jar、缓存、Referrer 或浏览器凭据模式。
|
||||
- `Accept: application/json`。
|
||||
- 固定、非敏感 `User-Agent`,例如 `GoodBuddy-Feedback/<version>`。
|
||||
- 不发送 `Authorization`、Cookie、API Key 或自定义代理凭据。
|
||||
- 总超时建议 15 秒,最大不得超过 30 秒。
|
||||
- 成功响应体最多 16 KiB,并检查流式实际字节数,不能只相信
|
||||
`Content-Length`。
|
||||
- 只接受 JSON `Content-Type` 和 JSON 响应体,并使用
|
||||
`feedbackSubmitResultSchema` 解析。
|
||||
- 日志只记录有界错误类别和 HTTP 状态,不记录请求正文、邮箱、截图、安装 UUID、
|
||||
`clientRequestId` 或响应编号。
|
||||
|
||||
测试可以注入 Endpoint、Dispatcher 和 Request Transport。生产构造路径不得读取测试覆盖值或
|
||||
环境变量。
|
||||
|
||||
---
|
||||
|
||||
## 7. 截图处理
|
||||
|
||||
### 7.1 Renderer
|
||||
|
||||
- 文件输入限制为 `.png,.jpg,.jpeg,.webp`。
|
||||
- 原始文件上限固定为 5 MiB,超过后不读取完整内容或调用 IPC。
|
||||
- 粘贴事件只处理图片,不拦截普通文本粘贴。
|
||||
- 读取 `File.arrayBuffer()` 后先执行 Shared 字节上限校验。
|
||||
- 使用临时 Object URL 预览,替换、移除或关闭对话框时立即
|
||||
`URL.revokeObjectURL()`。
|
||||
- 不调用 `webUtils.getPathForFile()`,不把本地文件路径传给 Main 或服务端。
|
||||
|
||||
### 7.2 Main
|
||||
|
||||
Main 不信任 Renderer 声明的 MIME:
|
||||
|
||||
1. 校验 PNG、JPEG、WebP 文件签名。
|
||||
2. 使用 `nativeImage.createFromBuffer()` 实际解码。
|
||||
3. 拒绝空图片、异常尺寸、超过 API 公开上限的像素数。
|
||||
4. 使用 `toPNG()` 重编码,去除原始元数据。
|
||||
5. 检查标准化后字节数不超过 5 MiB,最大边长不超过 8,192 像素,总像素数不超过
|
||||
3,200 万。
|
||||
6. 仅把标准化 PNG 放入当前网络请求,不写入磁盘。
|
||||
|
||||
客户端和服务端都执行图片验证属于有意的纵深防御:客户端保证原始元数据不离开设备,
|
||||
服务端保证不能信任任意客户端。
|
||||
|
||||
---
|
||||
|
||||
## 8. Renderer 实施落点
|
||||
|
||||
### 8.1 组件
|
||||
|
||||
- `UpdateSettingsSection.tsx`
|
||||
- 继续负责应用信息和更新卡片。
|
||||
- 渲染反馈入口卡片。
|
||||
- `FeedbackDialog.tsx`
|
||||
- 独立管理草稿、预览、校验、提交、重试和成功编号。
|
||||
- 关闭后销毁草稿状态。
|
||||
对话框不与更新检查状态共用 `saving`、`checking` 或 `error`,避免一个功能失败覆盖另一个
|
||||
功能的状态。
|
||||
|
||||
### 8.2 国际化
|
||||
|
||||
在 `settingsSections` 的 `zh-CN` 与 `en-US` 资源中增加同构键:
|
||||
|
||||
- 入口卡片标题、说明和按钮。
|
||||
- 类型名称。
|
||||
- 字段标签、占位、帮助和校验。
|
||||
- 截图选择、替换、移除和隐私提醒。
|
||||
- 提交中、失败、重试、成功和复制编号。
|
||||
- 数据发送说明。
|
||||
|
||||
组件不得内嵌只存在于一种语言的产品文案。
|
||||
|
||||
### 8.3 样式
|
||||
|
||||
- 复用 `capability-card`、`field`、`primary-button`、`secondary-button` 和现有
|
||||
对话框层级。
|
||||
- 新样式使用 `UI-DESIGN.md` 的语义颜色、间距、圆角和动效令牌。
|
||||
- 对话框桌面端最大宽度建议 600px;窄窗口占可用宽度并保持 16px 页面边距。
|
||||
- 不为反馈入口创建新的一级页面、页签视觉或高饱和营销卡片。
|
||||
|
||||
---
|
||||
|
||||
## 9. 失败、生命周期与恢复
|
||||
|
||||
### 9.1 幂等
|
||||
|
||||
- 打开新草稿时生成 `clientRequestId`。
|
||||
- 字段编辑、截图替换、网络失败和手动重试都不改变 ID。
|
||||
- 成功或显式丢弃草稿后,下一条反馈生成新 ID。
|
||||
- 同一 ID 返回原编号时按成功处理。
|
||||
|
||||
### 9.2 并发
|
||||
|
||||
- 对话框同一时间只允许一个提交。
|
||||
- Main 同一 `clientRequestId` 只运行一个请求。
|
||||
- Main 全局同一时间只发送一条反馈;相同 ID 的重复调用合并,不同 ID 在已有请求期间返回
|
||||
有界 `busy` 结果,不能并行保留多份 5 MiB 截图。
|
||||
- 读取截图使用选择代次;旧文件后完成时必须丢弃并释放 Object URL,不能覆盖较新的截图。
|
||||
|
||||
### 9.3 应用退出与清除数据
|
||||
|
||||
- 反馈请求有独立 AbortController。
|
||||
- 应用退出时取消进行中的请求并关闭严格 TLS Dispatcher。
|
||||
- “清除本地数据”先取消请求,再删除 `feedback-identity.json`。
|
||||
- 失败或取消不写入本地“已发送”状态,服务端是否已接收由同 ID 重试确认。
|
||||
|
||||
### 9.4 服务不可用
|
||||
|
||||
反馈平台不可用时:
|
||||
|
||||
- 保留表单和截图。
|
||||
- 显示明确网络或服务错误。
|
||||
- 不静默丢弃、不自动切换 GitHub、不打开网页、不自动重试。
|
||||
- 其他 GoodBuddy 功能继续可用。
|
||||
|
||||
---
|
||||
|
||||
## 10. 实施文件
|
||||
|
||||
当前实现涉及:
|
||||
|
||||
| 文件或目录 | 变更 |
|
||||
| --- | --- |
|
||||
| `src/shared/feedback-contracts.ts` | 输入、公共 payload、响应和限制 schema |
|
||||
| `src/shared/ipc-channels.ts` | `feedbackSubmit` channel |
|
||||
| `src/shared/contracts.ts` | `DesktopApi.feedback` |
|
||||
| `src/main/feedback/feedback-identity-store.ts` | 随机安装 ID |
|
||||
| `src/main/feedback/feedback-screenshot.ts` | 签名、解码和 PNG 标准化 |
|
||||
| `src/main/feedback/feedback-http-client.ts` | 固定 Endpoint、严格 TLS、有界响应 |
|
||||
| `src/main/feedback/feedback-service.ts` | payload 组装与生命周期 |
|
||||
| `src/main/feedback/feedback-ipc.ts` | 独立 trusted sender、schema parse 和 submit handler |
|
||||
| `src/main/trusted-ipc-sender.ts` | Main IPC 共享 sender / main Frame 校验 |
|
||||
| `src/main/index.ts` | Main service 构造、清理和 IPC 注入 |
|
||||
| `src/preload/index.ts` | 窄 `feedback.submit()` bridge |
|
||||
| `src/renderer/src/FeedbackDialog.tsx` | 表单和成功/失败流程 |
|
||||
| `src/renderer/src/UpdateSettingsSection.tsx` | About 入口卡片 |
|
||||
| `src/renderer/src/i18n/locales/*/settingsSections.ts` | 中英文文案 |
|
||||
| `src/renderer/src/styles.css` | 令牌化布局与响应式样式 |
|
||||
|
||||
每个生产文件应有相邻或现有测试文件覆盖。不要把 Endpoint、截图处理或网络逻辑塞进
|
||||
`ipc.ts` 或 React 组件。
|
||||
|
||||
---
|
||||
|
||||
## 11. 验证
|
||||
|
||||
以下 Shared、Main、IPC、Preload 和 Renderer 行为均由相邻或现有自动化测试覆盖;生产
|
||||
Endpoint 的健康检查和无副作用 schema 探针也必须在发布前通过。
|
||||
|
||||
### 11.1 Shared 合同
|
||||
|
||||
- 接受四种反馈类型和有效字段。
|
||||
- 拒绝未知字段、空标题、短描述、超长描述、错误邮箱、非 UUID 和多截图。
|
||||
- 对截图按字节限制,而不是字符串长度限制。
|
||||
- 请求与反馈平台 `schemaVersion: 1` fixture 双向兼容。
|
||||
|
||||
### 11.2 Main 单元测试
|
||||
|
||||
- 安装 ID 首次创建、并发读取、持久化、损坏隔离和清除。
|
||||
- 不复用 Agent、Runtime、Project 或硬件身份。
|
||||
- 平台和架构映射。
|
||||
- PNG/JPEG/WebP 签名、实际解码、尺寸限制、PNG 重编码和元数据不保留。
|
||||
- 无截图 JSON、有截图 multipart、请求 Header 和固定 URL。
|
||||
- 独立 Dispatcher 显式 `rejectUnauthorized: true`。
|
||||
- 拒绝重定向、非 HTTPS、错误 Origin、超时、超大响应、非 JSON 和错误 response schema。
|
||||
- 4xx、429、5xx 和网络错误映射。
|
||||
- 手动重试复用 `clientRequestId`。
|
||||
- 不同 ID 的并发请求被有界拒绝,清除本地数据期间不能开始新提交。
|
||||
|
||||
### 11.3 IPC 与 Preload
|
||||
|
||||
- 未知窗口和非主 Frame 被拒绝。
|
||||
- Renderer 不能覆盖 `productKey`、版本、平台、架构、安装 ID、Endpoint 或 Header。
|
||||
- Preload 只暴露 `feedback.submit()`。
|
||||
- Sandbox 测试验证没有通用网络或文件路径能力泄漏。
|
||||
|
||||
### 11.4 Renderer
|
||||
|
||||
- About 页面展示反馈卡片。
|
||||
- 表单字段、字符限制、中英文文案和隐私说明。
|
||||
- 普通文本粘贴不被图片处理拦截。
|
||||
- 截图选择、预览、替换、移除和 Object URL 清理。
|
||||
- 并发截图读取按最新选择生效,关闭对话框后不会留下待处理 Object URL。
|
||||
- 失败保留字段,重试使用原 `clientRequestId`。
|
||||
- 成功显示真实编号并清空后续新草稿。
|
||||
- Dialog role、初始焦点、Tab 循环、Escape 和焦点恢复。
|
||||
- 浅色、深色、窄窗口和 reduced motion。
|
||||
|
||||
### 11.5 真实集成
|
||||
|
||||
2026-08-25 已从隔离配置的真实 Electron 生产构建完成一次端到端合成提交:
|
||||
|
||||
- 标题:`[GOODBUDDY INTEGRATION TEST] 2026-08-25`
|
||||
- 类型:`other`
|
||||
- 联系邮箱:未填写
|
||||
- 截图:GoodBuddy 公共应用图标,经 Main 重编码后以 PNG multipart 上传
|
||||
- 服务端编号:`GOODBUDDY-000001`
|
||||
|
||||
该结果确认 About UI、Renderer、Preload、trusted IPC、安装身份、Main 图片处理、独立严格
|
||||
TLS Dispatcher 和生产 v1 API 调用链可以共同工作。发布候选仍应按以下清单复验:
|
||||
|
||||
1. 从打包候选的 About 页面提交一条明确标记的合成反馈。
|
||||
2. 可选添加一张不含个人数据的测试截图。
|
||||
3. 确认客户端显示的编号与管理后台一致。
|
||||
4. 确认版本、平台、架构和语言正确。
|
||||
5. 确认服务端只保存标准化 PNG,不含原始图片元数据。
|
||||
6. 断网后提交,确认草稿保留;恢复网络后以同一 ID 重试且只产生一条记录。
|
||||
7. 使用无效证书的测试服务,确认反馈客户端拒绝连接,即使 GoodBuddy 全局内网 TLS
|
||||
策略允许其他服务继续工作。
|
||||
8. 检查 Renderer 和 Main 日志不含正文、邮箱、截图、安装 ID 或凭据。
|
||||
|
||||
最终仍需运行仓库统一验证:
|
||||
|
||||
```text
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 发布与兼容性
|
||||
|
||||
- `schemaVersion: 1` 与 `/api/v1/feedback` 是同一版本边界。
|
||||
- 向请求增加必填字段、修改枚举或改变字段语义属于破坏性变更,必须发布新 schema 或新
|
||||
API 版本。
|
||||
- GoodBuddy 对响应执行严格解析。服务端需要扩展响应时,应先协调客户端兼容策略。
|
||||
- Endpoint 域名变化属于客户端发布变更,不能依赖 301/302 迁移。
|
||||
- 入口发布与 `goodbuddy` 产品启用状态、生产 TLS 和 v1 契约保持同一发布门槛。
|
||||
- 此功能不迁移现有用户数据;只新增可重置的随机反馈安装身份。
|
||||
- 发布说明应明确:用户现在可以在“关于与更新”内直接提交反馈,邮箱和截图可选,应用不会
|
||||
自动附加对话、日志、文件或凭据。
|
||||
|
||||
---
|
||||
|
||||
## 13. 当前对接状态
|
||||
|
||||
截至 2026-08-25:
|
||||
|
||||
1. 生产 Origin 已确定为 `https://imp.mesalogo.com`,HTTPS、HSTS 和健康检查正常。
|
||||
2. `/api/v1/feedback` 已返回符合 v1 契约的严格校验错误,无副作用探针未创建记录。
|
||||
3. `goodbuddy` 产品已启用,实际提交返回 `GOODBUDDY-000001`。
|
||||
4. GoodBuddy 客户端已完成 Shared、Main、IPC、Preload、About UI、双语文案、截图和
|
||||
生命周期实现。
|
||||
5. 服务端公开 schema、字段长度和 5 MiB 截图上限与客户端一致。
|
||||
6. 隔离 Electron 实例已完成浅色、深色、600×800 窄窗口、焦点与背景隔离、截图预览和
|
||||
真实生产提交验收。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,8 +41,8 @@
|
||||
- 云端模型调用。
|
||||
- LLM Provider、Runtime、Skills、MCP 或扩展下载。
|
||||
- GoodBuddy 应用自身的“检查更新源”。
|
||||
6. 每个模型文件继续固定 Revision、字节数和 SHA-256。两个来源只有提供完全相同的预期
|
||||
文件时,才表示同一个模型工件。
|
||||
6. 每个来源继续固定 Revision、字节数和 SHA-256。两个来源可以为同一模型 ID 提供字节
|
||||
不同的工件,但必须分别声明并校验完整来源包,不能只登记一个未校验链接。
|
||||
7. 一个模型在所选来源缺少任何必需文件时,整个模型在该来源标记“暂不可下载”。不能把
|
||||
同一模型包的部分文件从 ModelScope 下载、部分文件从 Hugging Face 下载。
|
||||
8. 下载任务启动时冻结来源。任务运行期间切换全局设置,不改变、重启或迁移该任务;新的
|
||||
@@ -72,7 +72,7 @@
|
||||
两者均已接入全局模型下载源,并具备:
|
||||
|
||||
- Main 进程下载。
|
||||
- 来源无关的固定文件名、字节数和 SHA-256。
|
||||
- 固定文件名与角色,以及可按来源覆盖的字节数和 SHA-256。
|
||||
- ModelScope 或 Hugging Face 的固定 Revision 与下载 Target。
|
||||
- 随机暂存目录。
|
||||
- 下载进度和取消。
|
||||
@@ -146,8 +146,8 @@
|
||||
| 模型下载源 | 用户选择的 ModelScope 或 Hugging Face |
|
||||
| 模型目录 | GoodBuddy 源码中经过验证的模型元数据集合 |
|
||||
| 模型包 | 一个模型运行所需的全部必需文件和安装清单 |
|
||||
| 下载目标 | 某个来源中一个固定模型文件的 URL 和仓库信息 |
|
||||
| 规范工件 | 由文件名、字节数和 SHA-256 定义的来源无关文件 |
|
||||
| 下载目标 | 某个来源中一个固定模型文件的 URL、仓库、Revision 和可选来源专用指纹 |
|
||||
| 规范工件 | 目录用于旧安装、本地目录和 ZIP 兼容的默认文件指纹 |
|
||||
| 来源覆盖 | 一个来源是否为模型包的全部必需文件提供下载目标 |
|
||||
| 冻结来源 | 下载操作启动时记录且在操作期间不变化的来源 |
|
||||
| 来源回退 | 一个来源失败后由应用自动请求另一个来源 |
|
||||
@@ -232,8 +232,7 @@
|
||||
|
||||
[ 通用设置 ] [ 魔法笔记 ]
|
||||
|
||||
┌ 本地模型 ──────────────────────────────────────────────┐
|
||||
│ 模型下载源 │
|
||||
┌ 模型下载源 ────────────────────────────────────────────┐
|
||||
│ 选择 GoodBuddy 托管本地模型后续下载使用的平台。 │
|
||||
│ 已安装模型和 ZIP 导入不受影响。 │
|
||||
│ │
|
||||
@@ -243,7 +242,6 @@
|
||||
│ ○ Hugging Face │
|
||||
│ 适合可以稳定访问 Hugging Face 的网络环境 │
|
||||
│ │
|
||||
│ 当前选择:ModelScope │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -299,14 +297,10 @@
|
||||
- 当前模型切换。
|
||||
- 活动下载取消。
|
||||
|
||||
### 7.5 活动下载说明
|
||||
### 7.5 活动下载
|
||||
|
||||
来源选项下方持续说明:
|
||||
|
||||
> 正在进行的模型下载会继续使用启动时的来源;新的下载使用当前选择。
|
||||
|
||||
首期不为这段说明新增跨语音、OCR 和向量管理器的活动任务聚合 IPC。具体活动操作继续在
|
||||
对应模型卡显示冻结来源、进度和取消入口。
|
||||
不常驻展示活动下载说明。具体下载操作在对应模型卡显示进度和取消入口;需要显示来源时
|
||||
必须使用任务启动时冻结的来源。切换全局来源不改变已经开始的操作。
|
||||
|
||||
---
|
||||
|
||||
@@ -420,10 +414,11 @@ type StoredApplicationSettingsV7 = {
|
||||
|
||||
## 10. 模型目录契约
|
||||
|
||||
### 10.1 来源无关的规范工件
|
||||
### 10.1 规范工件与来源专用指纹
|
||||
|
||||
当前每个文件把 URL、大小和 SHA-256 放在同一个 `download` 对象中。新契约把工件身份与
|
||||
下载目标拆开:
|
||||
每个文件保留一组默认大小和 SHA-256,用于兼容已有安装、本地目录和 ZIP。下载目标固定
|
||||
来源、仓库和 Revision;当某个来源发布的是同一模型 ID 的另一套字节时,Target 可以同时
|
||||
声明该来源自己的大小和 SHA-256:
|
||||
|
||||
```ts
|
||||
type ModelArtifactTarget = {
|
||||
@@ -431,6 +426,8 @@ type ModelArtifactTarget = {
|
||||
repositoryUrl: string
|
||||
revision: string
|
||||
redirectHosts?: string[]
|
||||
size?: number
|
||||
sha256?: string
|
||||
}
|
||||
|
||||
type ModelArtifactFile = {
|
||||
@@ -450,14 +447,17 @@ type ManagedModelCatalogEntry = {
|
||||
}
|
||||
```
|
||||
|
||||
`size` 和 `sha256` 位于来源外层,表示两个来源必须提供同一规范工件。
|
||||
Target 省略 `size` 和 `sha256` 时继承文件默认指纹;覆盖时两者必须同时出现。下载解析器
|
||||
始终冻结一个来源,并使用该来源每个 Target 的有效指纹计算总大小和完成下载校验。
|
||||
|
||||
如果两个站点提供的文件不是完全相同的字节:
|
||||
目录因此可以表达:
|
||||
|
||||
- 不能把它们放入同一文件的两个 Target。
|
||||
- 不能为了“兼容”使用两个 SHA-256。
|
||||
- 应创建不同模型工件版本、Variant 或不同模型 ID。
|
||||
- 每个 Variant 分别经过运行时和质量验证。
|
||||
- 两个来源提供字节完全一致的镜像;
|
||||
- 两个来源提供文件名和运行角色相同、但大小或 SHA-256 不同的版本;
|
||||
- 只有一个来源提供完整模型包。
|
||||
|
||||
来源差异不能削弱完整性校验。只提供 URL 而没有默认或来源专用指纹、只覆盖大小或
|
||||
SHA-256 之一、使用可变 Revision,都会在目录解析时被拒绝。
|
||||
|
||||
Renderer 使用的目录快照不暴露下载 URL、重定向 Host 或文件存储地址,只返回来源可用性:
|
||||
|
||||
@@ -493,7 +493,7 @@ type ManagedModelSnapshot = {
|
||||
1. 每个必需文件都有该来源的 Target。
|
||||
2. 每个 Target 使用固定 Revision,不使用 `main`、`latest` 或可变 Tag。
|
||||
3. URL、仓库 URL 和重定向 Host 通过 Schema 和来源策略校验。
|
||||
4. 文件大小和 SHA-256 已独立验证。
|
||||
4. 每个文件都有默认指纹或该来源专用指纹,并在下载时按解析后的大小和 SHA-256 校验。
|
||||
|
||||
只要缺少一个文件,该模型在该来源整体不可下载。
|
||||
|
||||
@@ -523,6 +523,7 @@ dictionary.yml ← ModelScope
|
||||
- 模型 ID 唯一。
|
||||
- 文件名和角色唯一。
|
||||
- 每个来源 Target 的 URL 和 Revision 有效。
|
||||
- 来源专用大小和 SHA-256 必须成对出现。
|
||||
- 至少一个来源完整覆盖可下载模型。
|
||||
- 非手动模型不得在两个来源都缺失。
|
||||
- `repositoryUrl` 与下载 Target 属于同一声明来源。
|
||||
@@ -539,15 +540,18 @@ dictionary.yml ← ModelScope
|
||||
| PP-OCRv6 Tiny / Small / Medium | 可下载 | 可下载 |
|
||||
| SenseVoiceSmall INT8 | 可下载 | 可下载 |
|
||||
| Whisper Tiny | 可下载 | 可下载 |
|
||||
| Paraformer 中英双语 / 中英日三语 | 暂不可下载 | 可下载 |
|
||||
| Whisper Small / Medium | 暂不可下载 | 可下载 |
|
||||
| Paraformer 中英双语 | 可下载 | 可下载 |
|
||||
| Paraformer 中粤英三语 | 可下载 | 可下载 |
|
||||
| Whisper Small / Medium | 可下载 | 可下载 |
|
||||
|
||||
OCR 模型包的检测模型与识别模型来自同一下载源中的不同上游仓库。因此每个 Target 都必须
|
||||
符合所声明来源的 Host 策略,而模型卡的主仓库入口必须对应到该来源至少一个实际文件
|
||||
Target。语音模型当前每个模型包的 Target 与主仓库入口保持一致。
|
||||
|
||||
未找到可独立验证且字节完全一致的 Target 时,目录保留部分覆盖,不通过替换模型、
|
||||
改写摘要或请求另一个来源来伪造双来源支持。
|
||||
当前六个语音模型均已登记 ModelScope 和 Hugging Face 固定 Target。Paraformer 中英双语
|
||||
在两个来源使用不同的已验证文件指纹;其余语音模型当前使用字节一致的镜像。来源专用
|
||||
版本不会触发自动回退,也不会允许一次安装混合两个来源。其 ModelScope 版本还必须通过
|
||||
当前捆绑 `sherpa-onnx` 的模型加载和中英样例转写探测,不能仅凭下载 URL 可访问就入库。
|
||||
|
||||
---
|
||||
|
||||
@@ -641,12 +645,12 @@ resolveModelDownloadPackage(files, source)
|
||||
|
||||
### 12.1 来源状态
|
||||
|
||||
语音、OCR、向量和后续模型卡统一显示:
|
||||
语音和 OCR 的当前模型详情各保留一个全局下载源标签。向量内置连接不重复显示平台功能中
|
||||
已经明确的全局来源,也不显示固定本地安装路径;下载仍使用已保存的全局来源。
|
||||
|
||||
- 当前全局下载源。
|
||||
- 当前模型在该来源是否可下载。
|
||||
- 活动操作的冻结来源。
|
||||
- 已安装模型的安装和校验状态。
|
||||
所有模型管理界面都显示当前模型是否可下载、活动操作的进度与取消入口,以及已安装模型
|
||||
的安装和校验状态。活动操作需要显示来源时,使用任务启动时冻结的来源,不能读取后来
|
||||
切换的全局值。
|
||||
|
||||
未安装且来源可用:
|
||||
|
||||
@@ -671,6 +675,7 @@ Hugging Face 暂不提供此模型的完整已验证文件。
|
||||
- 切换来源后继续可用。
|
||||
- 不重新验证网络地址。
|
||||
- 继续按安装 Manifest 中的大小和 SHA-256 验证。
|
||||
- Manifest 必须完整匹配目录中的默认包或任一来源包,不能逐文件拼接不同来源版本。
|
||||
- 不自动重新下载。
|
||||
- 不改变当前模型选择。
|
||||
|
||||
@@ -693,6 +698,7 @@ type InstalledModelProvenance = {
|
||||
ZIP 是来源无关的离线迁移方式:
|
||||
|
||||
- 导入始终按模型 ID、文件名、大小和 SHA-256 验证。
|
||||
- 整个 ZIP 必须匹配默认包或某一个完整来源包,不接受逐文件跨来源组合。
|
||||
- 当前下载源不参与导入兼容判断。
|
||||
- 从 ModelScope 下载的模型可以在选择 Hugging Face 的设备上导入。
|
||||
- 导出可以保留原安装来源作为审计元数据,但不能限制另一设备导入。
|
||||
@@ -780,6 +786,9 @@ ModelScope 和 Hugging Face 都可能跳转到文件存储或 CDN。每个来源
|
||||
- 不允许通配任意公网 Host。
|
||||
- 最终内容仍按固定大小和 SHA-256 验证。
|
||||
|
||||
当前 ModelScope 大文件只允许跳转到已核验的
|
||||
`cdn-lfs-cn-1.modelscope.cn`;小文件可以由 `modelscope.cn` 直接返回。
|
||||
|
||||
重定向到已声明 CDN 属于同一下载源内部传输,不构成下载源切换。UI 继续显示用户选择的
|
||||
逻辑来源。
|
||||
|
||||
@@ -837,7 +846,7 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
|
||||
### 15.4 模型运行
|
||||
|
||||
模型下载源只决定获取规范工件的位置,不进入:
|
||||
模型下载源只决定本次获取哪一个已登记来源包,不进入:
|
||||
|
||||
- 推理配置。
|
||||
- 向量 Provider Fingerprint。
|
||||
@@ -845,8 +854,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
- OCR 模型选择。
|
||||
- 模型质量或速度标签。
|
||||
|
||||
只要最终模型文件 SHA-256 相同,从 ModelScope 或 Hugging Face 安装后运行语义必须完全
|
||||
一致。
|
||||
安装后运行只依赖 Manifest 与磁盘文件匹配一个完整目录包,不依赖当前全局下载源。不同
|
||||
来源可以具有不同 SHA-256;目录中的模型族、语言、质量、速度和许可证说明必须准确覆盖
|
||||
所登记的来源版本。
|
||||
|
||||
---
|
||||
|
||||
@@ -899,7 +909,8 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
|
||||
对语音、OCR 和向量目录分别验证:
|
||||
|
||||
- 两个来源解析到相同文件名、大小和 SHA-256。
|
||||
- 两个来源分别解析到自己的固定文件名、大小和 SHA-256。
|
||||
- Target 未覆盖指纹时继承默认值;只覆盖大小或 SHA-256 之一时拒绝。
|
||||
- 缺少任一 Target 时模型在该来源不可下载。
|
||||
- 不允许一次任务混合来源。
|
||||
- 可变 Revision 被拒绝。
|
||||
@@ -916,7 +927,9 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
- 设置中途变化不改变活动任务。
|
||||
- 新任务使用最新已保存来源。
|
||||
- Renderer 提交过期来源时零网络请求。
|
||||
- 两个来源的安装 Manifest 文件摘要相同。
|
||||
- 两个来源字节不同时,安装 Manifest 分别记录实际来源包摘要。
|
||||
- 旧默认指纹和任一完整来源指纹都能通过安装、ZIP 和运行时校验。
|
||||
- 逐文件命中不同来源的混合包被拒绝。
|
||||
- 取消、关闭、错误和摘要不匹配不留下正式模型目录。
|
||||
- 快照准备会清理名称同时匹配受管模型 ID 与安装 UUID 的陈旧
|
||||
`.install-*` 目录,以及受管文件名对应的孤立 `.partial` 文件;已安装模型、
|
||||
@@ -945,10 +958,10 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
|
||||
### 阶段 2:共享目录和来源解析(已完成)
|
||||
|
||||
- 定义来源无关的规范工件与分来源 Target。
|
||||
- 定义默认规范工件与可覆盖指纹的分来源 Target。
|
||||
- 实现共享 `ModelDownloadTargetResolver`。
|
||||
- 为语音和 OCR 目录补充 ModelScope 与 Hugging Face 固定 Target。
|
||||
- 静态验证两个来源的文件大小和 SHA-256。
|
||||
- 静态验证每个来源解析后的文件大小和 SHA-256。
|
||||
- 更正来源感知文案和仓库入口。
|
||||
|
||||
### 阶段 3:模型管理器(已完成)
|
||||
@@ -982,7 +995,7 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
### 19.2 数据与下载
|
||||
|
||||
- 旧应用设置完整迁移,并得到 ModelScope 默认值。
|
||||
- 每个双来源模型的规范文件名、大小和 SHA-256 相同。
|
||||
- 每个双来源模型的文件名和角色一致;大小和 SHA-256 可以按来源固定。
|
||||
- 单次下载任务只使用一个冻结来源。
|
||||
- 活动下载显示实际冻结来源。
|
||||
- 已安装 Manifest 和 ZIP 兼容性不依赖来源。
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# SSH 远程主机与 GoodBuddy Agent 实现说明
|
||||
|
||||
## 状态
|
||||
|
||||
本文记录截至 2026-08-29 的当前代码实现,不定义额外的信任框架。“新增 Host 只探测、Host 卡片手动准备
|
||||
Agent/Runtime、Host 直接从 GitHub/北京镜像下载、项目始终使用 Host current 环境”已经完成源码接线,
|
||||
详细事务与验收边界见
|
||||
[SSH Host 远程环境准备与直连下载设计](./remote-host-environment-provisioning-design.md);
|
||||
控制面直连源码可直接使用既有 package format v1 包,不等待携带 installer 的新 Agent
|
||||
包,也不通过额外目录元数据判断 bootstrap 能力;公开能力仍等待 GitHub/北京镜像、
|
||||
Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。
|
||||
Windows 到 Linux x64 的既有安装、
|
||||
Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实
|
||||
Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与
|
||||
OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback
|
||||
路径 capability 后,Agent `0.11.7` 已通过独立 workflow 发布 Linux x64/arm64 复合包和
|
||||
签名累计目录;当前源码准备 `0.11.8`,修复 Host 更新并消除重复 payload 验证。失败的
|
||||
`agent-v0.11.3` 保持不可变且未发布。
|
||||
|
||||
## 产品语义
|
||||
|
||||
远程 Host 与项目能力是默认关闭的技术预览。用户在“设置 > 平台功能”的独立
|
||||
“远程项目(技术预览)”页签启用开关后,Renderer 才显示 SSH Host 管理、托管 SSH 项目创建和已保存
|
||||
远程项目;Main 在 IPC 边界执行同一开关校验,避免隐藏界面被绕过。关闭开关不删除 Host、
|
||||
项目或凭据;当前远程项目切回第一个普通本地项目。应用启动仍固定
|
||||
选择普通本地项目,不会因为保存了远程项目而自动连接 Host。
|
||||
|
||||
远程项目只有两种工作模式:
|
||||
|
||||
- **Ask**:Runtime 在操作系统边界以只读方式访问项目 Workspace。
|
||||
- **Execute**:用户已授权使用所选 SSH 账号的完整权限。Runtime 可以使用该账号可访问的文件、进程、网络和工具,不再要求额外 trust tier、consent checklist、逐工具审批或“受控执行”授权。
|
||||
|
||||
Execute 不获得 root 或 SSH 账号本身没有的权限。模型 API Key 与 Provider 认证仍保留在 Electron Main,不传给远端 Runtime。
|
||||
|
||||
## 组件
|
||||
|
||||
```text
|
||||
Renderer
|
||||
-> 窄 Preload / Main IPC
|
||||
Main
|
||||
-> SSH Host 与 Host Key 管理
|
||||
-> SSH connection pool
|
||||
-> 签名 Agent / Runtime 安装
|
||||
-> 远程项目当前环境准备与稳定配置 SQLite 事务
|
||||
-> Main-owned model bridge
|
||||
SSH attach
|
||||
-> 远端私有 Unix socket
|
||||
Detached GoodBuddy Agent
|
||||
-> Workspace / Git 协议
|
||||
-> OpenCode ACP channel
|
||||
-> 直接拥有的 Runtime 进程
|
||||
```
|
||||
|
||||
不提供把远端路径交给本机 Runtime 的伪本地模式,也不提供 SSH stdio Runtime fallback。
|
||||
|
||||
## SSH Host
|
||||
|
||||
- Renderer 只管理脱敏 Host 快照,不接触密码、密文、SSH Client 或任意命令接口。
|
||||
- Main 在认证前取得 Host Key,展示算法与 SHA-256 指纹,并保存完整 key blob。
|
||||
- 首次连接需要用户确认。Host Key 变化展示旧/新值并要求显式替换。
|
||||
- 密码使用 Electron `safeStorage`;系统 SSH Agent 认证不启用 Agent forwarding。
|
||||
- Host 地址、用户或 Host Key generation 变化时关闭旧连接,并定向退役依赖旧 Host identity 的 Workspace 和 Runtime 会话。下一次选择使用 Host 管理的当前连接记录解析 current Agent/Runtime,不读取项目中的旧 revision 或组件 identity;当前 registry、连接或 capability 无效时才要求显式修复。
|
||||
|
||||
## Agent 安装与生命周期
|
||||
|
||||
- Agent bundle 通过 manifest、Ed25519 签名、payload digest、平台和架构校验。
|
||||
- 安装使用当前 SSH 用户目录中的 GoodBuddy-owned 路径和 side-by-side digest 目录。
|
||||
- Agent 代码与固定 Node Runtime 一起位于签名 compound payload。显式安装或重装从本次
|
||||
已完整校验的准备目录原子发布整个 Agent,不扫描旧安装、不创建跨版本 Node 硬链接;
|
||||
Agent 不匹配不会阻止升级,也不会覆盖 GoodBuddy-owned 路径以外的内容。
|
||||
- 桌面包只携带版本 lock 与 production 公钥,不携带远端 payload。Host 卡片只有一个按
|
||||
版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮;次级
|
||||
SegmentedControl 选择“自动”“Host 下载”或“GoodBuddy 传输”,默认“自动”且不持久化。
|
||||
每个复合包绑定 Agent、固定 Node 与桌面维护的 OpenCode Runtime 精确工件。添加或重新
|
||||
验证 Host 只保存并探测,不自动传输完整包。
|
||||
- Host 管理独占 Agent/Runtime 的包准备、更新和完整 payload 验证。已有项目的打开和切换
|
||||
只更新本地项目选择。Workspace 和执行路径按需读取 Host current registry,
|
||||
执行固定 `attach-or-bootstrap` 并复用当前 Agent 连接;同一进程内复用已确认 identity。
|
||||
它们不取得安装包、不发布组件,也不通过 SFTP 重读或哈希 payload。
|
||||
- Agent 的固定 attach/按需启动命令只读取 Host 管理已提交的 installation registry 和
|
||||
与其 digest 匹配的有界 manifest 元数据,不在每次项目切换时重新验签或哈希完整 payload。
|
||||
完整验签、payload 扫描和 registry 写入仍只发生在显式 Host 准备/更新流程。
|
||||
- 项目切换不执行远程核对,立即选择本地项目配置。Workspace 或 Runtime 首次实际使用时
|
||||
才通过当前 Agent 连接核对所需能力;失败只影响该操作。新建或显式保存项目仍执行完整
|
||||
准备,并只事务写入 Host、路径、Runtime 选择和工作模式。
|
||||
- SSH 连接先尝试 attach;Agent 不存在或未运行时执行幂等 bootstrap,然后重新 attach。
|
||||
- Agent 是按需启动的 detached process,不注册开机服务,不依赖 systemd、D-Bus 或 Linger。
|
||||
- 每个模型桥 helper 都为 loopback HTTP 入口生成一次性随机路径 capability;只有写入当前 OpenCode 子进程配置的 URL 可以访问该入口,其他本机用户即使发现临时端口也不能提交模型请求。
|
||||
- Agent 监听当前用户拥有的私有 Unix socket。SSH 中断只关闭 relay,不拥有 Agent 和活动 Runtime 的生命周期。
|
||||
- SSH Host 设置通过固定探针和有界 SFTP 显示 Host 已登记的 Agent/OpenCode 版本,并与当前
|
||||
GoodBuddy 所需版本比较。进入或切回该设置页只读取本地 Host 列表,不连接任何 Host;
|
||||
用户点击“刷新版本”后才探测对应 Host,探测不安装或切换远端组件。项目切换与此按钮
|
||||
解耦且不建立连接;实际远程操作才建立或复用 SSH/Agent 连接。
|
||||
- “平台功能 > 远程项目”中的本地 Agent 包清单与 Host 状态分离。Main 对用户数据目录中
|
||||
Linux x64/arm64 `.gbagent` 分别执行外层可信签名、桌面/协议兼容性、内部 Agent/Runtime
|
||||
签名、架构与完整 payload 校验;首次打开和手动刷新还会读取“关于与更新”所选来源的
|
||||
小型签名目录。Renderer 只得到本地/在线版本、是否有更新、架构、远端 Runtime 版本、
|
||||
协议和本地状态,不得到缓存路径、key ID 或 digest。目录检查不下载 `.gbagent`,在线
|
||||
包下载只由用户点击带目标版本的操作触发;离线导入/导出通过 Main 管理的文件对话框完成。
|
||||
- “自动”只在 operation/prepare 前用 Host capability probe 选择 acquisition:直连明确
|
||||
可用才选 Host 下载,否则选 GoodBuddy 传输;显式选择不被改写。prepare、commit 或
|
||||
adoption 失败后都不跨 acquisition 自动 fallback。
|
||||
- Host 下载由桌面控制面通过固定 SSH prepare channel 和结构化 stdin 发送候选信息,
|
||||
Host 取得 compound `.gbagent` 并核对大小与 SHA-256。显式 GoodBuddy 传输在在线目录
|
||||
不可用,或本地已验证包版本不低于在线候选时优先租用本地包,包括更高版本离线导入;
|
||||
线上存在更新且可用时仍取得线上新包。需要下载时,同一次操作会验 SHA-256 与签名、
|
||||
缓存并取得 lease,再以有界流式 SFTP 上传一个 compound archive 和归档中已验证的
|
||||
bootstrap Node;约 294 MiB 完整包不会一次读入 Main `Buffer`,Host 仍再次校验完整包。
|
||||
- 两种 acquisition 交付到固定 operation staging 后,完全共用 control-plane
|
||||
`prepare → commit → Agent activate/health → Runtime activate → finalize → cleanup`。
|
||||
commit 终态持久化;通道丢失时 `commit-status` 读取终态,或从已认证准备状态幂等完成
|
||||
中断的目录/metadata 发布,不重新下载或准备包。prepare
|
||||
完成后、commit 前快照 Agent/Runtime 五个 metadata 文件,任一组件 adoption 失败都恢复其原字节或原缺失
|
||||
状态;已经发布的 side-by-side payload 可以保留但不能成为 current。确认 adoption 后
|
||||
才显式 cleanup;cleanup 失败只保留维护重试,不回滚健康环境。Host 在 prepare 解包时
|
||||
完成一次完整 payload 校验,commit 和后续注册检查不再遍历包体。首次 bootstrap 不依赖
|
||||
既有 Agent daemon,也不要求 format v1 归档携带 `agent/lib/package-installer.cjs`。
|
||||
- 即使版本号均为当前版本,Host 卡片仍提供同版本“重新安装”,用于修复 registry、签名
|
||||
或安装 identity 异常。GoodBuddy-owned 同 digest 目录损坏时先隔离后替换,并在发布或
|
||||
激活失败时恢复;不要求删除 Host、凭据或引用项目。
|
||||
- Host 卡片的“版本匹配”badge 只表达版本事实,不代表 Agent 正在运行或环境健康。重新安装
|
||||
失败时明确显示本次操作未完成,并以随后重新检查的版本卡片表达当前事实;提交结果
|
||||
不确定时不声称旧版本未被替换。
|
||||
- 更新成功后才定向清理该 Host 的 Agent 连接和 Runtime 选择缓存。引用项目再次打开时
|
||||
解析新的 current registry 并建立当前连接,无需刷新项目记录;当前环境无效时要求用户
|
||||
显式修复,但不在项目切换中下载或安装。失败或取消保留 Host 配置、凭据、项目、
|
||||
Workspace 和旧组件。
|
||||
- 显式 stop、升级、身份冲突或进程退出时清理 GoodBuddy 自己的 socket、状态和子进程;不得删除或覆盖无关 Host 文件。
|
||||
|
||||
### Host 级环境生命周期
|
||||
|
||||
- Agent、Node 和 Runtime 是 Host/SSH 账号级共享环境。新增或重新验证 Host 后先保存 Host
|
||||
并只读探测,不自动安装;用户随后在 Host 卡片明确选择安装或更新方式,失败保留 Host
|
||||
并允许重试。
|
||||
- Host 可以按签名目录固定的 URL、大小和 SHA-256 直接从当前 GitHub/北京镜像来源下载
|
||||
完整 `.gbagent`,也可使用 GoodBuddy 本机下载、流式 SFTP 传输和离线导入。自动模式只在
|
||||
操作开始时择一;执行失败不会在同一次操作中切换 acquisition。
|
||||
- 项目只保存 Host ID、远端路径、Runtime 选择和默认模式。创建或保存时验证 Host current
|
||||
环境;打开或切换只读取本地配置。Workspace/Runtime 实际使用时从 Host current registry
|
||||
和当前 Agent 连接取得 live identity,不再扫描、下载、上传或发布 Agent/Runtime。完整
|
||||
事务、兼容边界和验收要求以
|
||||
[Host 环境准备设计](./remote-host-environment-provisioning-design.md) 为准。
|
||||
- 打开新建/项目设置弹窗只读取本地 Host 验证记录,不并发检查所有 Host;目录浏览或保存
|
||||
才连接所选 Host。
|
||||
- 项目选择器中的管理操作默认浮动隐藏,在悬停、键盘焦点或触屏环境显示。管理任意已保存
|
||||
项目不先激活项目,因此 Host 不可达或远端目录已不存在时仍可删除其本地记录。
|
||||
- 删除 Host 时确认框列出所有引用它的本地项目记录,包括归档项目。确认后只删除本机
|
||||
Host、凭据、项目及其关联记录,不连接 Host、不删除远端目录或内容;Host 设置写入或
|
||||
项目事务失败时恢复另一侧,避免只删掉其中一类本地记录。
|
||||
|
||||
## Workspace
|
||||
|
||||
Main 传入已规范化的绝对 POSIX root。Agent 返回 Workspace identity、访问模式、Git 状态和有界能力。
|
||||
|
||||
- 创建项目时,用户可以直接输入远端工作目录,或通过输入框右侧的文件夹按钮浏览并选择目录。
|
||||
- 项目保存前的目录浏览使用 Main 管理的只读、有界 SFTP,只返回目录;每次请求重新解析并校验当前 Host revision 与 Host Key generation,不安装 Agent、不暴露任意 shell/SFTP 接口。
|
||||
- 浏览从 SSH 账号 Home 或当前有效绝对路径开始,限制扫描次数、返回条目和总时限;取消、Host 变化、超时或连接失败时关闭 SFTP 并保留用户原先输入。
|
||||
- 选择目录只更新项目草稿,仍需通过正常的 Agent、Workspace、Runtime 验证和项目保存事务才会持久化。
|
||||
- Ask Workspace handle 不暴露写入方法。
|
||||
- Execute Workspace handle 可读写,但这不是 Execute 的唯一权限面;Execute Runtime 本身使用 SSH 账号的正常权限。
|
||||
- 文件预览、搜索、Git diff、目录项和显式文件传输保持字节、条目数和路径长度上限。
|
||||
|
||||
## Runtime
|
||||
|
||||
首个远程 Runtime 固定为签名 OpenCode ACP bundle。
|
||||
托管 SSH 项目的 Composer Runtime 菜单只显示当前配置的 OpenCode 和管理入口,不显示
|
||||
直连模型、Continue 或 DeepSeek Harness。激活旧远程会话时,若其保存了其他 Runtime
|
||||
selection,Renderer 会恢复为当前 OpenCode 配置;Main 的远程请求校验仍是最终边界。
|
||||
|
||||
### Ask
|
||||
|
||||
Ask 使用系统 `bwrap`:
|
||||
|
||||
- Workspace 和 Runtime bundle 只读 bind;
|
||||
- 独立可写 scratch HOME、TMPDIR 和 XDG 目录;
|
||||
- 固定环境名和固定 Runtime argv;
|
||||
- 通过只读边界阻止 Workspace 修改。
|
||||
- ACP 权限请求只有在工具种类为原生 `read` 且 Runtime 提供 `allow_once` 选项时才允许;
|
||||
search、edit、execute、未知工具以及只提供持久授权的请求全部拒绝。
|
||||
|
||||
`allow_once` 只解决 OpenCode 发起原生读取前的 ACP 协商,真正的文件系统边界仍是只读
|
||||
`bwrap` bind。Host 缺少 `bwrap` 时 Ask 不可用。
|
||||
|
||||
### Execute
|
||||
|
||||
Execute 不经过 Ask 的 bubblewrap profile:
|
||||
|
||||
- 直接启动已签名 Runtime entrypoint;
|
||||
- 使用 Main-only 模型桥时,签名 Agent launcher 会 `exec` 候选 manifest 锁定的
|
||||
Node Runtime;进程 owner 校验最终 Node executable,而不是已经被替换的 shell
|
||||
launcher 路径;
|
||||
- `cwd` 为项目 Workspace;
|
||||
- 继承 SSH 账号的正常环境、文件系统、进程和网络能力;
|
||||
- 不进行 T2/T3、confinement attestation、approval bridge 或逐工具批准。
|
||||
|
||||
两种模式都保留请求 deadline、输入/输出字节上限、取消和进程组清理。
|
||||
|
||||
## ACP 与断线
|
||||
|
||||
- 管控面只负责有界 JSON-RPC:连接、查询、启动、取消、关闭和重连。数据面只负责
|
||||
ACP/blob 数据与 Agent 本机 journal。二者共享同一个经过 Host Key 校验和认证的
|
||||
SSH attach 字节流,但不共享生命周期:管控连接关闭不会隐式取消 Agent 已接受的
|
||||
Runtime 动作。
|
||||
- 帧只保留连接 identity、channel identity、单调 sequence、类型和长度。Main 与
|
||||
Agent 都按调用顺序 FIFO 写入,输入队列达到固定字节或条目上限时暂停 SSH stream。
|
||||
顺序、可靠传输、分包和网络背压全部交给 SSH;协议不实现 credit/window、优先级
|
||||
调度、跨 channel 超车、批发送或第二套拥塞控制。
|
||||
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
|
||||
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
|
||||
- 同一 detached Agent 存活时,短暂 SSH 断线依次执行 `controller/resume`、`runtime/resumeAcpChannel` 和 `runtime/replayAcpChannel`,从 Main 已确认的 cursor 后只重放 Agent 到 Main 的已记录输出。重复 frame 会被确认但不会再次交给上层。若上一代连接只留下没有活动请求的 detached binding,完成精确 controller takeover 后会先有界停止并核对遗留 Runtime process,再用新 channel epoch 重新打开同一 binding 并恢复已有 ACP session;其他 controller、未证明 takeover 或仍有活动请求的 binding 仍被拒绝。
|
||||
- Main 到 Runtime 的 ACP 输入、模型请求、工具请求和 blob 不自动重放。
|
||||
- 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。
|
||||
- 只有远端返回 identity 匹配且 `closed: true` 时,Main 才删除持久化 binding;传输失败或未确认 close 会保留 recovery identity。
|
||||
- 终态 close 由 Agent 在一个事务中为两个方向记录 sequence high-water tombstone,删除剩余 frame 和 active channel,并归零对应 journal quota。tombstone 用于拒绝迟到的旧 epoch frame,因此当前不会按时间自动裁剪。
|
||||
- 断线不主动终止正在运行的 Runtime。用户取消、deadline、显式关闭或 Agent shutdown 才触发停止。
|
||||
- Agent 失联期间继续把 Runtime 输出写入本机 journal;重新连接只校验 controller
|
||||
identity、恢复 binding,并从 Main 最后确认的 cursor 后同步缺失数据。已接受的
|
||||
指令和结果不确定的 Provider 请求都不自动重放。
|
||||
|
||||
## 模型桥
|
||||
|
||||
- Provider URL、API Key 和认证头只存在于 Main。
|
||||
- 远端 OpenCode 通过 GoodBuddy Agent helper 和每次 Prompt 的私有 Unix socket 使用模型桥。
|
||||
- Main 校验固定模型 profile、协议、请求路径和有界传输格式,并记录 Provider
|
||||
返回的实际 usage;不限制 Prompt 内的模型调用轮数、工具调用次数、累计 Token
|
||||
或单次模型输出 Token。取消、Runtime deadline、请求/响应字节上限和结果不确定时
|
||||
禁止自动重放仍然保留。
|
||||
- helper 可以接收同一 Prompt 内并发到达的模型桥请求;它在单一稳定模型桥上按到达
|
||||
顺序等待并交付,不返回本地 `bridge-busy`,每个响应只有在 HTTP 完整 flush 后才
|
||||
发送 delivery ACK。
|
||||
- GoodBuddy 自己管理会话标题,因此传给 OpenCode 的配置禁用 title Agent;一次用户
|
||||
Prompt 不会额外触发标题模型请求。
|
||||
- 一条模型桥消息编码为一个 canonical JSON blob frame;blob frame 最大 2 MiB,
|
||||
Provider 请求与响应 body 仍各自限制为 768 KiB。每条模型消息只调用一次 SSH
|
||||
write,不再二次分片、等待逐帧 credit 或合并多个协议帧。凭据不进入 Renderer、
|
||||
SSH 命令参数或远端环境。
|
||||
- Unix socket 接收端按 `readableLength` 中已经缓冲的字节增量排空一个声明长度的帧,
|
||||
不会在部分大响应到达时反复请求尚未缓冲的完整剩余长度;短读、连接结束、错误和取消
|
||||
都会使当前交换失败。原生 Linux 回归覆盖至少 256 KiB 的 broker 响应。
|
||||
- Renderer 把相邻 text/reasoning delta 合并为消息 block 时始终替换最后一个 block,
|
||||
不修改既有 React state 对象。这样开发环境 StrictMode 重复调用 state updater 时,
|
||||
block metadata 与 canonical 消息正文保持一致。
|
||||
|
||||
## 已完成的 E2E 验收记录
|
||||
|
||||
- 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0`
|
||||
和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布
|
||||
的双架构验收。Agent `0.11.7` 后续已由原生 workflow 发布并公开验证双架构工件;
|
||||
当前源码 lock 是修复候选 `0.11.8`。
|
||||
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
|
||||
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
|
||||
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
|
||||
`read`,读取专用测试 Workspace 中的证据文件,再在第二个 build 模型轮次生成最终
|
||||
回答;没有 title 模型轮次,也没有第二个工具调用。
|
||||
- 两个模型轮次都记录已交付,远端 helper 保持休眠且 Unix socket 没有未读响应积压。
|
||||
持久化的 canonical 助手正文精确为 `GOODBUDDY_REMOTE_TOOL_4C6B9D8A`。验收操作没有
|
||||
重放任何结果未知或已经完成的 Provider 请求。
|
||||
|
||||
## 项目保存
|
||||
|
||||
远程项目只持久化稳定配置:
|
||||
|
||||
- Host ID;
|
||||
- 规范远端工作目录;
|
||||
- Runtime selection;
|
||||
- `ask | execute` 默认模式。
|
||||
|
||||
Agent installation、Host revision/Host Key generation、Workspace identity、Runtime
|
||||
bundle/adapter digest 和 capability generation 都属于 live connection/lease,不进入项目
|
||||
domain object 或 SQLite。打开托管 SSH 项目不接受 Renderer 回传的旧项目草稿,而是从 Host
|
||||
store 解析当前连接目标,通过 Agent/Runtime 安装管理器的 current/activate 读取路径取得
|
||||
registry identity,再用同一个 Agent 连接验证 Workspace 路径和 Runtime capability。该流程
|
||||
不会扫描完整 payload、取得安装包或发布组件。
|
||||
|
||||
实时 Runtime 创建只接受 OpenCode,使用当前解析后的模型 profile 建立 Main-only 模型桥,
|
||||
从当前 Agent 连接和 Runtime registry 取得会话 identity,并在该连接上打开 Workspace 和
|
||||
ACP channel。Host 编辑或环境更新会定向失效 Agent 连接与 Runtime 缓存,下一次请求自然
|
||||
重新取得 current 环境;无需修改项目。Ask/Execute 权限继续由每次 Prompt 的 ACP Runtime
|
||||
边界执行。
|
||||
|
||||
Renderer 在用户主动打开已有托管 SSH 项目时立即切换,不显示远程激活状态。Host、Agent、
|
||||
Workspace、Runtime 和 Saving 进度仅用于新建或显式保存项目;进行中可显式取消并禁用会
|
||||
产生冲突的项目创建和设置入口。远端 RPC 拒绝只向
|
||||
Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转发 Host 私有错误详情。
|
||||
`runtime/preparePrompt` 在远端明确拒绝且尚未接受 Prompt 时会关闭对应 Main binding,
|
||||
不会把确定性失败错误保留为 `outcome-unknown`。
|
||||
|
||||
不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema
|
||||
v31 保留项目及关联用户数据,但重建执行空间表为 `project_id/kind/root_path/ssh_host_id`
|
||||
四列,并删除旧 Runtime 验证表。
|
||||
|
||||
## 资源与发布
|
||||
|
||||
- 所有桌面构建(包括 `portable` 与 `release:package`)只携带
|
||||
`agent-runtime-lock.json`、`remote-runtime-lock.json` 与公开的
|
||||
`agent-release-keys.json`,不嵌入 `.agent-resources`、
|
||||
`.remote-runtime-resources` 或任何可安装 Linux 远端 payload。
|
||||
- `build/agent-package.cjs` 在对应原生 Linux 架构组装确定性 `.gbagent`,外层签名覆盖
|
||||
描述符和每个内部文件身份;内部 Agent 与 Runtime 仍分别使用既有签名 manifest 和 lock
|
||||
校验,但所有 production 层统一使用一组 GoodBuddy 通用发布身份并通过签名域区分用途,
|
||||
不要求内部 Runtime 单独配置密钥。`build/agent-catalog.cjs` 为双架构包生成签名累计目录,
|
||||
拒绝同一版本/架构改变字节。
|
||||
- `.github/workflows/agents.yml` 只使用进程内临时测试 key 做分支/PR 原生验证,不发布。
|
||||
`.github/workflows/agent-release.yml` 才可在 annotated `agent-v<version>` 标签和受保护
|
||||
`agent-signing` Environment 中构建 production 包。Agent GitHub Release 必须
|
||||
`--latest=false`;北京 OSS 先写 `agent-releases/v<version>/`,GitHub 公开后才切换
|
||||
单一 `agent-releases/latest.json` 指针,使客户端始终从同一不可变版本目录读取目录与签名。
|
||||
- Main 的本地缓存只发布完整验证成功的内容。在线目录和包都绑定固定 GitHub/OSS URL、
|
||||
大小和 SHA-256;选取规则是桌面最低版本满足、Agent protocol major 相同且 minor 不高于
|
||||
当前支持值的最高 SemVer。缺少某架构仅使该架构 Host 的托管 SSH 无法激活。
|
||||
- `agent-release-keys.json` 读取时按 UTF-8 JSON 解析并严格验证 schema、唯一 key ID、
|
||||
规范 Base64、有效 Ed25519 key、环境和撤销列表,不把 CRLF/LF 或普通 JSON 空白作为
|
||||
信任条件。上传到 Host 前重新序列化为确定性 LF JSON。签名目录、描述符、manifest 和
|
||||
payload 仍按原始签名字节及 SHA-256 验证。
|
||||
|
||||
## 发布前验证
|
||||
|
||||
1. `npm test`
|
||||
2. `npm run typecheck`
|
||||
3. `npm run lint`
|
||||
4. `npm run build`
|
||||
5. 用测试签名复合包验证在线下载源选择和离线导入/导出;正式 Agent 候选还需公开校验
|
||||
双架构 production 包与签名目录。
|
||||
6. 在已固定 Host Key 的 Linux x64 测试 Host 上验证 attach-or-bootstrap。
|
||||
7. 在 GoodBuddy 专用测试目录验证 Ask 无法修改文件。
|
||||
8. 验证 Execute 可以写文件、启动进程和访问网络,同时不触碰无关 Host 文件。
|
||||
9. 运行一次有界的真实模型调用,确认凭据只由 Main 使用。
|
||||
10. 中断并恢复 SSH,确认活动 Runtime 不被网络抖动终止。
|
||||
@@ -0,0 +1,336 @@
|
||||
# SSH Host 远程环境准备与直连下载设计
|
||||
|
||||
## 状态
|
||||
|
||||
本文记录 2026-08-28 确认的目标设计及当前实施状态。源码已经实现 Host 手动准备、
|
||||
GoodBuddy 传输、Host 直连下载、control-plane one-shot installer、发布后健康 adoption,
|
||||
以及项目路径只验证已安装环境。Host 负责 Agent/Runtime 的安装与更新生命周期,项目流程
|
||||
只消费已经准备好的 Host 环境。
|
||||
|
||||
当前源码可以直接使用既有 package format v1 `.gbagent`:归档已经包含固定 Agent 和
|
||||
Node,installer 由 GoodBuddy 桌面控制面打包并通过固定 SSH prepare channel 单次发送,
|
||||
不要求归档包含 `agent/lib/package-installer.cjs`,签名目录也不提供或检查额外的
|
||||
bootstrap 能力元数据。公开直连能力仍须完成本文末尾 GitHub/北京镜像、Linux x64/arm64、
|
||||
取消和离线 GoodBuddy 传输的真实 Host 验收;本文
|
||||
不表示这些验收或新的 Agent 发布已经发生。当前实现细节同时以
|
||||
[SSH 远程主机与 GoodBuddy Agent 实现说明](./remote-host-and-goodbuddy-agent-design.md)
|
||||
和源码为准。
|
||||
|
||||
## 设计结论
|
||||
|
||||
将安装和更新从项目切换移到 Host 管理是合理的,原因如下:
|
||||
|
||||
- Agent、固定 Node 和 OpenCode Runtime 安装在 SSH 账号 Home 下,由同一 Host/账号上的
|
||||
多个项目共享,不属于某一个 Workspace。
|
||||
- 大文件传输发生在项目切换时会阻塞导航,也把 Host 级故障误显示成项目故障。
|
||||
- Host Key、认证、平台和架构本来就在新增 Host 时验证,此时继续只读探测并在 Host 卡片
|
||||
提供手动准备入口最容易理解。
|
||||
- 项目只保存 Host ID、远端路径、Runtime 选择和默认工作模式。Agent installation、
|
||||
Runtime digest、Workspace identity 和 Host revision 都从当次连接取得,不写入项目;
|
||||
“移出安装”不等于“跳过当前环境检查”。
|
||||
|
||||
目标职责是:
|
||||
|
||||
| 流程 | 职责 |
|
||||
| --- | --- |
|
||||
| 新增或重新验证 Host | 验证身份和认证,保存 Host 并探测环境,不传输安装包 |
|
||||
| Host 管理 | 用户明确刷新目标 Host,并可选择安装、同版本重修、取消和重试 |
|
||||
| 创建远程项目 | 只读本地 Host 验证记录,保存时连接所选 Host 并验证当前 Agent、Workspace、Runtime 和模型配置,只持久化稳定配置 |
|
||||
| 切换远程项目 | 只切换本地项目配置,不连接 Host;实际使用 Workspace 或 Runtime 时按需复用 Host 当前环境 |
|
||||
|
||||
## 不变量
|
||||
|
||||
- Host 配置与远程环境准备是两个事务。SSH 验证成功后先原子保存 Host、凭据和完整 Host
|
||||
Key,再开始可能耗时的下载和安装。
|
||||
- 环境准备失败或取消时保留已保存 Host,显示“未就绪”并允许重试,不创建半完成项目。
|
||||
- 不把“环境健康”保存为长期可信布尔值。设置页从 Host 的签名 registry 和当前 Host
|
||||
identity 派生版本状态;准备完成和实际使用时再执行 bootstrap/health。合法但暂未运行的
|
||||
detached Agent 仍可显示为已安装,打开设置不能为了刷新卡片隐式启动或更新它。
|
||||
- 所有安装继续使用 GoodBuddy-owned staging 和 side-by-side digest 目录。失败不得覆盖
|
||||
可用旧安装,也不得删除 Host 上无关文件。
|
||||
- Renderer 不接收下载 URL、缓存路径、签名 key、digest、SSH 命令或凭据。Main 负责来源
|
||||
选择、可信目录解析、操作编排和 IPC 输入校验。
|
||||
- 产品不提供“自动安装远程环境”开关。保存、重新验证或打开 Host 都不能自动传输
|
||||
`.gbagent`;只有用户点击 Host 卡片的主按钮后,Main 才开始准备。
|
||||
- Host 卡片只有一个主按钮,按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”。
|
||||
次级 SegmentedControl 选择“自动”“Host 下载”或“GoodBuddy 传输”;默认“自动”,只影响
|
||||
下一次操作,不持久化为 Host 配置。
|
||||
- “自动”只在 operation/prepare 前执行一次 Host capability probe:直连明确可用时选择
|
||||
Host 下载,否则选择 GoodBuddy 传输。用户显式选择的方式保持有效;一旦 acquisition
|
||||
已选定,prepare、commit 或 adoption 的任何失败都停止本次操作,不跨 acquisition
|
||||
自动回退。
|
||||
- 直连安装只要求已验证 SSH 连接和远端系统工具,不要求现有 Agent daemon。轻量能力探测
|
||||
返回确定性缺少工具、空间、权限或来源不可达时说明原因;探测本身异常时仍允许用户点击
|
||||
直连安装,由实际安装流程重新解析签名候选并通过 SSH 重试。
|
||||
- 项目路径不得为了“方便”回退到隐式安装。Host 未就绪时应引导到 Host 设置,不在项目
|
||||
切换器中启动大文件传输。
|
||||
|
||||
## 新增 Host 流程
|
||||
|
||||
新增向导保持现有 Host Key 和认证顺序,并在系统探针之后显示一次只读环境结果:
|
||||
|
||||
1. 输入名称、地址、端口、用户名和认证方式。
|
||||
2. 在发送认证材料前读取并确认 Host Key。
|
||||
3. 完成 SSH 认证以及平台、架构、Shell、Home 有界探针。
|
||||
4. 原子保存 Host、加密凭据和完整 Host Key。
|
||||
5. 读取 Host 已安装的 Agent/Runtime registry,并执行一次轻量下载能力探测。
|
||||
6. 如果已安装 identity 满足当前 GoodBuddy 要求,显示“版本匹配”并结束;该 badge 不代表
|
||||
Agent 正在运行或环境健康。
|
||||
7. 如果组件缺失或需要更新,显示目标 Agent、Node、Runtime、架构、来源和完整包大小,
|
||||
告知用户保存后前往 Host 卡片安装,然后结束新增向导。
|
||||
|
||||
新增流程到此结束,不显示自动安装开关,也不自动开始环境安装。保存后的 Host 卡片提供
|
||||
一个手动准备主按钮,并以次级 SegmentedControl 选择“自动”“Host 下载”或
|
||||
“GoodBuddy 传输”。主按钮按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”。
|
||||
本地尚无精确包不要求用户先离开当前页面;选择 GoodBuddy 传输时,同一次操作会取得并
|
||||
验证候选后继续上传。
|
||||
|
||||
探测不得自动下载完整 `.gbagent`。只有用户明确启动安装后才允许传输大包。
|
||||
|
||||
## Host 编辑
|
||||
|
||||
- 仅修改显示名称不要求重装远端环境。
|
||||
- 地址、端口、用户名或 Host Key 变化时,旧 Host identity 立即失效,关闭旧连接和对应
|
||||
Runtime 缓存。项目记录不含旧组件 identity;下一次打开直接解析新 Host 的当前环境。
|
||||
保存新身份后只重新执行环境探测,所需安装由用户在 Host 卡片手动启动。
|
||||
- 只替换同一目标的认证材料时仍重新认证并读取实际环境;如果签名 identity 和健康状态
|
||||
未变化,不重复安装。
|
||||
- 当前项目引用的 Host 被编辑时,Main 立即失效该 Host 的 identity 和连接缓存;下一次
|
||||
Workspace/Runtime 操作按需使用新目标。环境未准备好时该操作失败,不回退旧连接。
|
||||
|
||||
## 下载能力探测
|
||||
|
||||
探测在 Host Key 已固定且 SSH 认证成功后执行,只检查当前选择的“关于与更新”来源:
|
||||
|
||||
- Host 是受支持的 Linux `x64` 或 `arm64`。
|
||||
- SSH 账号 Home 可写,GoodBuddy-owned staging 可创建。
|
||||
- 可用空间满足签名目录声明的包大小、解包上限和安全余量。
|
||||
- Host 可以通过 HTTPS 访问所选 GitHub 或北京镜像 URL。
|
||||
- 首次安装所需的下载、SHA-256 和运行归档内固定 Node 的能力可用。
|
||||
- 已安装 Agent 是否报告未来的直连更新能力。
|
||||
|
||||
探测使用小型签名目录或有界请求,不以一次成功 `HEAD` 代替真实安装验证。结果只用于当次
|
||||
展示,不持久化为永久能力;开始安装前重新检查,因为网络、代理和磁盘状态可能变化。
|
||||
|
||||
探测失败不能阻止保存 Host,也不能禁用显式 GoodBuddy 传输或离线导入路径。显式选择
|
||||
Host 下载时由实际操作报告不可用原因;“自动”只把“直连明确可用”作为选择 Host 下载的
|
||||
条件,其他结果选择 GoodBuddy 传输。
|
||||
|
||||
## 工件选择与来源
|
||||
|
||||
Main 继续读取并验证 `agent-catalog.json`、`agent-catalog.sig` 和 production 公钥
|
||||
registry,按以下条件选择唯一候选:
|
||||
|
||||
- Host 平台和架构匹配;
|
||||
- 最低桌面版本满足;
|
||||
- Agent protocol major 相同,minor 不高于桌面支持值;
|
||||
- 使用满足条件的最高 SemVer;
|
||||
- URL、文件名、大小和 SHA-256 来自签名目录;
|
||||
- 下载来源跟随“关于与更新”的 GitHub/镜像选择。
|
||||
|
||||
远程下载只优化大包的网络路径。Main 仍需先取得并验证小型目录,再通过经过 Host Key
|
||||
验证的 SSH 控制面传递固定候选元数据。产品不接受 Renderer、项目、Host 文件或用户输入的
|
||||
任意下载 URL。
|
||||
|
||||
## 获取与统一安装事务
|
||||
|
||||
首次安装不能假设 Host 已有 GoodBuddy Agent、installer 或系统 Node,也不由既有 Agent
|
||||
daemon 参与。两种 acquisition 的差别只在于如何把同一个签名 compound `.gbagent` 交付到
|
||||
固定 operation staging:
|
||||
|
||||
- **Host 下载**:控制面通过固定 SSH prepare channel 发送签名目录绑定的 URL、大小和
|
||||
SHA-256;Host 有界下载并校验完整归档。
|
||||
- **GoodBuddy 传输**:显式选择时,在线目录不可用或本地已验证包版本不低于在线候选,
|
||||
则优先使用本地包,包括版本更高的离线导入;线上存在更新且可用时仍取得线上新包。
|
||||
需要下载时,同一次用户操作会验证 SHA-256 和签名、写入缓存并取得 lease。随后以有界
|
||||
流式 SFTP 上传一个 compound archive,以及从该归档中已验证的 bootstrap Node。约
|
||||
294 MiB 的完整包不会一次读入 Main `Buffer`,Host 收到后仍再次校验完整包。
|
||||
|
||||
交付后两种 acquisition 完全共用一个 control-plane 事务:
|
||||
|
||||
1. Main 创建带 operation ID、总时限、输出上限和取消信号的准备操作,并通过固定
|
||||
`prepare` channel 发送有界 one-shot installer 与结构化输入。
|
||||
2. Host 在解包写入准备目录的同一遍处理中校验归档大小、SHA-256、外层及内层签名、
|
||||
平台、架构、协议和每个 payload digest;不再先完整扫描 payload、再重复解包和哈希。
|
||||
3. `prepare` 完成后、`commit` 前按原字节记录 Agent 与 Runtime 的五个
|
||||
current/registry metadata 文件,
|
||||
同时记录原本缺失的状态。
|
||||
4. `commit` 只复核有界的签名 manifest、准备回执和入口 metadata,再以目录 rename 发布
|
||||
已准备内容及包内认证的公钥 registry/Runtime lock;不会重读归档或遍历 payload。
|
||||
终态先持久化到 operation staging;控制通道丢失时,同一 operation 的
|
||||
`commit-status` 读取终态,或从已认证准备状态幂等完成中断的 rename/metadata 发布。
|
||||
5. commit 后在同一条已固定身份的 SSH lease 上执行 Agent bootstrap/readiness 和 Runtime
|
||||
activate。新 Agent 最多完整验证一次,新 Runtime 最多完整验证一次;注册成功后的
|
||||
health、capabilities 和 prompt 启动只读取 registry、签名 manifest 与入口 metadata。
|
||||
adoption 失败时停止候选,并把五个 metadata 文件恢复为原字节或原缺失状态。
|
||||
6. Main 确认 adoption 后显式执行 cleanup。cleanup 失败只保留待重试的有界 operation,
|
||||
不回滚已经健康的 Agent/Runtime。
|
||||
|
||||
这不是两个并行安装管理器,也不存在把 Agent 与 Runtime payload 逐文件走 SFTP 安装的
|
||||
第二条路径。两种 acquisition 共享相同的 prepare、commit、adoption、finalize 与 cleanup。
|
||||
|
||||
Host 缺少可靠的下载、SHA-256 或 ZIP 能力时,不自动安装系统软件,也不要求 root。显式
|
||||
Host 下载返回明确失败;本次操作不自动切换 acquisition,用户可改选 GoodBuddy 传输后
|
||||
重新点击主按钮。后续更新和同版本重装复用同一事务,不要求用户先通过项目切换完成
|
||||
“过渡升级”。
|
||||
|
||||
## 本机传输与离线路径
|
||||
|
||||
现有 `.gbagent` 本地缓存、在线下载、离线导入和导出继续保留:
|
||||
|
||||
- GoodBuddy 路径在本地缺少精确候选时,于同一次操作完成下载、整包大小与 SHA-256、
|
||||
全部签名和 payload 验证、缓存发布及 lease 获取。
|
||||
- SFTP 有界流式上传单个 compound archive 与其中已验证的 bootstrap Node 到操作自己的
|
||||
staging;Main 不把完整归档读入内存,Host 再次校验后进入统一 control-plane 事务。
|
||||
- Host 无公网、代理/TLS 异常或缺少 bootstrap 工具时,用户可显式使用此路径;离线导入
|
||||
后也走同一缓存 lease、上传和 Host 校验流程。
|
||||
- 本地项目和普通桌面打包不依赖任何远端 Agent 工件。
|
||||
|
||||
## 完整包与发布
|
||||
|
||||
第一阶段继续发布和下载完整复合 `.gbagent`,不同时引入组件化增量发布:
|
||||
|
||||
- 远程直连和 GoodBuddy 本机下载都会传输完整包,其中包含 Agent、固定 Node 和 OpenCode
|
||||
Runtime。
|
||||
- 每次显式安装或重装都从本次已经校验并解包的准备目录原子替换对应 Agent 与 Runtime
|
||||
digest 目录,不为节省本机磁盘写入而重新遍历旧目录,也不使用跨安装硬链接。
|
||||
- 操作响应丢失后的同 operation 重试只检查签名 manifest、registry identity 和三份小型
|
||||
trust metadata,不重新读取归档或 payload;正常 cleanup 随后删除准备目录。
|
||||
|
||||
只有真实包体和发布频率证明网络成本仍不可接受时,才另行设计 Agent、Node 和 Runtime
|
||||
独立签名组件目录;本功能不提前引入第二套发布协议。
|
||||
|
||||
## Host 准备状态
|
||||
|
||||
设置中的 Host 卡片分开表达版本事实和操作结果:
|
||||
|
||||
- **未验证**:Host Key、认证或系统探针尚未完成。
|
||||
- **尚未安装**:Host 可连接,但 Agent 或 Runtime 缺失。
|
||||
- **需要更新**:已安装 identity 与当前兼容候选不一致。
|
||||
- **正在准备**:正在探测、下载、验签、安装或健康检查。
|
||||
- **版本匹配**:版本 badge 只表示 Agent/Runtime identity 与当前兼容候选匹配,不等同于
|
||||
进程正在运行或环境健康;设置页普通刷新不会启动已停止的 Agent。
|
||||
- **准备失败**:保留 Host 和可用旧组件,展示有界错误与重试入口。
|
||||
|
||||
主按钮按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”。重新安装失败后,
|
||||
界面显示本次操作未完成,并以随后重新检查的版本卡片表达当前事实,不在提交结果不确定时
|
||||
断言旧版本未被替换,也不得把“版本匹配”改称“环境健康”。统一进度以 `探测`、
|
||||
`下载/传输`、`校验`、`应用环境`、`Agent 激活与健康检查`、`Runtime 激活` 和
|
||||
`完成设置` 表达用户可理解的阶段。
|
||||
|
||||
同一 Host identity 同时只允许一个由用户启动、Main-owned 的准备操作。重复观察者加入
|
||||
已有操作,不启动第二次发布;窗口关闭、显式取消、Host identity 改变或 Main 退出时执行
|
||||
有界取消。Main 在 commit 前为该 Host 持久化一个不含凭据的 pending operation 记录,
|
||||
其中包含五份有界 metadata 的原字节/原缺失快照;
|
||||
commit 指令开始后若 SSH 通道、输出或终态丢失,当前操作保留该记录和远端 staging。
|
||||
下一次重试或应用重启后的首次操作先对同一 Host identity 执行 `commit-status` 并
|
||||
恢复 adoption;恢复中的 Agent/Runtime adoption 失败仍使用该持久化快照回滚。它不会
|
||||
重新下载或准备包,也不开始第二条 acquisition。环境健康后再重试
|
||||
cleanup,确认清理后删除 pending 记录。
|
||||
取消在最终原子切换完成后不再伪装成回滚成功。
|
||||
|
||||
## 项目创建与切换
|
||||
|
||||
- 新建托管 SSH 项目弹窗只读取本地 Host Key/连接验证记录,不逐台连接或刷新版本。只有
|
||||
已完成本地验证的 Host 可选;浏览目录或保存时才连接所选 Host,并由保存流程完整检查
|
||||
Agent、Workspace 和 Runtime,但不隐式安装。
|
||||
- 项目保存验证 Host 当前 revision 与 Host Key generation、当前 Agent、Workspace、
|
||||
Runtime、模型 profile 和 Ask/Execute 配置;SQLite 事务只写入 Host ID、规范远端路径、
|
||||
Runtime 选择和默认工作模式。
|
||||
- 打开或切换已有项目只提交本地项目选择,不执行 SSH、registry、Workspace 或 Runtime
|
||||
检查。首次实际使用远程 Workspace 或 Runtime 时才解析 Host 管理的当前 Agent/Runtime,
|
||||
并建立或复用当前 Agent 连接。已安装 Agent 暂未运行时,
|
||||
固定 `attach-or-bootstrap` 命令幂等启动 registry 中的 current identity;这里的
|
||||
bootstrap 只启动进程,不安装组件,也不重新读取、哈希或验签完整 payload。
|
||||
Agent 进程读取 Host 管理已提交的 registry 和匹配 manifest 元数据,不重新验签或扫描
|
||||
完整安装 payload。
|
||||
- Workspace 和 Runtime 使用当前 Agent 连接产生的 live handle/lease。项目不保存或核对
|
||||
历史 Workspace/Runtime identity;当前 registry、连接状态或 capability 无效时停止并
|
||||
引导到 Host 管理,不在项目流程中下载或安装。
|
||||
- 桌面更新导致 Host 组件过旧时,项目保持未切换并提示前往 Host 设置。应用启动仍进入
|
||||
第一个普通本地项目,不自动连接或更新远端 Host。
|
||||
- Host 环境更新成功后,Main 定向失效该 Host 的 Agent/Runtime identity、连接和 Runtime
|
||||
会话缓存。所有项目在下一次实际操作时自然使用新的 current 环境;无需改写项目记录。
|
||||
- 环境准备失败时不改变已有项目绑定。Host identity 编辑失败时则继续保持 fail-closed,
|
||||
因为旧身份不能替代用户刚保存的新身份。
|
||||
- 项目管理入口与项目选择分离。项目选择器中的浮动管理按钮直接打开目标项目设置;删除
|
||||
不访问 Workspace、Agent 或 Host,因此远端目录已删除或 Host 不可达都不能阻止本地
|
||||
项目记录清理。删除当前项目或 Host 级联删除当前项目时只回退到普通本地项目。
|
||||
- Host 删除确认使用本地数据库列出全部引用项目,包括归档项目;确认后级联删除本地项目
|
||||
及其会话、任务、成果、记忆、计划和心跳关联,再删除本地 Host 与凭据。整个流程不发起
|
||||
SSH,也不删除远端内容;跨 Host 设置文件与 SQLite 的普通失败会恢复已写入的一侧。
|
||||
|
||||
项目切换不展示远程激活进度,也不阻塞项目导航。`Host`、`Agent`、`Workspace`、`Runtime`
|
||||
和 `Saving` 进度只属于新建或显式保存项目;实际 Workspace/Runtime 操作使用正常的局部
|
||||
加载和错误反馈。
|
||||
|
||||
## 实现结构
|
||||
|
||||
当前实现复用现有机制,没有新增平行的安装管理器:
|
||||
|
||||
- 扩展当前 Host 系统探针和远程环境 inspector,返回瞬时下载准备能力。
|
||||
- 让当前 `RemoteEnvironmentUpdateService` 演进为 Host 环境准备编排器,统一用户手动
|
||||
启动的首次安装和后续更新、自动 acquisition 选择、进度、取消和最终失效。
|
||||
- Host 环境准备编排器直接完成一次 package prepare/commit 及 Agent/Runtime adoption。
|
||||
Workspace 和执行路径只读取 current registry 并调用 Host 侧轻量 identity/health 检查,
|
||||
在当前进程内复用已确认 identity 和 Agent 连接;Host 编辑或环境更新定向清空这些缓存。
|
||||
这些路径不会取得安装包、发布组件或通过 SFTP 扫描 payload。
|
||||
- Host 卡片即使显示相同版本也保留显式重装操作,用于修复 registry、签名或 payload
|
||||
identity 异常;若同一 digest 的 GoodBuddy-owned 目录校验失败,重装会先隔离旧目录,
|
||||
完整发布并验证新目录,失败时恢复旧目录。重装不要求删除 Host、凭据或项目,也不会处理
|
||||
所有者异常、符号链接或非 GoodBuddy 管理路径。
|
||||
- 由桌面控制面打包有界 one-shot installer,并通过固定短命令 `exec sh -s` 和 stdin
|
||||
发送脚本及结构化输入,避免 SSH command 长度限制;既有 package format v1 归档内固定
|
||||
Node 负责解码和运行它。
|
||||
- Preload 只暴露明确的 Host 探测、准备、取消和进度方法,不暴露 URL 或任意 SSH 命令。
|
||||
- 现有 Agent/Runtime side-by-side、固定 SSH lease、Host 端一次完整验证、注册后轻量检查、
|
||||
连接失效和项目稳定配置事务继续作为唯一实现。
|
||||
|
||||
## 迁移
|
||||
|
||||
- 已保存 Host 打开设置时只显示本地记录,不自动连接或检查版本;用户点击对应 Host 的
|
||||
“刷新版本”后才执行只读状态探测。项目切换不依赖该按钮,也不建立连接。
|
||||
- 数据库升级保留远程项目、Host ID、远端路径、Runtime 选择及关联会话/任务,并删除旧的
|
||||
Agent/Runtime/Workspace/Host 验证列和 Runtime 验证表。下一次实际远程操作从 Host
|
||||
current 环境重新取得所有 live identity。
|
||||
- 如果 Host 未就绪或版本不兼容,项目仍可在本地选择,但实际远程操作失败;不再走旧的
|
||||
隐式安装。
|
||||
- 已下载或导入的本地 `.gbagent` 保持可用,不能因加入远程直连而迁移或删除。
|
||||
- `0.11.7` 遗留的 version-1 pending operation 没有持久化 metadata 快照,也没有新格式的
|
||||
已解包准备状态;`0.11.8` 只清理其暂存并重新执行一次认证 prepare,不尝试用新 installer
|
||||
续写旧 commit。
|
||||
- 远程直连继续使用现有 package format version 1,不要求归档携带 installer,也不要求
|
||||
目录声明额外的 bootstrap 能力元数据。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 新 Host 保存和探测完成后不会自动下载、上传或安装任何包,也不存在自动安装开关;
|
||||
再次进入 Host 设置不会逐台连接,只有显式“刷新版本”才探测目标 Host。
|
||||
2. Host 卡片只有一个按版本事实命名的主按钮;次级 SegmentedControl 提供默认且不持久化的
|
||||
“自动”及显式“Host 下载”“GoodBuddy 传输”。没有预装 Agent、Node 或 Runtime 的 Host
|
||||
可以完成准备;远程下载不可用时可手动改用 GoodBuddy 传输。
|
||||
3. 探测不下载完整包,失败不阻止保存 Host;非确定性探测失败不禁用直连重试,安装失败
|
||||
或取消后 Host 仍可重试。
|
||||
4. 直接下载严格匹配签名目录中的 URL、大小和 SHA-256;GoodBuddy 传输可在同一次操作中
|
||||
下载并验证缺失候选,以有界流式 SFTP 上传完整归档和已验证 bootstrap Node。Host 对
|
||||
两者完成相同的外层与内层签名、路径、架构、协议和 payload 校验。
|
||||
5. Host 对本次候选只完成一次完整 payload 校验;commit、adoption、health、capabilities
|
||||
和 prompt 启动不得再次遍历或哈希完整 Agent/Runtime,显式重装仍能用本次准备内容修复
|
||||
同 digest 的损坏目录。
|
||||
6. 创建、打开和切换项目不会产生 `.gbagent` 下载、Agent payload SFTP 上传或安装目录发布。
|
||||
7. 未就绪 Host 不能创建远程项目;已有项目仍可本地选择,但远程操作失败并引导到 Host 设置。
|
||||
8. Host 编辑或更新后,引用项目使用 Host 管理的当前连接和 current registry,不读取项目
|
||||
中的旧 Host/Agent/Runtime/Workspace identity;当前环境无效时才停止并要求在 Host 管理
|
||||
中修复。
|
||||
9. Host 地址、用户或 Host Key 在准备期间变化时停止候选发布,旧身份不会被重新启用。
|
||||
10. GitHub、北京镜像、无公网 Host、取消、应用退出、磁盘不足、损坏下载和不支持架构均有
|
||||
明确结果,且不触碰 GoodBuddy-owned 目录之外的文件。
|
||||
11. Linux x64 和 arm64 均通过 GitHub/北京镜像、取消、离线 GoodBuddy 传输,以及项目使用
|
||||
Host current 环境执行 Workspace/Runtime 操作的真实 Host 测试;这些真实 Host 验收当前
|
||||
仍待完成。
|
||||
12. Host 不可达或远端目录不存在时,用户仍可从项目浮动管理入口删除本地项目;删除 Host
|
||||
会列出并本地清理全部关联项目,但不会连接 Host 或删除任何远端目录和内容。
|
||||
13. commit 终态可在通道丢失后用 `commit-status` 读取,或从已认证准备状态幂等完成;
|
||||
Agent 或 Runtime
|
||||
adoption 失败恢复五个 metadata 文件的原字节/原缺失状态;已健康环境的 cleanup
|
||||
失败保留为维护重试,不触发环境回滚。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
# GoodBuddy 龙芯(LoongArch)预览版构建
|
||||
|
||||
本文档是 GoodBuddy 龙芯预览版的权威构建与功能状态说明。当前目标是
|
||||
Debian `loong64` 桌面,不代表 GoodBuddy 已将龙芯纳入正式发布支持矩阵。
|
||||
|
||||
## 当前状态
|
||||
|
||||
当前流程可以在 AMD64 Linux 主机上交叉构建一个独立的
|
||||
`goodbuddy-loongarch-preview` DEB,并校验包内 Electron、node-pty 和 Koffi
|
||||
均为 LoongArch ELF。
|
||||
|
||||
预览版使用社区维护的 Electron 42.3.0 LoongArch 构建。GoodBuddy 正式版本
|
||||
当前使用 Electron 43.2.0,因此该预览包只用于移植和真机验证,不能作为正式
|
||||
发布基线。Electron 官方目前也不提供 Linux LoongArch 预编译包。
|
||||
|
||||
已完成的实际构建结果:
|
||||
|
||||
| 项目 | 结果 |
|
||||
| --- | --- |
|
||||
| GoodBuddy 源版本 | `0.11.0` |
|
||||
| 预览版本 | `0.11.0-loong64-preview.1` |
|
||||
| Debian 架构 | `loong64` |
|
||||
| Electron | 社区版 `42.3.0` |
|
||||
| DEB 包名 | `goodbuddy-loongarch-preview` |
|
||||
| 构建产物 | `GoodBuddy-0.11.0-linux-loong64-preview.deb` |
|
||||
| 已验证构建时间(UTC) | `20260825T102937Z` |
|
||||
| 已验证产物大小 | `187053208` bytes |
|
||||
| 已验证产物 SHA-256 | `461ade49f10a20ef7b28163077a2ecd6afd2c5b1b0befc673c6d174fd58497b3` |
|
||||
| DEB 解包、架构和动态库解析 | 已通过 |
|
||||
| QEMU Electron 初始化 | 共享库解析通过;Chromium 多线程初始化在 QEMU 下异常退出 |
|
||||
| 龙芯真机启动 | 尚未验证 |
|
||||
|
||||
版本号和产物名会跟随当前 `package.json` 版本变化,上表只记录首次成功构建。
|
||||
|
||||
## 功能列表
|
||||
|
||||
“已打包”只表示所需代码或组件进入 DEB,不等同于已在龙芯桌面完成运行验证。
|
||||
|
||||
| 功能 | 预览包状态 | 当前验证 |
|
||||
| --- | --- | --- |
|
||||
| GoodBuddy Electron 桌面主体 | 已打包 | Electron 可执行文件为 LoongArch ELF;尚未完成真机 GUI 启动 |
|
||||
| React 界面和主进程业务代码 | 已打包 | 生产 bundle 构建、测试、类型检查和 lint 已通过 |
|
||||
| 直接模型调用路径 | 已打包 | JavaScript 代码与生产依赖已包含;待龙芯真机端到端验证 |
|
||||
| Continue Runtime | 已打包 | CLI JavaScript 入口及 npm Runtime 已包含;待真机执行验证 |
|
||||
| 本地终端能力 | 已打包 | 输入锁文件固定的 node-pty(当前为 1.2.0-beta.15)已针对 Electron 42.3.0 交叉编译并验证为 LoongArch ELF;待真机 PTY 验证 |
|
||||
| Koffi glibc 原生绑定 | 已打包 | Koffi 3.1.4 LoongArch glibc binding 已校验为 LoongArch ELF |
|
||||
| 内置 Skills | 已打包 | 静态资源已进入 DEB |
|
||||
| GoodBuddy Agent | **不包含** | 当前 Agent 的正式构建和双 glibc/musl 产物契约不支持 LoongArch |
|
||||
| Remote Runtime | **不包含** | 当前没有 LoongArch Remote Runtime 产物 |
|
||||
| OpenCode Runtime | **不包含** | OpenCode 所需 Bun Runtime 当前不支持 LoongArch |
|
||||
| 原生 Canvas 图像解码 | **不包含** | npm 当前没有 `@napi-rs/canvas-linux-loong64-gnu` 包 |
|
||||
| Koffi musl binding | **不包含** | Koffi 3.1.4 只发布了 `linux_loong64` glibc binding |
|
||||
| 正式自动更新和发布索引 | **不支持** | 预览包不加入正式 CI、安装包矩阵或更新索引 |
|
||||
|
||||
在真机验证完成前,不应把“已打包”功能描述为正式可用。尤其是窗口显示、GPU、
|
||||
音频、系统托盘、桌面集成、PTY 交互和模型调用都必须在真实 LoongArch 桌面上
|
||||
重新验证。
|
||||
|
||||
## 与正式 GoodBuddy 隔离
|
||||
|
||||
预览版有意使用单独身份,避免覆盖正式安装:
|
||||
|
||||
- Debian 包名:`goodbuddy-loongarch-preview`
|
||||
- 产品名:`GoodBuddy LoongArch Preview`
|
||||
- 可执行文件:`goodbuddy-loongarch-preview`
|
||||
- 安装目录:`/opt/GoodBuddy-LoongArch-Preview`
|
||||
- Electron 应用身份与正式 GoodBuddy 不同,因此使用单独的用户数据目录
|
||||
|
||||
该流程不会修改 `.github/workflows/packages.yml`,也不会把 `loong64` 加入
|
||||
正式 release manifest 或下载索引。
|
||||
|
||||
## 构建前提
|
||||
|
||||
### 本地源码主机
|
||||
|
||||
- 已完成 `npm ci`
|
||||
- Node.js 和 npm 版本满足仓库要求
|
||||
- 可以成功执行 `npm run build:bundle`
|
||||
|
||||
### AMD64 交叉构建主机
|
||||
|
||||
- Linux AMD64
|
||||
- Docker
|
||||
- 建议至少 30 GiB 内存和 50 GiB 可用磁盘
|
||||
- 可访问 Ubuntu、npm、Electron headers 和社区 Electron Release
|
||||
|
||||
### Electron LoongArch shell
|
||||
|
||||
从
|
||||
[`darkyzhou/electron-loong64`](https://github.com/darkyzhou/electron-loong64)
|
||||
的 Releases 下载:
|
||||
|
||||
```text
|
||||
electron-v42.3.0-linux-loong64.zip
|
||||
```
|
||||
|
||||
构建脚本固定校验以下 SHA-256,摘要不匹配时必须停止:
|
||||
|
||||
```text
|
||||
92b0ca0c9c18ed90166918a4ac1970266c4fa967aee9277031b3b250b905526e
|
||||
```
|
||||
|
||||
这是社区产物,不是 Electron 官方发布的 Linux LoongArch 二进制。
|
||||
|
||||
## 第一步:生成架构无关输入
|
||||
|
||||
在 GoodBuddy 仓库根目录执行:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build:bundle
|
||||
node build/loongarch-cross/prepare-input.cjs
|
||||
```
|
||||
|
||||
默认输出:
|
||||
|
||||
```text
|
||||
dist/loongarch-cross/goodbuddy-loongarch-cross-input.tgz
|
||||
```
|
||||
|
||||
输入归档包含生产 `out`、锁文件、Skills、Continue Runtime、npm Runtime、
|
||||
图标和发布元数据。它不包含 Agent、Remote Runtime 或 OpenCode 二进制。
|
||||
|
||||
可通过 `--output` 指定其他输出位置:
|
||||
|
||||
```sh
|
||||
node build/loongarch-cross/prepare-input.cjs \
|
||||
--output /path/to/goodbuddy-loongarch-cross-input.tgz
|
||||
```
|
||||
|
||||
## 第二步:构建交叉工具链镜像
|
||||
|
||||
将 `build/loongarch-cross`、输入归档和 Electron zip 放到 AMD64 Docker
|
||||
主机,然后执行:
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
--tag goodbuddy-loongarch-cross:ubuntu-26.04 \
|
||||
build/loongarch-cross
|
||||
```
|
||||
|
||||
工具链镜像包括:
|
||||
|
||||
- `loongarch64-linux-gnu-gcc` 和 G++
|
||||
- LoongArch glibc 开发 sysroot
|
||||
- QEMU user mode
|
||||
- CMake 和 Ninja
|
||||
- Rust `loongarch64-unknown-linux-gnu` target
|
||||
- Node.js、npm 和上游 node-gyp
|
||||
|
||||
可先运行基础探针:
|
||||
|
||||
```sh
|
||||
mkdir -p build/loongarch-cross/results
|
||||
docker run --rm \
|
||||
--volume "$PWD/build/loongarch-cross/results:/results" \
|
||||
goodbuddy-loongarch-cross:ubuntu-26.04
|
||||
```
|
||||
|
||||
探针必须通过 C、C++、CMake、Rust、NAPI-RS、QEMU、DEB、Koffi 和
|
||||
node-pty 检查。Canvas 和 Koffi musl 显示为 unavailable 是当前已知限制,
|
||||
不是 Debian 桌面主体 DEB 的构建失败。
|
||||
|
||||
## 第三步:生成预览 DEB
|
||||
|
||||
假设交叉构建主机上的文件布局如下:
|
||||
|
||||
```text
|
||||
input/goodbuddy-loongarch-cross-input.tgz
|
||||
electron/electron-v42.3.0-linux-loong64.zip
|
||||
preview-output/
|
||||
```
|
||||
|
||||
运行:
|
||||
|
||||
```sh
|
||||
docker run --rm \
|
||||
--entrypoint /usr/local/bin/goodbuddy-loongarch-preview-deb \
|
||||
--volume "$PWD/input:/input:ro" \
|
||||
--volume "$PWD/electron:/electron:ro" \
|
||||
--volume "$PWD/preview-output:/preview-output" \
|
||||
goodbuddy-loongarch-cross:ubuntu-26.04 \
|
||||
/input/goodbuddy-loongarch-cross-input.tgz \
|
||||
/electron/electron-v42.3.0-linux-loong64.zip \
|
||||
/preview-output
|
||||
```
|
||||
|
||||
每次构建创建一个 UTC 时间戳目录,包含:
|
||||
|
||||
```text
|
||||
GoodBuddy-<version>-linux-loong64-preview.deb
|
||||
preview-manifest.json
|
||||
SHA256SUMS
|
||||
```
|
||||
|
||||
脚本会执行以下强制检查:
|
||||
|
||||
1. 校验 Electron zip 的固定 SHA-256。
|
||||
2. 删除 npm 错误解析出的宿主架构可选原生包。
|
||||
3. 按 lockfile integrity 校验并安装 Koffi LoongArch glibc binding。
|
||||
4. 读取输入 lockfile 固定的 node-pty 版本,并使用上游 node-gyp 11.4.2
|
||||
和 Electron 42.3.0 headers 交叉编译。
|
||||
5. 拒绝 `node_modules` 中任何不是 LoongArch ELF 的 `.node` 文件。
|
||||
6. 拒绝意外进入包内的 Agent、Remote Runtime 或 OpenCode 目录。
|
||||
7. 生成 `Architecture: loong64` 的 DEB,解包后再次校验关键 ELF。
|
||||
|
||||
Ubuntu 发行版自带的 node-gyp 会在 Linux addon 链接阶段无条件添加
|
||||
`-lnode`,不适合此 Electron 交叉编译;不要用它替换脚本固定的上游
|
||||
node-gyp。
|
||||
|
||||
## 验证边界
|
||||
|
||||
### 已由交叉构建验证
|
||||
|
||||
- C、C++、CMake 和 Rust 生成 LoongArch ELF
|
||||
- C、C++ 和 Rust 程序通过 QEMU user mode 执行
|
||||
- 最小 NAPI-RS addon 交叉编译
|
||||
- node-pty 针对 Electron headers 交叉编译
|
||||
- Koffi LoongArch glibc binding 架构
|
||||
- Electron、node-pty、Koffi 和 DEB `loong64` 元数据
|
||||
- DEB 完整解包
|
||||
- Debian Forky rootfs 中 Electron 全部共享库解析;该检查发现并补充了
|
||||
`libpulse0` DEB 依赖
|
||||
|
||||
### 仍需真实龙芯桌面验证
|
||||
|
||||
1. 安装 DEB 并从桌面入口启动。
|
||||
2. 验证窗口、渲染、GPU 回退、输入法、系统托盘和通知。
|
||||
3. 配置模型并执行一次真实的直接模型对话。
|
||||
4. 验证 Continue Runtime 启动、取消和退出清理。
|
||||
5. 打开本地终端,验证 node-pty 输入、输出、窗口缩放和进程回收。
|
||||
6. 验证 Koffi 使用路径。
|
||||
7. 验证音频、文件选择器、剪贴板和外部链接。
|
||||
8. 确认 Agent、Remote Runtime、OpenCode 和 Canvas 相关入口不会被误认为
|
||||
预览版已支持。
|
||||
|
||||
QEMU 可以补充动态链接器和缺库检查,但不能替代上述 GUI、PTY、GPU、音频和
|
||||
桌面集成真机测试。当前 Electron 在 QEMU user mode 下可以完成全部共享库
|
||||
解析;继续进入 Chromium 多线程初始化后,QEMU 出现 target `SIGSEGV`,因此
|
||||
不能用该结果判断真机 Electron 是否可以启动。
|
||||
|
||||
## 已知依赖问题
|
||||
|
||||
- npm 的 `--cpu=loong64 --os=linux` 仍可能安装宿主 x64 的 Koffi 可选包。
|
||||
打包脚本会删除所有该作用域的已解析原生包,并按 lockfile integrity 手工
|
||||
安装 LoongArch 包。
|
||||
- `@napi-rs/canvas` 没有发布 LoongArch 原生包。基础 NAPI-RS 探针通过只说明
|
||||
Rust addon 工具链可用,不代表 Skia/Canvas 已完成移植。
|
||||
- Koffi 没有 LoongArch musl binding,因此当前不能满足 GoodBuddy Agent 的
|
||||
双 glibc/musl 产物契约。
|
||||
- 预览 Electron 比正式 GoodBuddy 低一个主版本。正式支持必须先获得与当前
|
||||
Electron 基线一致、可重复构建并完成安全维护的 LoongArch shell。
|
||||
@@ -5,15 +5,15 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-08-19 |
|
||||
| 版本 | 0.5 |
|
||||
| 日期 | 2026-08-28 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md) |
|
||||
| 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md)、[SSH 远程主机与 GoodBuddy Agent 稳定终态设计](../../architecture/remote-host-and-goodbuddy-agent-design.md) |
|
||||
| 相关能力 | [会话监督](../supervision/conversation-supervision-prd.md)、[自动化平台](../../architecture/automation-platform-architecture.md)、[长期助手路线图](../../roadmap/long-term-assistant-roadmap.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
GoodBuddy 已经在聊天右侧提供上下文、工作区、浏览器和成果面板,也已经具备
|
||||
GoodBuddy 已经在聊天右侧提供任务中心、工作区、浏览器和成果面板,也已经具备
|
||||
Runtime 事件、Git 变更、文件预览、成果存储、受控浏览器和专家执行等基础能力。后续还
|
||||
计划增加:
|
||||
|
||||
@@ -23,8 +23,8 @@ Runtime 事件、Git 变更、文件预览、成果存储、受控浏览器和
|
||||
- HTML 等成果的即时安全预览。
|
||||
- 本机与 SSH 远程主机上的工作区和 Agent Runtime。
|
||||
|
||||
这些能力不能被收束为只面向编程的工作台。监督、Runtime、终端、进程、浏览器、成果和
|
||||
上下文都可以服务于普通问答、内容分析、自动化、数据处理、远程运维、知识整理和软件开发。
|
||||
这些能力不能被收束为只面向编程的工作台。监督、Runtime、终端、进程、浏览器和成果
|
||||
都可以服务于普通问答、内容分析、自动化、数据处理、远程运维、知识整理和软件开发。
|
||||
同时,能力目录也不能根据当前页面、项目类型或 Runtime 能力无提示地变化,否则用户无法在
|
||||
需要时主动打开面板并选择目标、主机或运行环境。
|
||||
|
||||
@@ -36,8 +36,8 @@ Runtime 统一抽象为“执行空间”。
|
||||
### 2.1 助手工作栏
|
||||
|
||||
助手工作栏是 GoodBuddy 中始终可访问的应用级工具容器。它提供稳定能力目录,用户从中
|
||||
查看任务中心,并按需打开一个或多个监督、Runtime、终端、进程、工作区、浏览器、成果和
|
||||
上下文面板实例。稳定的是能力的可发现性,不是九个同时占据界面的固定面板。
|
||||
查看任务中心,并按需打开一个或多个监督、Runtime、终端、进程、工作区、浏览器和成果
|
||||
面板实例。稳定的是能力的可发现性,不是八个同时占据界面的固定面板。
|
||||
|
||||
工作栏不是:
|
||||
|
||||
@@ -52,7 +52,7 @@ Runtime 统一抽象为“执行空间”。
|
||||
执行空间描述工作区、终端、受管进程和 Agent Runtime 实际运行的位置:
|
||||
|
||||
```ts
|
||||
type ExecutionSpace =
|
||||
type ExecutionSpaceSelection =
|
||||
| {
|
||||
kind: 'local'
|
||||
rootPath?: string
|
||||
@@ -65,7 +65,9 @@ type ExecutionSpace =
|
||||
```
|
||||
|
||||
执行空间可以来自当前项目,也可以由用户在工作栏中临时选择。临时选择不会静默修改项目
|
||||
设置,只有用户显式保存时才成为项目默认值。
|
||||
设置,也不直接授予文件、终端或 Runtime 能力。Main 必须先把选择草稿验证为绑定窗口、
|
||||
controller、Host/Workspace identity、信任 revision、能力和 lease 的 opaque 临时 Grant;
|
||||
后续 IPC 只引用 Grant ID。只有用户显式保存并重新通过 Project 候选验证时才成为项目默认值。
|
||||
|
||||
## 3. 核心产品原则
|
||||
|
||||
@@ -82,7 +84,6 @@ Runtime
|
||||
工作区
|
||||
浏览器
|
||||
成果
|
||||
上下文
|
||||
```
|
||||
|
||||
- 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。
|
||||
@@ -105,7 +106,9 @@ Runtime
|
||||
- Runtime 默认跟随当前会话或 Task;执行事件可以查看,但 Job/Run 不作为独立选择对象。
|
||||
- 终端默认使用当前项目执行空间,用户可以新建本机或远程终端。
|
||||
- 工作区默认显示当前项目目录,用户可以打开其他本机目录或远程目录。
|
||||
- 成果和上下文默认使用当前范围,用户可以切换到项目、全局或其他允许范围。
|
||||
- 成果默认使用当前范围,用户可以切换到项目、全局或其他允许范围。
|
||||
- 下一次请求的附件和知识库由当前 Composer 展示与管理,不作为工作栏能力。历史执行上下文
|
||||
随关联 Task 查看;记忆功能实现后也归入任务中心,而不是新增独立上下文面板。
|
||||
|
||||
每个可切换目标的面板实例都提供一致的范围模式:
|
||||
|
||||
@@ -131,7 +134,8 @@ Runtime
|
||||
- 当前 Runtime 不支持后台 Job 时,Runtime 能力仍可发现;打开后说明当前可监督的内容。
|
||||
- 当前执行空间没有 Git 仓库时,工作区文件功能仍可使用,Git 区域显示不可用原因。
|
||||
- 没有活动进程时,进程面板提供创建终端或启动 Runtime 的入口。
|
||||
- 没有项目时,终端和工作区允许用户选择本机目录或远程主机。
|
||||
- 没有项目时,终端和工作区允许用户选择本机目录或远程主机,但必须先取得有界临时
|
||||
Execution Space Grant,不能把可选路径直接传给文件、Git、Process 或 Runtime。
|
||||
- 监督未启用时,监督面板提供目标、模式和“开始监督”,而不是隐藏能力。
|
||||
|
||||
不得渲染成排没有解释的禁用按钮,也不得把“进程连通”描述为已经支持完整原生监督。
|
||||
@@ -174,8 +178,8 @@ Runtime
|
||||
- 不允许 Agent 未经现有 Runtime 边界直接向用户终端注入输入。
|
||||
- 不自动执行 HTML 中的脚本或访问网络。
|
||||
- 不让监督器自动替用户发言、批准工具、扩大范围或修改安全策略。
|
||||
- 不在首期承诺网络断开后远程任务一定可恢复。
|
||||
- 不在首期支持任意 ProxyCommand、任意端口转发或 SSH Agent Forwarding。
|
||||
- 不提供以 SSH 连接为生命周期的临时远程 Agent;远程执行必须由用户级常驻 Daemon 承载。
|
||||
- 不支持任意 ProxyCommand、任意端口转发或 SSH Agent Forwarding。
|
||||
- 不要求所有 Runtime 提供相同的 Subagent、Job、Hook 或会话能力。
|
||||
|
||||
## 6. 信息架构
|
||||
@@ -197,7 +201,7 @@ Runtime
|
||||
|
||||
### 6.2 能力目录与面板实例
|
||||
|
||||
能力目录不等于同时打开九个面板。推荐使用工作栏内的纵向能力导航,并在旁边或停靠区域管理
|
||||
能力目录不等于同时打开八个面板。推荐使用工作栏内的纵向能力导航,并在旁边或停靠区域管理
|
||||
用户已经打开的面板实例:
|
||||
|
||||
- 每项始终显示稳定图标,并提供可见标签或可持续查看的工具提示。
|
||||
@@ -209,16 +213,27 @@ Runtime
|
||||
- 默认布局只恢复经过产品确认的少量常用面板,不自动打开全部标准能力。
|
||||
- 每个实例显示稳定实例 ID、能力名称、跟随或固定状态与当前目标;同能力多实例不能只靠位置区分。
|
||||
|
||||
当前聊天右栏过渡实现保留 Task Center、上下文、工作区、浏览器和成果五个横向页签时,必须
|
||||
当前聊天右栏过渡实现保留 Task Center、工作区、浏览器和成果四个横向页签时,必须
|
||||
单行横向滚动,不能自动隐藏或缩写到不可辨认。Task Center 继续作为 Task 的现有入口;
|
||||
审批定位到所属 Task 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和完整配置统一归属
|
||||
“智能心跳”菜单入口。当前阶段不新增独立自动化中心。
|
||||
|
||||
### 6.3 工作栏尺寸
|
||||
|
||||
- 宽窗口:工作栏停靠右侧,支持键盘和指针调整宽度。
|
||||
- 中等窗口:可停靠或覆盖主内容,保持用户上次选择。
|
||||
- 窄窗口:以全屏或接近全屏抽屉显示。
|
||||
- 所有窗口宽度下工作栏都停靠右侧并参与水平布局回流;展开时主工作区同步收缩,不能覆盖
|
||||
或遮罩其内容。
|
||||
- 分栏计算排除最左侧主导航,主工作区与工作栏使用相同的 `300px` 最小宽度。拖动记录两侧
|
||||
在可用分栏宽度中的比例;任一侧达到最小值后停止,窗口缩放后按原比例重新计算并再次
|
||||
应用同一最小值约束。
|
||||
- 最左侧主导航保持全高连续表面;窗口标题栏只覆盖右侧内容框架,不能横跨主导航并把品牌、
|
||||
项目切换器和导航整体向下推移。
|
||||
- 对话内容与输入区始终保留对称的左右安全边距,工作栏边界不得覆盖或裁切输入框、发送
|
||||
按钮及主工作区右侧边距。
|
||||
- 唯一展开与收起开关固定在窗口关闭按钮正下方。工作栏内部不重复关闭入口。开关图标中的
|
||||
箭头方向随当前动作切换,按钮不以持续高亮表达展开状态;当前会话没有 Task 时不渲染空
|
||||
Task 上下文栏。
|
||||
- “新建任务”入口位于工作栏“任务索引”标题后方,不在会话 Task 上下文栏重复出现;索引
|
||||
状态筛选占满可用宽度并等分各筛选项。
|
||||
- 终端、宽日志和大型成果允许用户切换到底部停靠或独立窗口。
|
||||
- 应用只建议适合的布局,不因面板内容自动改变用户已经选择的停靠位置。
|
||||
|
||||
@@ -273,6 +288,8 @@ Conversation,默认 Execute,并持续显示 Runtime、Project、工作目录
|
||||
|
||||
Runtime 能力统一监督直连模型、OpenCode、Continue、DeepSeek Harness 和后续 Runtime。
|
||||
能力在目录中稳定可发现,打开的面板实例依据所选 Runtime 的真实能力显示状态。
|
||||
远程实现先只交付 OpenCode 官方 ACP 闭环;Continue 与 DeepSeek Harness 后续复用同一
|
||||
面板和通道契约。产品不为并行建设多套后端而增加用户可见模式或临时入口。
|
||||
|
||||
共同区域:
|
||||
|
||||
@@ -310,7 +327,7 @@ Runtime 能力统一监督直连模型、OpenCode、Continue、DeepSeek Harness
|
||||
进程面板只展示 GoodBuddy 创建、托管或明确接管的进程:
|
||||
|
||||
- 用户终端 Shell。
|
||||
- Runtime Host、Server、Utility 和远程 Helper。
|
||||
- Runtime Host、Server、Utility 和远程 GoodBuddy Agent。
|
||||
- Runtime 后台 Job。
|
||||
- 用户通过工作栏显式启动的长运行命令。
|
||||
- 浏览器或自动化中属于 GoodBuddy 的受管子进程摘要。
|
||||
@@ -342,7 +359,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
|
||||
- 显式打开、下载副本或在终端中打开。
|
||||
- HTML 文件的源码与安全预览。
|
||||
|
||||
本机和远程访问都必须由 Main 或远程 Helper 在对应文件系统上执行路径规范化、相对路径和
|
||||
本机和远程访问都必须由 Main 或远程 GoodBuddy Agent 在对应文件系统上执行路径规范化、相对路径和
|
||||
符号链接边界检查。Renderer 只能提交受约束的相对路径和已授权范围 ID。
|
||||
|
||||
### 7.7 浏览器
|
||||
@@ -387,18 +404,15 @@ Task 呈现来源,不把 Run 作为导航对象。
|
||||
- 不使用 `dangerouslySetInnerHTML`,不启用 Electron `webviewTag`。
|
||||
- 外部打开是明确的用户操作,并说明外部浏览器可能执行脚本或联网。
|
||||
|
||||
### 7.9 上下文
|
||||
### 7.9 Composer 与任务上下文
|
||||
|
||||
上下文面板显示用户已选择或系统准备送入下一次模型请求的内容:
|
||||
工作栏不提供独立“上下文”能力。用户准备下一次模型请求时:
|
||||
|
||||
- 附件、图片和文档提取结果。
|
||||
- 知识库、引用和检索范围。
|
||||
- 已确认记忆。
|
||||
- 浏览器、工作区文件和授权目录。
|
||||
- Runtime、监督或自动化显式绑定的其他上下文。
|
||||
|
||||
每项显示来源、范围、大小、发送状态和用途。用户可以预览、移除或清空。查看 Task 的历史
|
||||
执行上下文时只读展示不可变快照;跟随当前 Composer 时才允许编辑下一次请求的上下文。
|
||||
- 附件、图片、文档提取结果以及知识库范围在 Composer 中展示与管理。
|
||||
- Composer 提供与发送行为直接关联的预览、移除和清空操作,不在工作栏重复入口或状态。
|
||||
- 浏览器、工作区文件、授权目录以及 Runtime 或自动化绑定内容由各自能力展示。
|
||||
- Task 的历史执行上下文作为不可变快照随 Task 查看。
|
||||
- 记忆功能实现前不展示占位内容;实现后在任务中心按关联 Task 展示和管理。
|
||||
|
||||
## 8. 范围和选择模型
|
||||
|
||||
@@ -415,7 +429,6 @@ type WorkbarCapabilityId =
|
||||
| 'workspace'
|
||||
| 'browser'
|
||||
| 'results'
|
||||
| 'context'
|
||||
|
||||
type WorkbarTargetRef =
|
||||
| { type: 'conversation'; id: string }
|
||||
@@ -455,18 +468,90 @@ type WorkbarPanelInstance = {
|
||||
|
||||
## 9. 主机管理与远程执行空间
|
||||
|
||||
本节定义产品入口和用户行为。Host Key、凭据、远程 Agent 协议、模型请求代理、签名更新、
|
||||
断线和 Runtime 适配的技术边界以
|
||||
[SSH 远程主机与 GoodBuddy Agent 稳定终态设计](../../architecture/remote-host-and-goodbuddy-agent-design.md)
|
||||
为准。
|
||||
|
||||
### 9.1 设置入口
|
||||
|
||||
设置中心增加“主机与远程执行”分类,管理:
|
||||
设置中心“平台功能”提供独立“远程项目(技术预览)”页签及开关,默认关闭。关闭时不显示
|
||||
“主机与远程执行”分类、托管 SSH 项目创建选项和已保存远程项目,Main IPC 同时拒绝新的
|
||||
SSH/远程项目操作;已有 Host、项目和凭据保持不变。若当前项目已经是远程项目,则切回
|
||||
项目列表中的第一个普通
|
||||
本地项目。重新启用后恢复完整入口和既有数据。
|
||||
|
||||
启用后,设置中心显示“主机与远程执行”分类,管理:
|
||||
|
||||
- 主机名称、地址、端口和用户名。
|
||||
- 认证方式和凭据配置状态。
|
||||
- Host Key 算法与 SHA-256 指纹。
|
||||
- 连接测试、远程系统和架构。
|
||||
- Helper、Runtime 和能力状态。
|
||||
- 引导式身份/认证验证、远程系统和架构。
|
||||
- GoodBuddy Agent、Runtime 和能力状态。
|
||||
- 删除、重新验证或更新 Host Key。
|
||||
|
||||
主机配置是全局资源。项目或工作栏只引用主机 ID,不能复制凭据。
|
||||
删除 Host 前列出所有引用它的本地项目记录,包括归档项目;确认后仅删除本机 Host、凭据、
|
||||
项目和关联数据,不建立 SSH 连接,也不删除远端目录或内容。若当前项目被删除,界面切回
|
||||
普通本地项目。
|
||||
|
||||
当前实施状态:设置中心已经实现主机 CRUD 和模态验证向导。新增或目标/认证编辑依次完成
|
||||
连接信息、认证前 Host Key 核对、密码/系统 SSH Agent 认证和平台/架构/Shell/Home 有界
|
||||
探针,全部成功后才原子保存主机、加密凭据和完整 Host Key。认证失败可在弹窗内修改凭据
|
||||
重试;取消或失败不保留新主机。Key 变化显示旧/新指纹并要求显式高风险确认。项目创建和
|
||||
设置现可选择托管 SSH、已保存 Host、远端工作目录、OpenCode 和 Ask/Execute,不要求
|
||||
额外 trust tier 或 consent checklist。候选完成签名 Agent/Runtime、Workspace、ACP、模型桥
|
||||
和文本模型 profile 验证后原子提交。私钥导入仍未开放。旧流程已保存但
|
||||
没有成功探针记录的主机继续读取,但在完成新向导前显示为“需要重新验证”。
|
||||
|
||||
当前源码已把 Agent/Runtime 生命周期归入 Host 管理。新增或重新验证 Host
|
||||
在身份、认证和系统探针成功后先保存 Host,再只读探测实际组件状态和瞬时下载能力,然后
|
||||
结束新增向导。产品不提供自动安装开关,也不在保存后传输包。Host 卡片只有一个按版本
|
||||
事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮;次级 SegmentedControl
|
||||
选择“自动”“Host 下载”或“GoodBuddy 传输”,默认“自动”且不持久化。版本匹配 badge
|
||||
不代表环境健康。环境准备失败或取消保留 Host 并显示可重试状态;重新安装失败时显示
|
||||
本次操作未完成,并以随后重新检查的版本卡片表达当前事实,不对不确定提交宣称旧版本
|
||||
未被替换。详细技术契约见
|
||||
[SSH Host 远程环境准备与直连下载设计](../../architecture/remote-host-environment-provisioning-design.md)。
|
||||
|
||||
进入或切回“主机与远程执行”页面只读取本地 Host 列表,不逐台建立 SSH 连接。版本和瞬时
|
||||
下载能力仅在用户点击某台 Host 的“刷新版本”时探测。该动作不是项目连接前置条件;用户
|
||||
切换已有远程项目只更新 Renderer 的本地项目选择,不建立 Host/Agent 连接。
|
||||
新建远程项目弹窗同样只读取本地 Host 验证记录,不扫描所有 Host;浏览目录或保存时才连接
|
||||
所选 Host。版本号相同时仍允许用户显式重新安装,以修复 registry、签名或 identity 异常,
|
||||
且不删除 Host、凭据或项目。由远程主机安装只使用 SSH 和远端系统工具,不依赖已有 Agent;
|
||||
“自动”仅在操作开始时探测:直连明确可用才选 Host 下载,否则选 GoodBuddy 传输;用户
|
||||
显式选择不被改写,prepare、commit 或 adoption 失败后不在同次操作中切换 acquisition。
|
||||
|
||||
项目选择器中每个项目的管理按钮默认隐藏,在悬停、键盘焦点或触屏环境显示。该入口直接
|
||||
打开目标项目设置而不先切换项目,因此 Host 不可达或远端目录已不存在时仍可删除本地项目
|
||||
记录;删除操作从不删除远端项目内容。
|
||||
|
||||
应用重启后固定进入项目列表中的第一个普通本地项目。用户主动打开已有托管 SSH 项目时只
|
||||
切换本地项目配置;首次实际使用 Workspace 或 Runtime 时,Main 才通过 Agent/Runtime
|
||||
安装管理器解析 Host current identity,执行固定 `attach-or-bootstrap`,并使用同一 Agent
|
||||
连接。
|
||||
项目数据库不保存 Agent installation、Host revision、Workspace identity 或 Runtime
|
||||
digest。当前 registry、连接或 capability 无效时项目保持未切换,并提示到 Host 管理显式
|
||||
修复。当前进程缓存每个 Host 已确认的 Agent/Runtime identity,并复用 Agent 连接;Host
|
||||
编辑或环境更新会定向清空。Agent 的 attach/按需启动只读取 Host 管理已提交的 registry 与
|
||||
匹配 manifest 元数据,不重新验签或哈希完整 Agent payload。项目切换器立即提交本地选择,
|
||||
不展示远程激活进度;完整进度和取消只属于新建或显式保存项目。
|
||||
|
||||
项目只持久化 Host ID、规范远端路径、Runtime 选择和默认工作模式。新建和保存验证当前
|
||||
Workspace/Runtime,但不写入组件 identity;普通切换不做远程验证。未就绪或版本过旧时,
|
||||
实际 Workspace/Runtime 操作显示错误。桌面更新造成组件版本变化时,用户先在 Host 管理中
|
||||
准备环境;项目切换本身不连接、不下载、不扫描、不安装,也不改写项目。
|
||||
|
||||
已保存 Host 的地址、用户或固定 Host Key 变化后,Main 关闭旧连接并定向退役该 Host 的
|
||||
Runtime 缓存。引用项目下次选择时使用 Host 管理的当前 revision、Host Key 和用户执行固定
|
||||
attach 并读取 current registry,不存在项目中的旧 Host revision 或组件 identity;
|
||||
当前环境无效时才要求显式修复。
|
||||
|
||||
新建托管 SSH 项目时,远端工作目录既可手工输入,也可通过文件夹按钮打开 Main 管理的
|
||||
只读、有界 SFTP 目录选择器。选择器从 SSH 账号 Home 或当前有效绝对路径开始,只返回目录,
|
||||
支持上级、刷新、取消和选择当前目录;Host 变化、连接错误或取消都保留草稿,选择结果不会
|
||||
绕过正常项目验证与保存事务。
|
||||
|
||||
### 9.2 凭据和主机验证
|
||||
|
||||
@@ -474,33 +559,69 @@ type WorkbarPanelInstance = {
|
||||
- 导入私钥或密码时使用 Electron `safeStorage` 加密。
|
||||
- 凭据绑定主机 ID、地址、端口、用户名和认证类型。
|
||||
- Renderer 只接收 `credentialConfigured`、来源和错误状态。
|
||||
- 首次连接展示 Host Key 算法和 SHA-256 指纹,必须由用户显式接受。
|
||||
- Host Key 检查使用 Main 内五分钟有效、目标绑定的临时候选,检查阶段不接收认证凭据;
|
||||
认证和系统探针成功前不持久化新增主机,取消或失败不留下主机或密码。
|
||||
- 首次连接必须在发送密码、私钥签名或 SSH Agent 签名前取得并展示 Host Key 算法和
|
||||
SHA-256 指纹,由用户显式接受;持久化完整 Host Key 或可信 Host CA,不能只保存展示指纹。
|
||||
- Host Key 变化硬失败,并通过独立高风险流程替换。
|
||||
- 禁止 `StrictHostKeyChecking=no` 和默认 SSH Agent Forwarding。
|
||||
- 命令参数、URL、日志、SQLite 和 IPC 中不得出现私钥或密码。
|
||||
- 命令参数、URL、日志、SQLite 明文和 IPC 响应中不得出现私钥或密码;用户在受信任表单
|
||||
输入的密码只通过严格校验的单用途 IPC 请求交给 Main,Renderer 不得读取已保存密码。
|
||||
|
||||
### 9.3 远程 Helper
|
||||
### 9.3 远程 GoodBuddy Agent
|
||||
|
||||
远程能力通过版本化 GoodBuddy Helper 提供:
|
||||
远程能力通过版本化 GoodBuddy Agent 提供:
|
||||
|
||||
- 使用 SSH exec 或受控通道启动,不依赖字符串拼接 Shell 命令。
|
||||
- 安装到远程用户级受管目录,不要求 root。
|
||||
- 上传内容使用固定版本、大小和 SHA-256 校验,临时写入后原子替换。
|
||||
- 握手报告协议版本、系统、架构和能力。
|
||||
- 安装到远程当前用户的 GoodBuddy-owned 目录,不要求 root。首次连接先 attach,Agent
|
||||
不存在或未运行时幂等 bootstrap 为 detached process,再重新 attach;不注册开机服务,
|
||||
不依赖 systemd、D-Bus 或 `Linger=yes`。
|
||||
- SSH 负责 Host Key、认证、固定 bootstrap、可选 SFTP 安装和 `goodbuddy-agent attach`
|
||||
中继;attach 只连接用户私有 Unix socket。SSH relay 断开不终止 Agent 或活动 Runtime。
|
||||
- Host 环境准备可让远端按签名目录固定的 GitHub/北京镜像 URL、大小和 SHA-256 下载完整
|
||||
`.gbagent`,也可由 GoodBuddy 下载、完整验证后通过有界流式 SFTP 传输一个 compound
|
||||
archive 和归档中已验证的 bootstrap Node;完整包不一次读入 Main `Buffer`,Host 仍再次
|
||||
校验。两种 acquisition 交付到固定 operation staging 后共用
|
||||
`prepare → commit → Agent activate/health → Runtime activate → finalize → cleanup`。
|
||||
- 直连首次 bootstrap 不依赖已安装 Agent daemon。GoodBuddy 桌面控制面通过固定 SSH
|
||||
prepare channel 发送有界 one-shot installer;Host 先验证目录绑定的归档大小和 SHA-256,
|
||||
再由 format v1 归档内固定 Node 解码并运行 installer。归档无需包含
|
||||
`agent/lib/package-installer.cjs`,目录不提供或检查额外的 bootstrap 能力元数据。
|
||||
- Agent 与 Runtime 各自维护版本,签名摘要标识精确工件。Host 在解包到准备目录的同一遍
|
||||
处理中完成完整 payload 校验,commit 只复核签名 metadata 并原子替换 side-by-side
|
||||
目录,不重新读取归档、扫描旧目录或使用跨安装硬链接。
|
||||
- commit 终态持久化;控制通道丢失时,`commit-status` 读取终态,或从已认证准备状态
|
||||
幂等完成中断的目录/metadata 发布,不重新下载或准备包。prepare 完成后、commit 前快照
|
||||
Agent/Runtime 五个 metadata 文件;任一组件 adoption 失败恢复原字节或原缺失状态,
|
||||
已发布 payload 不得成为 current。新 Agent/Runtime 各自最多完整验证一次;注册后的
|
||||
health、capabilities 和 prompt 启动只检查 registry、签名 manifest 与入口 metadata。
|
||||
确认 adoption 后显式 cleanup,cleanup 失败不回滚健康环境。
|
||||
- 握手报告 installation、Daemon boot identity、协议、系统、架构、用户监督器和能力。
|
||||
- 在远程执行路径规范化、Git、文件、PTY、进程组和 Runtime 管理。
|
||||
- 对事件、日志、文件、帧、超时、并发和总传输量设置上限。
|
||||
- 断开或租约过期后终止孤儿进程。
|
||||
- 持久化有界 Agent 到 Main 输出;SSH 断开后 Agent 继续运行,同一 Agent 重连时只恢复
|
||||
可确认的输出。Main 到 Runtime 的输入、工具、模型和 blob 请求不自动重放。
|
||||
- 对有副作用的请求使用稳定 operation ID 和 payload digest 去重;结果无法证明时进入
|
||||
`outcome-unknown`,禁止自动重放。
|
||||
- 对事件、日志、journal、文件、帧、超时、并发、缓存和总传输量设置硬上限。
|
||||
- Daemon、Runtime、受管进程和用户终端分别具有明确 lease、取消、drain 和回收语义。
|
||||
- Agent 不可用或身份不匹配时远程执行失败,不退化为 SSH stdio Runtime。
|
||||
|
||||
首期断线后把活动运行标记为 `interrupted`,撤销短期能力并要求用户重试;在事件序列、租约、
|
||||
重放和幂等附加完成前,不宣称可以无损恢复。
|
||||
Host Key、远端用户或目标变化会关闭旧连接,并使绑定旧 Host identity 的 Workspace 和
|
||||
Runtime 会话失效。产品不维护 T1/T2/T3 等附加信任层级。
|
||||
|
||||
### 9.4 远程 Runtime
|
||||
|
||||
- Runtime 在远程执行空间内运行,不能让本机 Runtime 对远程路径进行伪本地操作。
|
||||
- Main 保持可信控制面,远程 Helper 只接受有范围、有期限的请求。
|
||||
- Ask 的只读限制在远程 Helper 和 Runtime 适配层共同强制。
|
||||
- Execute 继续经过 Runtime 工具策略、审批、取消、超时和审计。
|
||||
- 模型凭据优先留在 Main,通过仅绑定远程回环的 SSH 隧道和请求级代理提供。
|
||||
- Main 保持可信控制面,远程 GoodBuddy Agent 只接受有范围、有期限的请求。
|
||||
- GoodBuddy 控制协议负责 Agent、Workspace、Git、Process、Blob 和 Runtime supervision;
|
||||
Runtime Session、stream、tool、permission、usage 和取消优先复用 ACP。
|
||||
- Ask 的只读限制在远程 GoodBuddy Agent 和 Runtime 适配层共同强制。
|
||||
- Execute 是用户对所选 SSH 账号完整权限的授权,直接以该账号可用的文件、进程、网络和
|
||||
工具能力运行,不要求逐工具审批或第二个“受控执行”模式。取消、超时、输出上限和活动
|
||||
记录继续保留。
|
||||
- GoodBuddy 模型凭据和 Provider URL 必须留在 Main。远程 Runtime 只获得不含凭据的模型
|
||||
policy、私有 Unix socket、隔离 loopback endpoint 和 Prompt-bound blob channel;
|
||||
Main 按 durable round、profile digest、调用次数、最坏输出 token 预留和最终 delivery ACK
|
||||
代发,不得把原始 API Key 或认证头发送到远端。
|
||||
- 不向远程 Runtime 暴露通用本机 MCP、浏览器、文件系统或其他未分配能力。
|
||||
|
||||
## 10. Runtime 与进程统一生命周期
|
||||
@@ -513,11 +634,13 @@ type ManagedLifecycleState =
|
||||
| 'running'
|
||||
| 'waiting_approval'
|
||||
| 'paused'
|
||||
| 'reconciling'
|
||||
| 'stopping'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'interrupted'
|
||||
| 'outcome_unknown'
|
||||
```
|
||||
|
||||
每个 Runtime 会话、Task 级执行、终端或受管进程公开:
|
||||
@@ -578,13 +701,16 @@ terminal_sessions
|
||||
managed_processes
|
||||
runtime_sessions
|
||||
runtime_jobs
|
||||
remote_agent_installations
|
||||
remote_operations
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- 工作栏偏好只保存能力目录顺序、面板实例、停靠布局、尺寸和目标引用。
|
||||
- 主机表只保存非敏感元数据和加密凭据引用。
|
||||
- 活动终端和进程在应用重启时标记为中断,除非对应远程租约可验证恢复。
|
||||
- 应用重启后先通过远程 Daemon 的 journal、lease、boot identity 和 event cursor 核对活动
|
||||
终端、进程与 Runtime;无法确认时标记 `interrupted` 或 `outcome_unknown`。
|
||||
- 日志使用有界环形缓冲或分页持久化,不能无限写入 SQLite。
|
||||
- Artifact 继续作为成果的权威实体,不把完整成果复制进工作栏状态。
|
||||
- 当前 Renderer `localStorage` 活动记录不能作为 Runtime、监督或进程的权威来源。
|
||||
@@ -594,11 +720,11 @@ runtime_jobs
|
||||
Renderer 只通过显式方法访问:
|
||||
|
||||
- 工作栏偏好和目标绑定。
|
||||
- 主机 CRUD、测试和 Host Key 确认。
|
||||
- 主机 CRUD、临时 Host Key 检查、验证并原子保存。
|
||||
- 终端创建、输入、调整大小、关闭和有界输出订阅。
|
||||
- 受管进程列表、日志和允许的控制动作。
|
||||
- Runtime Inspector 快照、事件和允许的控制动作。
|
||||
- 工作区、成果、浏览器、监督和上下文的既有或扩展服务。
|
||||
- 工作区、成果、浏览器和监督的既有或扩展服务。
|
||||
|
||||
每个 Main Handler 都必须验证可信发送者、Zod 输入、对象归属和当前状态。不得暴露 raw
|
||||
Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
@@ -645,7 +771,9 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
- HTML、文件、目录、浏览器画面和远程传输沿用或收紧现有大小限制。
|
||||
- Runtime Snapshot 与事件流分离,重新打开时先取权威快照,再接增量事件。
|
||||
- 监督使用独立低优先级并发池和预算,不延迟前台回答。
|
||||
- 应用退出时停止新操作,取消订阅,关闭终端、隧道和 Helper,并在期限内标记未完成对象。
|
||||
- 应用退出时停止新操作并让用户选择取消、等待或 detach;关闭 SSH attach 不卸载或终止
|
||||
用户级 GoodBuddy Agent Daemon。依赖 Main 模型网关、审批或本机工具的 Run 不能被描述为
|
||||
可以无人值守继续。
|
||||
|
||||
## 15. 无障碍与响应式
|
||||
|
||||
@@ -660,12 +788,16 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
|
||||
## 16. 分阶段实施
|
||||
|
||||
本节只表示内部依赖顺序,不表示可以发布临时架构。SSH 远程执行只使用用户级 Daemon,
|
||||
不发布连接期 stdio Agent。OpenCode Ask、OpenCode Execute 和后续 Runtime 分别通过自身的
|
||||
安装、权限、取消和重连门槛后开放,未完成的能力继续显示不可用。
|
||||
|
||||
### 阶段 0:应用级工作栏壳层
|
||||
|
||||
- 将当前聊天专属右栏提升为应用级壳层。
|
||||
- 建立稳定能力目录和用户打开、关闭、排序、停靠的面板实例模型。
|
||||
- 建立实例级跟随、固定和失效目标语义。
|
||||
- 保留现有任务中心、上下文、工作区、浏览器和成果行为。
|
||||
- 保留现有任务中心、工作区、浏览器和成果行为;附件与知识库继续由 Composer 管理。
|
||||
- 将 Task Center 明确为 Task 的单例索引,并补齐范围、状态、最近进展、需要关注和直接打开 Conversation。
|
||||
- 审批在所属任务或 Runtime 中持续可见,不新增独立审批面板。
|
||||
- 智能心跳菜单入口承接完整配置和范围后,再从任务中心移除重复表单;不得移除任务中心本身。
|
||||
@@ -695,15 +827,32 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
|
||||
### 阶段 4:SSH 主机与远程执行空间
|
||||
|
||||
- 主机管理、加密凭据和 Host Key 固定。
|
||||
- Linux x64/arm64 Helper 安装与握手。
|
||||
- [x] 引导式主机管理、先验证后保存、密码安全存储、系统 SSH Agent、Host Key 固定和
|
||||
有界系统探针。
|
||||
- [ ] 私钥与受限 OpenSSH config 导入。
|
||||
- 签名、side-by-side GoodBuddy Agent 安装和按需 detached Daemon。
|
||||
- SSH connection pool、固定 attach relay、Agent 控制协议、Agent 本地 ACP 日志和断线重连同步。
|
||||
- 远程工作区、Git、终端和受管进程。
|
||||
- 远程 Runtime 执行、取消、超时和审计。
|
||||
- 首期断线明确标记中断,不承诺恢复。
|
||||
- 先完成签名 OpenCode bundle、Ask bubblewrap、Execute 直接进程、官方 ACP channel、
|
||||
Main 模型网关、取消、超时和活动记录。
|
||||
- OpenCode 闭环稳定后,Continue 与 DeepSeek Harness 复用相同面板和通道契约逐个接入。
|
||||
- 新增或重新验证 Host 时只保存并探测 Agent/Runtime,不自动安装。用户在 Host 卡片用
|
||||
单一主按钮启动,并用次级控件选择自动、Host 下载或 GoodBuddy 传输;Host 保存与环境
|
||||
准备是两个可独立重试的事务。
|
||||
- 控制面直连源码直接使用既有 package format v1 包;不等待发布携带 installer 的新
|
||||
Agent 包,也不通过额外目录元数据判断 bootstrap 能力。GitHub/北京镜像、
|
||||
Linux x64/arm64、取消和离线 GoodBuddy 传输仍须真实 Host 验收,不能据此声称已发布或已完成
|
||||
真实 Host 测试。
|
||||
- 一次远程项目保存请求完成 Host/Agent/Workspace/Runtime 当前环境验证和 SQLite 原子
|
||||
提交,但只持久化 Host、路径、Runtime 选择和模式,也不获取或安装组件。
|
||||
- 桌面更新后,用户先在 Host 管理中更新当前签名 Agent/Runtime;远程项目下一次实际使用
|
||||
Workspace/Runtime 时直接使用新的 current 环境,无需刷新项目绑定。应用启动和项目切换
|
||||
本身都不连接 Host,也不下载或安装组件。
|
||||
- SSH、Main、Daemon、Runtime 和远端重启后的核对、恢复与 `outcome_unknown`。
|
||||
- 支持平台的安装、Ask、Execute、取消和重连冒烟测试通过后开放对应 OpenCode 能力。
|
||||
|
||||
### 阶段 5:恢复与扩展
|
||||
### 阶段 5:开放后的扩展
|
||||
|
||||
- 远程租约、事件重放和幂等重连。
|
||||
- 更多远程系统和架构。
|
||||
- PDF、Office 和数据成果预览。
|
||||
- Conversation、Task 和实验的完整监督。
|
||||
@@ -713,7 +862,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
|
||||
### 17.1 稳定能力目录与用户控制
|
||||
|
||||
- [ ] 九个标准能力在所有主要页面的目录中始终可发现,但不会默认同时打开。
|
||||
- [ ] 八个标准能力在所有主要页面的目录中始终可发现,但不会默认同时打开。
|
||||
- [ ] Task Center 继续作为 Task 的单例索引,不删除入口、不复制会话,也不混入 Job、Run 或心跳事项。
|
||||
- [ ] 项目、会话、Runtime 或主机变化不会无提示地增删能力目录项。
|
||||
- [ ] 用户可以按需打开、关闭、排序和停靠面板实例。
|
||||
@@ -739,11 +888,21 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
- [ ] Supervisor 不能自动发送消息、批准工具、切换工作模式或扩大范围。
|
||||
- [ ] SSH 首次连接和 Host Key 变化均经过明确验证流程。
|
||||
- [ ] 凭据不进入 Renderer、日志、SQLite 明文或命令参数。
|
||||
- [ ] Host 地址、用户或 Host Key 变化会关闭旧连接,并使旧 Workspace 和 Runtime 会话失效。
|
||||
|
||||
### 17.4 生命周期与恢复
|
||||
|
||||
- [ ] Runtime、终端、Task 级执行和进程具有权威 Main 快照和有序增量事件。
|
||||
- [ ] 取消、终止、失败、断线和应用退出都有确定终态。
|
||||
- [ ] 远程执行只通过按需 detached Daemon 提供,SSH attach 断开不销毁 Daemon 状态。
|
||||
- [ ] 重连先恢复 event cursor 并查询 operation;有副作用操作不会自动重放,无法确认的
|
||||
结果显示为 `outcome_unknown`。
|
||||
- [ ] Agent side-by-side 更新支持所需签名版本的健康切换,失败时保留旧项目绑定。
|
||||
- [x] 新增 Host 只探测远端下载能力,不提供自动安装开关,也不传输完整包;Host 卡片用
|
||||
一个按版本事实命名的主按钮与不持久化的 acquisition SegmentedControl 启动准备。
|
||||
- [x] 创建、保存、打开和切换托管 SSH 项目都从 Host current registry 与当前 Agent
|
||||
连接验证环境;项目只保存稳定配置,且这些流程不下载 `.gbagent`、上传 payload 或发布
|
||||
安装。
|
||||
- [ ] 切换跟随目标后不显示上一对象的过期状态。
|
||||
- [ ] 固定目标的订阅在页面切换后保持,关闭时正确释放。
|
||||
- [ ] 日志、终端、文件、成果和远程传输均有明确上限和背压。
|
||||
@@ -751,6 +910,8 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
### 17.5 可用性
|
||||
|
||||
- [ ] 宽、中、窄窗口均可访问完整能力目录和用户打开的面板实例。
|
||||
- [ ] 工作栏在宽、中、窄窗口展开时都推动主工作区回流,不覆盖、遮罩或隔离主内容;唯一
|
||||
开关始终固定在窗口关闭按钮正下方。
|
||||
- [ ] 仅使用键盘可以选择能力、面板实例、目标、执行空间和控制动作。
|
||||
- [ ] 状态不只依赖颜色,徽标具有文字或可访问名称。
|
||||
- [ ] 终端、HTML、监督证据和高频日志具有可访问替代或降噪行为。
|
||||
@@ -766,8 +927,10 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
- [自动化平台总体设计](../../architecture/automation-platform-architecture.md) 定义 Plan、Job、Run、监督、预算和记忆。
|
||||
- [长期助手路线图](../../roadmap/long-term-assistant-roadmap.md) 记录整体长期能力与实施背景。
|
||||
- [DeepSeek Harness Runtime 设计](../../architecture/deepseek-harness-runtime-design.md) 定义该 Runtime 的具体适配边界。
|
||||
- [SSH Host 远程环境准备与直连下载设计](../../architecture/remote-host-environment-provisioning-design.md)
|
||||
定义 Host 级组件获取、首次准备、更新、回退和项目只验证边界。
|
||||
- [统一界面设计系统](../../../UI-DESIGN.md) 定义视觉、语义、响应式和无障碍规则。
|
||||
|
||||
若其他文档把工作栏描述为九个同时固定显示的栏目、根据项目或 Runtime 自动裁剪的动态入口,
|
||||
若其他文档把工作栏描述为八个同时固定显示的栏目、根据项目或 Runtime 自动裁剪的动态入口,
|
||||
或仅属于当前聊天的附属区域,以本文“能力目录稳定、面板实例由用户打开、当前上下文只提供
|
||||
默认值”的产品决策为准。
|
||||
|
||||
@@ -19,7 +19,7 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
|
||||
3. 远程执行任务使用哪个工作目录。
|
||||
4. 执行请求、处理后端、工具调用和结果归属于哪个范围。
|
||||
|
||||
腾讯官方微信 ClawBot 使用扫码授权,不使用 App ID 或 Secret,并由本机独立 Sidecar 持续运行通信服务。文字、图片和文件收发、只读对话与受控执行已经实现;真实微信账号的认证媒体端到端联调仍作为发布前手动验收。
|
||||
腾讯官方微信 ClawBot 使用扫码授权,不使用 App ID 或 Secret,并由本机独立 Sidecar 持续运行通信服务。文字、图片和文件收发、Ask 只读对话与 Execute 完整授权已经实现;真实微信账号的认证媒体端到端联调仍作为发布前手动验收。
|
||||
|
||||
为避免把通道来源、发送者身份和执行范围混在一起,本功能使用“通道项目 + 远程会话”的两级结构:
|
||||
|
||||
@@ -51,7 +51,7 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
|
||||
- 在项目切换器中一眼识别微信、企业微信和钉钉来源。
|
||||
- 在通道项目中区分不同发送者和群聊。
|
||||
- 在设置卡片中完成连接、启停、处理后端、模式和工作目录配置。
|
||||
- 通过微信进行只读问答或发起受控执行任务。
|
||||
- 通过微信进行 Ask 只读问答或发起 Execute 任务。
|
||||
- 选择可信的执行后端,并查看完整执行记录。
|
||||
- 保留通道关闭前后的历史上下文和审计记录。
|
||||
|
||||
|
||||
@@ -188,7 +188,9 @@ type ParsedDocument = {
|
||||
Tiny、Small 和 Medium 模型使用 PaddlePaddle 发布的规范工件。GoodBuddy 为 ModelScope
|
||||
和 Hugging Face 分别维护固定下载 Target,默认使用 ModelScope。Small 是默认推荐档位;
|
||||
Medium 面向更高识别质量,但具有更高内存占用和延迟。每个档位的检测模型、识别模型与
|
||||
字符字典配置分别使用固定提交,并在应用内记录共同的文件字节数和 SHA-256。
|
||||
字符字典配置分别使用固定提交,并在应用内记录各来源解析后的文件字节数和 SHA-256。
|
||||
当前 OCR 两个来源的对应文件字节一致;共享下载契约同时支持后续来源声明不同的固定
|
||||
工件指纹。
|
||||
|
||||
下载流程:
|
||||
|
||||
@@ -221,7 +223,7 @@ ZIP 根目录包含模型文件和 `goodbuddy-model.json`。清单格式为 `goo
|
||||
- 文件名、角色、原始字节数和 SHA-256;
|
||||
- 导出时间。
|
||||
|
||||
导出不能直接信任已有安装清单,必须重新读取并校验每个文件。导入不能只信任 ZIP 自声明内容,模型 ID、文件角色、字节数和哈希必须再次与当前应用内置目录完全匹配。导入通过后复用普通本地安装的受控临时目录和原子重命名路径。
|
||||
导出不能直接信任已有安装清单,必须重新读取并校验每个文件。导入不能只信任 ZIP 自声明内容,模型 ID、文件角色、字节数和哈希必须再次完整匹配当前应用内置目录的默认包或任一来源包;不接受逐文件跨来源组合。导入通过后复用普通本地安装的受控临时目录和原子重命名路径。
|
||||
|
||||
归档处理使用有界流式读写,不把大型模型或整个展开结果复制到内存。主进程限制压缩包大小、条目数、清单大小、单文件大小和总展开大小,并拒绝:
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 实施中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-08-11 |
|
||||
| 版本 | 0.2 |
|
||||
| 日期 | 2026-08-22 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 实施范围 | 第一阶段:可用、可见、可诊断;第二阶段:可调、可优化、可维护 |
|
||||
| 相关设计 | [本地文本向量模型与连接设计](../../architecture/local-text-embedding-model-design.md) |
|
||||
@@ -40,7 +40,7 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
|
||||
预检索,不能只依赖提示词要求模型调用工具。
|
||||
4. 知识库新建后不默认启用全部已有知识库;对话中的范围继续由用户显式选择。
|
||||
5. 向量服务不可用时保留全文与中文检索,但必须返回明确降级状态。这是可见的检索通道
|
||||
降级,不得自动切换应用托管模型、Ollama、云端 Provider 或其他向量模型。
|
||||
降级,不得自动切换内置连接、用户添加的 OpenAI 兼容连接或其他向量模型。
|
||||
6. 中文召回使用应用内可控的 CJK n-gram 索引,不新增远程服务依赖。
|
||||
7. 混合检索保留 RRF 候选融合,并增加本地确定性重排、可选的
|
||||
Cohere/Jina 兼容学习型重排、最低相关度和上下文预算。学习型重排失败时
|
||||
@@ -60,6 +60,12 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
|
||||
用户配置决定。
|
||||
14. Ask 的运行时边界保持只读。知识库内容始终被标记为不可信证据,
|
||||
不能成为系统指令。
|
||||
15. 向量连接是 GoodBuddy Main 管理的知识服务依赖,不是 Agent Runtime 模型。它不进入
|
||||
直连模型、OpenCode、Continue、DeepSeek Harness、远程 Runtime 或项目 Runtime 的
|
||||
模型选择、配置和凭据边界。
|
||||
16. Runtime 只能获得 Main 检索后选出的有界文本证据、来源引用和脱敏诊断,不能获得
|
||||
Embedding Provider、连接 Profile、Endpoint、API Key、模型目录、Tokenizer、原始向量
|
||||
或索引 Fingerprint。
|
||||
|
||||
## 3. 目标
|
||||
|
||||
@@ -102,6 +108,9 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
|
||||
- 自动问题生成、FAQ 生成和训练数据标注平台。
|
||||
- 完整 RAG 离线评测平台。第二阶段只提供手动检索测试与可导出的诊断信息。
|
||||
- 在应用内高保真渲染所有原始 Office 和 PDF 文档。
|
||||
- 把向量连接用作 LLM、Agent Runtime 模型或 Runtime 模型网关。
|
||||
- 让 Runtime 直接调用 Embeddings Endpoint、内置推理进程或向量数据库。
|
||||
- 未经独立设计就把知识库向量连接扩展到记忆、自动化、项目文件搜索或其他应用服务。
|
||||
|
||||
## 5. 竞品基线与 GoodBuddy 定位
|
||||
|
||||
@@ -160,10 +169,25 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
|
||||
“检索测试”是当前知识库的高频诊断操作,通过知识库标题区次操作打开独立
|
||||
工作台,不新增第五个一级页签。
|
||||
|
||||
全局向量模型仍在“设置 → 模型连接 → 向量模型”中配置。应用托管本地模型、
|
||||
用户自行安装的 Ollama/自托管服务和云端兼容服务的界面、数据路径及切换语义以
|
||||
全局向量模型仍在“设置 → 模型连接 → 向量模型”中配置。GoodBuddy 内置连接和用户添加的
|
||||
OpenAI 兼容连接使用同一连接列表;其界面、数据路径及切换语义以
|
||||
[本地文本向量模型与连接设计](../../architecture/local-text-embedding-model-design.md)
|
||||
为准,知识库页面只显示当前模型、索引兼容性、覆盖率和重建操作。
|
||||
为准,知识库页面只显示当前模型、索引兼容性、覆盖率和重建操作。“模型连接”是设置入口
|
||||
名称,不表示向量连接可以分配给 Agent Runtime;项目 Runtime 和 Agent Runtime 的模型
|
||||
选择中不显示向量连接。
|
||||
|
||||
知识检索与 Runtime 的关系固定为:
|
||||
|
||||
```text
|
||||
用户问题
|
||||
→ Main / KnowledgeService 使用当前向量连接检索
|
||||
→ Main 生成有界文本证据、引用和检索诊断
|
||||
→ 当前 Runtime 仅消费这些不可信检索结果
|
||||
```
|
||||
|
||||
`always` 模式由 Main 在启动 Runtime 前完成检索;`auto` 模式中的 `knowledge_search`
|
||||
也只允许 Runtime 调用 Main 的有界知识工具。两种模式都不把向量连接、凭据、模型文件、
|
||||
Tokenizer、原始向量或索引访问能力传入 Runtime。切换 Runtime 不改变当前向量连接。
|
||||
|
||||
对话输入区的知识范围弹层包含:
|
||||
|
||||
@@ -234,6 +258,10 @@ OpenAI 兼容向量模型、RRF 混合检索、知识图谱、任务状态和来
|
||||
- 相邻分块或父块合并后的实际上下文。
|
||||
- “查看分块”“打开来源”操作。
|
||||
|
||||
图谱通道对中文自然语言问题生成有界 CJK 二元词和拉丁关键词,在实体名称、
|
||||
别名和类型中确定种子,再沿具有有效文档证据的关系执行有界多跳扩展。图谱探索页
|
||||
搜索到实体时保留其一跳邻居和关系上下文,并继续允许从实体或关系打开原始证据。
|
||||
|
||||
检索测试不创建聊天消息、不写入会话历史、不调用 LLM,也不改变知识库内容。
|
||||
|
||||
### 7.5 可扩展向量搜索
|
||||
@@ -512,6 +540,7 @@ type KnowledgeRetrievalResponse = {
|
||||
| 单文档重建失败 | 保留上一版可用索引 |
|
||||
| 同名模型端点变化 | 旧 Fingerprint 索引标记不兼容,等待重建 |
|
||||
| 新向量重建失败 | 保留上一版就绪向量继续服务,单独记录失败尝试 |
|
||||
| 图谱模型空响应、超时或瞬时服务错误 | 按不超过约 8,192 个估算 Token 组织独立抽取批次,每批最多尝试两次;部分批次成功时发布已验证结果并在任务中显示具体警告;所有批次失败时图谱任务失败,但新文档的全文索引仍可发布,已有文档的显式重建继续保留上一版可用索引 |
|
||||
| 原文件已移动 | 显示来源不可用,提供重试或移除 |
|
||||
| 引用对象已删除 | 显示引用已失效,不打开任意替代路径 |
|
||||
| 上下文超预算 | 按排名截断并明确标记 |
|
||||
@@ -527,6 +556,7 @@ GoodBuddy 不上传私人检索查询或文档内容。本地诊断至少记录
|
||||
- 是否发生降级。
|
||||
- 最终结果数和上下文字符数。
|
||||
- 重建文档数、成功数、失败数和取消状态。
|
||||
- 图谱抽取批次数、部分失败警告和具体失败原因。
|
||||
|
||||
手动验收使用仓库内不含私人内容的固定样例集,覆盖:
|
||||
|
||||
@@ -567,7 +597,12 @@ GoodBuddy 不上传私人检索查询或文档内容。本地诊断至少记录
|
||||
- “每次先检索”在 Runtime 启动前产生检索诊断和引用,即使模型未调用工具。
|
||||
- 未配置向量模型时,中文改写问题仍能通过 CJK 索引召回相关分块。
|
||||
- 向量查询失败时回答可继续,界面明确显示已降级。
|
||||
- 应用托管模型、Ollama 和云端向量连接之间不会自动切换;实际数据路径持续可见。
|
||||
- 内置连接和用户添加的 OpenAI 兼容连接之间不会自动切换;用户连接的实际数据去向持续
|
||||
可见,内置连接不重复展示固定本地路径。
|
||||
- 项目 Runtime 和 Agent Runtime 的模型选择不显示向量连接。
|
||||
- Runtime 只获得有界文本证据、引用和脱敏诊断,不获得向量 Provider、凭据或原始向量。
|
||||
- 切换直连模型、OpenCode、Continue、DeepSeek Harness 或远程 Runtime 不改变当前向量
|
||||
连接。
|
||||
- 10,000 个分块的向量测试能够返回正确 Top K,不出现固定上限空结果。
|
||||
- 同名模型切换端点后,不会读取 Fingerprint 不匹配的旧向量。
|
||||
- 重建失败时,上一版已就绪向量仍能继续召回。
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
- 全文完成但向量失败时,明确显示“全文可用、向量失败”
|
||||
- 向量未启用与向量失败是不同状态
|
||||
- 图谱按需、未启用和失败是不同状态
|
||||
- 图谱模型按有界 Token 批次抽取;部分批次失败时保留成功结果并在任务中显示具体警告,全部批次失败时新文档仍保留可用全文索引
|
||||
- 汇总显示全文可用数、向量完成数和失败数
|
||||
|
||||
### US-D2 修复失败文档
|
||||
|
||||
@@ -256,18 +256,17 @@ Conversation
|
||||
```text
|
||||
▾ 产品发布讨论 10:24
|
||||
|
||||
▣ 每周进度总结
|
||||
每周五 17:00 · Execute · 下次 8 月 21 日
|
||||
|
||||
▣ 发布前检查
|
||||
单次 · Execute · 等待确认
|
||||
每周进度总结 每周 · Execute · 等待中 ●
|
||||
发布前检查 单次 · Execute ✓
|
||||
```
|
||||
|
||||
- 父会话行不重复显示任务标签或数量;Task 身份只在展开后的子项中使用稳定任务图标表达。
|
||||
- 父会话行不重复显示任务标签或数量;Task 子项名称在左,模式、计划和状态标记收在右侧,
|
||||
与父会话的更新时间位置一致。
|
||||
- 点击 Conversation 标题打开聊天;点击 Task 子项打开同一 Conversation 并定位到该 Task。
|
||||
- 新建 Task 成功后首次自动展开。用户手动折叠后持久化其选择,后台运行不强制展开。
|
||||
- 默认最多直接显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整 Task 区。
|
||||
- Task 子项显示本地化的模式、计划和状态文字;状态不能只靠任务图标颜色表达。
|
||||
- Task 子项显示本地化的模式、计划和非完成态状态文字;状态标记固定在行尾。已完成使用
|
||||
带勾成功点,并将完成文案保留给辅助技术,避免在紧凑元数据中重复显示且不只靠颜色表达。
|
||||
- 左侧只展开 Task;当前产品 UI 的其他区域也不提供 Job/Run 树或独立导航。
|
||||
|
||||
## 10. Conversation 内呈现
|
||||
@@ -364,7 +363,8 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、模
|
||||
- [x] 手动运行不改变下次计划时间。
|
||||
- [x] Scheduled Task 与普通消息共用 Conversation 级队列,不并发写入同一时间线。
|
||||
- [x] 当前回复期间可以继续发送普通消息,并在 Composer 上沿查看、删除或提升待发送项。
|
||||
- [x] 应用重启恢复尚未执行的队列项和有界附件上下文。
|
||||
- [x] 应用重启恢复尚未执行的队列项和有界附件上下文;已写入 Conversation 的用户输入
|
||||
通过队列项身份完成恢复对账,不会再次进入待发送队列。
|
||||
- [x] 文本结果只写入 Conversation,独立交付物才进入成果。
|
||||
- [ ] Task Center 和桌面通知可以打开正确 Conversation 并定位 Task。
|
||||
- [ ] 应用重启不自动重放结果未知的副作用。
|
||||
|
||||
@@ -37,8 +37,11 @@ Conversation 是用户消息、助手消息和面向用户结果的内容容器
|
||||
- 多个 Task 可以共享同一 Conversation 的可见上下文,但各自拥有独立配置、计划、权限
|
||||
快照、状态、Job、Run 和成果引用。
|
||||
- Conversation 标题与 Task 名称相互独立。创建或重命名 Task 不静默修改现有会话标题。
|
||||
- 从稳定的本地 Conversation 创建分支会生成新的 Conversation 和消息 ID,并保存直接来源
|
||||
标识;它只复制可见聊天内容与会话级 Runtime/知识检索选择,不复制 Task、发送队列、Job、
|
||||
Run、记忆或 Artifact 归属。来源与分支后续独立更新,删除来源不删除已创建的分支。
|
||||
- 左侧会话列表根据显式 Task 关联显示行首展开按钮;父会话行不重复任务标签,展开后的
|
||||
Task 子项使用任务图标,并只展开到 Task 层。
|
||||
Task 子项左侧显示名称,右侧显示状态标记,并只展开到 Task 层。
|
||||
- 并行 Job 不直接无序写入消息流;进度留在各自 Task/Job 状态中,最终文本以带来源元数据
|
||||
的完整消息写入 Conversation。
|
||||
- 删除 Conversation 前必须说明关联 Task 数量,并先停止或结算仍活动的 Job。
|
||||
@@ -193,10 +196,10 @@ waiting_approval > failed > running > paused > idle
|
||||
|
||||
- 无 Task 的 Conversation 保持现有单行样式。
|
||||
- 有 Task 的 Conversation 显示行首展开按钮,父会话行不重复任务标签或数量。
|
||||
- 展开后只显示带任务图标和本地化摘要的 Task,不继续显示 Job、Subjob 或 Run。
|
||||
- 展开后只显示名称、本地化摘要和右侧状态标记,不继续显示 Job、Subjob 或 Run。
|
||||
- 新建 Task 成功后首次自动展开;用户手动折叠后保持选择,后台状态变化不强制展开。
|
||||
- 默认最多直接显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整 Task 区。
|
||||
- Task 子项的任务图标表示身份;运行、审批、失败和暂停同时使用本地化状态文字。
|
||||
- Task 子项的状态标记固定在右侧;运行、审批、失败和暂停同时使用本地化状态文字。
|
||||
- 删除最后一个关联 Task 后,Conversation 的展开按钮自动消失。
|
||||
|
||||
## 9. 兼容映射
|
||||
|
||||
@@ -70,19 +70,19 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
|
||||
```text
|
||||
▾ 产品发布讨论 10:24
|
||||
|
||||
▣ 每周进度总结
|
||||
每周五 17:00 · Execute · 下次 8 月 21 日
|
||||
|
||||
▣ 发布前检查
|
||||
单次 · Execute · 等待确认
|
||||
每周进度总结 每周 · Execute · 等待中 ●
|
||||
发布前检查 单次 · Execute ✓
|
||||
```
|
||||
|
||||
- 无 Task 的 Conversation 保持现有单行样式。
|
||||
- 有 Task 时在行最左侧显示独立展开按钮;父会话行不重复显示任务标签或数量。
|
||||
- 会话标题溢出时保持时间和操作区固定;悬停会话行后,标题在自身裁切区域内横向滑动展示
|
||||
完整名称。未溢出标题不滑动,减少动态效果偏好下使用完整标题提示而不产生位移。
|
||||
- 展开后每个 Task 子项使用任务图标,并显示本地化的模式、计划和状态;不显示 Job、
|
||||
Subjob、Run 或工具步骤。
|
||||
- 更新时间默认靠近行右侧,更多操作按钮不常驻占位;悬停会话行时,按钮替换右侧时间显示。
|
||||
键盘焦点进入会话行或操作菜单展开时,按钮仍保持可见。会话标题溢出时保持右侧区域固定,
|
||||
并在自身裁切区域内横向滑动展示完整名称。未溢出标题不滑动,减少动态效果偏好下使用完整
|
||||
标题提示而不产生位移。
|
||||
- 展开后每个 Task 子项左侧显示名称,右侧显示本地化的模式、计划和状态标记,与父会话
|
||||
更新时间对齐;已完成使用带勾成功点并将文案保留给辅助技术。列表不显示 Job、Subjob、
|
||||
Run 或工具步骤。
|
||||
- 点击 Conversation 标题打开聊天;点击 Task 打开同一 Conversation 并定位该 Task。
|
||||
- 新建 Task 后首次自动展开;用户手动折叠后保持选择。
|
||||
- 后台状态变化不强制展开;Task 状态持续显示在展开后的子项和 Task Center 中。
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
| --- | --- |
|
||||
| 文档类型 | 产品路线图 |
|
||||
| 状态 | 规划中 |
|
||||
| 版本 | 0.3 |
|
||||
| 日期 | 2026-08-19 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-08-22 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 相关设计 | [通用助手工作栏与执行空间 PRD](../prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md)、[Task 与 Job 统一领域模型](../prd/task-and-job/task-and-job-model.md)、[智能心跳 PRD](../prd/smart-heartbeat/smart-heartbeat-prd.md)、[全双工实时语音交互设计](../architecture/full-duplex-voice-design.md) |
|
||||
|
||||
@@ -23,8 +23,8 @@ GoodBuddy 应能够:
|
||||
|
||||
1. 持续组织项目、会话、任务、成果和记忆,而不是只保存聊天记录。
|
||||
2. 在明确授权下理解文件、知识库、截图、应用窗口和浏览器上下文。
|
||||
3. 以只读问答、计划审查和受控执行三种模式完成工作。
|
||||
4. 在应用级助手工作栏中持续提供任务中心、监督、Runtime、终端、进程、工作区、浏览器、成果和上下文。
|
||||
3. 以 Ask 只读问答和 Execute 完整授权两种模式完成工作;计划审查是 Ask 中的交互方式,不是第三种权限模式。
|
||||
4. 在应用级助手工作栏中持续提供任务中心、监督、Runtime、终端、进程、工作区、浏览器和成果。
|
||||
5. 支持后台任务、定时任务、失败恢复和桌面通知。
|
||||
6. 让所有记忆、权限、上下文和远程传输可见、可审查、可撤销。
|
||||
|
||||
@@ -40,16 +40,19 @@ GoodBuddy 应能够:
|
||||
│ 会话 │ │ Runtime / 终端 │
|
||||
│ 智能心跳 │ │ 进程 / 工作区 │
|
||||
│ 记忆 │ │ 浏览器 / 成果 │
|
||||
│ 设置 │ │ 上下文 │
|
||||
│ 设置 │ │ │
|
||||
└──────────────┴──────────────────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
- 宽窗口:右侧栏固定显示,可拖动宽度。
|
||||
- 中等窗口:右侧栏默认折叠,点击后覆盖主工作区右侧。
|
||||
- 窄窗口:右侧栏作为全屏抽屉。
|
||||
- 所有窗口宽度下右侧栏都停靠显示并推动主工作区水平回流,不覆盖或遮罩主内容。
|
||||
- 排除最左侧主导航后,主工作区与右侧栏使用相同的 `300px` 最小宽度,并按用户拖动后的
|
||||
比例分配可用宽度;任一侧到达最小值后停止拖动。
|
||||
- 主工作区中的消息流和输入区保留对称的左右安全边距,右侧栏不得覆盖或裁切其右边缘。
|
||||
- 唯一展开与收起开关固定在窗口关闭按钮正下方,右侧栏内部不重复关闭入口;当前会话没有
|
||||
Task 时不保留空的 Task 上下文栏。
|
||||
- 右侧栏在对话、知识库和活动视图之间保持状态。
|
||||
- 知识图谱实体详情复用同一右栏容器,不再维护独立布局。
|
||||
- 九个标准能力固定可达;能力、连接和数据状态可以变化,但应用不按当前上下文自动隐藏入口。
|
||||
- 八个标准能力固定可达;能力、连接和数据状态可以变化,但应用不按当前上下文自动隐藏入口。
|
||||
- Task Center 是 Task 的单例索引;其他可绑定目标的能力支持跟随当前上下文或固定到用户选择的目标。
|
||||
|
||||
### 3.2 右侧工作栏
|
||||
@@ -80,11 +83,11 @@ GoodBuddy 应能够:
|
||||
- 用户可主动创建本机或 SSH 终端,并明确看到执行空间、目录和连接状态。
|
||||
- 用户终端与 Agent 工具执行分离,Agent 不得未经明确授权向终端注入输入。
|
||||
|
||||
#### 上下文
|
||||
#### Composer 与任务上下文
|
||||
|
||||
- 展示本次请求使用的附件、知识库、截图、剪贴板和授权目录。
|
||||
- 每项上下文显示来源、大小、发送状态和作用域。
|
||||
- 支持预览、移除和清空。
|
||||
- 本次请求使用的附件、知识库、截图和剪贴板由 Composer 展示与管理。
|
||||
- 授权目录、浏览器和工作区内容由对应工作栏能力展示,不创建重复的上下文栏目。
|
||||
- 历史执行上下文随关联 Task 查看;记忆功能实现后归入任务中心。
|
||||
- 不显示或持久化用户未主动选择的桌面内容。
|
||||
|
||||
#### 成果
|
||||
@@ -118,7 +121,8 @@ GoodBuddy 应能够:
|
||||
- 默认工作模式、Runtime、模型连接、Skills、MCP 和知识库范围。
|
||||
- 项目可归档、恢复和导出。
|
||||
|
||||
会话支持置顶、归档、重命名、删除、按项目筛选和搜索。
|
||||
会话支持置顶、归档、重命名、删除、按项目筛选和搜索;稳定的本地会话还可复制当前聊天
|
||||
内容到独立分支,并保留不依赖来源生命周期的来源标识。
|
||||
|
||||
### 4.2 工作模式
|
||||
|
||||
@@ -282,7 +286,7 @@ GoodBuddy 应能够:
|
||||
- Projects 与会话归属。
|
||||
- Ask、Execute 工作模式与旧版 Plan 数据兼容。
|
||||
- 应用级助手工作栏壳层。
|
||||
- 固定任务中心、监督、Runtime、终端、进程、工作区、浏览器、成果和上下文九个能力入口。
|
||||
- 固定任务中心、监督、Runtime、终端、进程、工作区、浏览器和成果八个能力入口。
|
||||
- 任务中心保持单例索引,其他可绑定能力支持跟随当前上下文或固定目标。
|
||||
|
||||
### 阶段 2:Task 与 Job
|
||||
@@ -322,9 +326,9 @@ GoodBuddy 应能够:
|
||||
### 8.1 右侧栏
|
||||
|
||||
- 三种窗口宽度下布局可用。
|
||||
- 九个标准能力在主要视图中固定可达,应用不按能力自动隐藏。
|
||||
- 八个标准能力在主要视图中固定可达,应用不按能力自动隐藏。
|
||||
- 跨主视图切换保持栏目、折叠、跟随和固定目标状态。
|
||||
- 任务中心、监督、Runtime、终端、进程、工作区、浏览器、成果和上下文更新不要求离开当前主任务。
|
||||
- 任务中心、监督、Runtime、终端、进程、工作区、浏览器和成果更新不要求离开当前主任务。
|
||||
- 键盘可操作,并具备正确 ARIA 标签。
|
||||
|
||||
### 8.2 Projects
|
||||
|
||||
@@ -28,8 +28,8 @@ Linux x64 和 Linux arm64。
|
||||
导航及重定向仍经过 DNS 解析和目标地址固定。
|
||||
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
|
||||
空闲回收和应用退出清理。
|
||||
- 直连模型工具循环已提高到适合浏览器任务的有界上限,并包含重复调用和
|
||||
无进展保护。
|
||||
- 直连模型工具循环不设置工具次数或模型轮次配额;取消、请求超时、工具结果大小和
|
||||
累计输出边界继续防止失控执行。
|
||||
|
||||
### MCP
|
||||
|
||||
|
||||
+21
-3
@@ -133,6 +133,20 @@ function rendererBundleModuleManifestPlugin(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
export function stableMainEntryFileName(chunk: {
|
||||
readonly name: string
|
||||
}): string {
|
||||
if (chunk.name === 'remote-package-installer') {
|
||||
return 'remote-package-installer.mjs'
|
||||
}
|
||||
return [
|
||||
'deepseek-harness-host-bootstrap',
|
||||
'embedding-inference-bootstrap'
|
||||
].includes(chunk.name)
|
||||
? `${chunk.name}.js`
|
||||
: '[name].js'
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [
|
||||
@@ -177,6 +191,12 @@ export default defineConfig({
|
||||
),
|
||||
'deepseek-harness-host-bootstrap': resolve(
|
||||
'src/main/deepseek-harness-host-bootstrap.ts'
|
||||
),
|
||||
'embedding-inference-bootstrap': resolve(
|
||||
'src/main/embedding-inference-bootstrap.ts'
|
||||
),
|
||||
'remote-package-installer': resolve(
|
||||
'src/main/remote-agent/control-plane-package-installer-cli.ts'
|
||||
)
|
||||
},
|
||||
external: [
|
||||
@@ -186,9 +206,7 @@ export default defineConfig({
|
||||
],
|
||||
output: {
|
||||
entryFileNames(chunk) {
|
||||
return chunk.name === 'deepseek-harness-host-bootstrap'
|
||||
? 'deepseek-harness-host-bootstrap.js'
|
||||
: '[name].js'
|
||||
return stableMainEntryFileName(chunk)
|
||||
},
|
||||
chunkFileNames(chunk) {
|
||||
const moduleIds = chunk.moduleIds.join('\n')
|
||||
|
||||
+9
-1
@@ -5,7 +5,15 @@ import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', 'out/**', 'coverage/**']
|
||||
ignores: [
|
||||
'.agent-resources/**',
|
||||
'.remote-runtime-resources/**',
|
||||
'.runtime-resources/**',
|
||||
'coverage/**',
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'out/**'
|
||||
]
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
Generated
+464
-313
File diff suppressed because it is too large
Load Diff
+77
-29
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.11.0",
|
||||
"version": "0.11.8",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
@@ -27,16 +27,22 @@
|
||||
"test:watch": "vitest",
|
||||
"eval:retrieval": "vitest run --config tests/support/knowledge-retrieval-evaluation.ts tests/knowledge-retrieval-metrics.test.ts tests/knowledge-retrieval-evaluation.test.ts",
|
||||
"build": "npm run typecheck && npm run build:bundle",
|
||||
"build:bundle": "electron-vite build && node build/check-renderer-bundle.cjs",
|
||||
"build:bundle": "electron-vite build && node build/check-control-plane-package-installer.cjs && node build/check-renderer-bundle.cjs",
|
||||
"smoke:deepseek-harness": "npm run build:bundle && node build/run-deepseek-harness-utility-smoke.cjs",
|
||||
"smoke:deepseek-harness:packaged": "node build/run-packaged-deepseek-harness-smoke.cjs",
|
||||
"agent:build": "node build/agent-bundle.cjs build",
|
||||
"agent:verify": "node build/agent-bundle.cjs verify",
|
||||
"agent:import": "node build/agent-bundle.cjs import",
|
||||
"remote-runtime:build": "node build/remote-runtime-bundle.cjs build",
|
||||
"remote-runtime:verify": "node build/remote-runtime-bundle.cjs verify",
|
||||
"remote-runtime:import": "node build/remote-runtime-bundle.cjs import",
|
||||
"release:notes:verify": "node build/release-notes.cjs",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:win": "npm run build && electron-builder --win nsis --x64 --arm64",
|
||||
"dist:mac": "npm run build && electron-builder --mac dmg --x64 --arm64",
|
||||
"dist:linux": "npm run dist:linux:x64 && npm run dist:linux:arm64",
|
||||
"dist:linux:x64": "npm run build && electron-builder --linux AppImage deb --x64",
|
||||
"dist:linux:arm64": "npm run build && electron-builder --linux AppImage deb --arm64",
|
||||
"dist:linux:x64": "npm run build && electron-builder --linux AppImage deb rpm --x64",
|
||||
"dist:linux:arm64": "npm run build && electron-builder --linux AppImage deb rpm --arm64",
|
||||
"icons": "node build/generate-icons.mjs",
|
||||
"release:package": "node build/build-release.cjs",
|
||||
"portable": "node build/build-portable.cjs"
|
||||
@@ -54,6 +60,7 @@
|
||||
"asarUnpack": [
|
||||
"out/main/package.json",
|
||||
"out/main/deepseek-harness-*",
|
||||
"out/main/embedding-inference-bootstrap.js",
|
||||
"out/main/chunks/**/*",
|
||||
"node_modules/node-pty/lib/**/*",
|
||||
"node_modules/node-pty/package.json",
|
||||
@@ -62,7 +69,8 @@
|
||||
"node_modules/koffi/**/*",
|
||||
"node_modules/@koromix/koffi-*/**/*",
|
||||
"node_modules/@napi-rs/canvas{,/**/*}",
|
||||
"node_modules/@napi-rs/canvas-*/**/*"
|
||||
"node_modules/@napi-rs/canvas-*/**/*",
|
||||
"node_modules/onnxruntime-web/dist/**/*.wasm"
|
||||
],
|
||||
"npmRebuild": false,
|
||||
"compression": "maximum",
|
||||
@@ -83,6 +91,18 @@
|
||||
"from": "resources/release-notes.json",
|
||||
"to": "release-notes.json"
|
||||
},
|
||||
{
|
||||
"from": "agent-runtime-lock.json",
|
||||
"to": "agent-runtime-lock.json"
|
||||
},
|
||||
{
|
||||
"from": "remote-runtime-lock.json",
|
||||
"to": "remote-runtime-lock.json"
|
||||
},
|
||||
{
|
||||
"from": "resources/agent-release-keys.json",
|
||||
"to": "agent-release-keys.json"
|
||||
},
|
||||
{
|
||||
"from": "build/icon-taskbar.ico",
|
||||
"to": "icon.ico"
|
||||
@@ -130,6 +150,10 @@
|
||||
"from": "node_modules/node-pty/LICENSE",
|
||||
"to": "licenses/node-pty-MIT.txt"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/ssh2/LICENSE",
|
||||
"to": "licenses/ssh2-MIT.txt"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/koffi/LICENSE.txt",
|
||||
"to": "licenses/koffi-MIT.txt"
|
||||
@@ -169,9 +193,13 @@
|
||||
"to": "licenses/ppu-ocv-MIT.txt"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/onnxruntime-web/LICENSE",
|
||||
"from": "resources/licenses/onnxruntime-MIT.txt",
|
||||
"to": "licenses/onnxruntime-web-MIT.txt"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/@huggingface/tokenizers/LICENSE",
|
||||
"to": "licenses/huggingface-tokenizers-Apache-2.0.txt"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/katex/LICENSE",
|
||||
"to": "licenses/katex-MIT.txt"
|
||||
@@ -223,7 +251,8 @@
|
||||
"syncDesktopName": true,
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
"deb",
|
||||
"rpm"
|
||||
],
|
||||
"category": "Utility"
|
||||
}
|
||||
@@ -232,28 +261,29 @@
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
"@antv/g6": "^5.1.1",
|
||||
"@deepseek-ai/cordis": "4.0.1",
|
||||
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-agent-loop": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-bash-local": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-credentials": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-fs-local": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-llm-pi-ai": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-pwsh-local": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-sandbox": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-session": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-shell-env": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-skill": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-subprocess-local": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-system-prompt": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-token-meter": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tool-bash": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tool-fs": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tool-pwsh": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tool-skill": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tools": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-user-approval": "0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-agent": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-agent-loop": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-bash-local": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-credentials": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-fs-local": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-llm": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-llm-pi-ai": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-pwsh-local": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-sandbox": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-session": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-shell-env": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-skill": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-subprocess-local": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-system-prompt": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-token-meter": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-tool-bash": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-tool-fs": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-tool-pwsh": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-tool-skill": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-tools": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-user-approval": "0.1.0-rc.8",
|
||||
"@huggingface/tokenizers": "^0.1.3",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@napi-rs/canvas": "1.0.3",
|
||||
"@opencode-ai/sdk": "^1.18.9",
|
||||
@@ -266,6 +296,7 @@
|
||||
"i18next": "^25.10.10",
|
||||
"json5": "^2.2.3",
|
||||
"katex": "^0.16.47",
|
||||
"koffi": "3.1.4",
|
||||
"lucide-react": "^1.27.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"npm": "11.19.0",
|
||||
@@ -282,6 +313,7 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"sherpa-onnx": "1.13.4",
|
||||
"ssh2": "^1.17.0",
|
||||
"undici": "^7.29.0",
|
||||
"yaml": "^2.9.0",
|
||||
"zod": "^4.4.3"
|
||||
@@ -299,10 +331,12 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"electron": "^43.2.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"electron-vite": "^5.0.0",
|
||||
"esbuild": "0.25.12",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.8.0",
|
||||
@@ -323,5 +357,19 @@
|
||||
"@koromix/koffi-linux-x64": "3.1.4",
|
||||
"@koromix/koffi-win32-arm64": "3.1.4",
|
||||
"@koromix/koffi-win32-x64": "3.1.4"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@deepseek-ai/dsh-subprocess-local@0.1.0-rc.8": true,
|
||||
"@google/genai@1.52.0": true,
|
||||
"cpu-features@0.0.10": true,
|
||||
"electron-winstaller@5.4.0": true,
|
||||
"esbuild@0.25.12": true,
|
||||
"esbuild@0.28.1": true,
|
||||
"fsevents@2.3.3": true,
|
||||
"koffi@3.1.4": true,
|
||||
"node-pty@1.2.0-beta.15": true,
|
||||
"opencode-ai@1.18.9": true,
|
||||
"protobufjs@7.6.5": true,
|
||||
"ssh2@1.17.0": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"runtimes": {
|
||||
"opencode": {
|
||||
"version": "1.18.9",
|
||||
"provider": "opencode",
|
||||
"entrypoint": "bin/opencode",
|
||||
"entrypointIdentity": "opencode-acp",
|
||||
"argvPrefix": [
|
||||
"acp"
|
||||
],
|
||||
"allowedEnvironmentNames": [
|
||||
"HOME",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"PATH",
|
||||
"TMPDIR",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_STATE_HOME"
|
||||
],
|
||||
"protocol": {
|
||||
"major": 1,
|
||||
"minor": 0
|
||||
},
|
||||
"targets": {
|
||||
"x64": {
|
||||
"package": "opencode-linux-x64-baseline",
|
||||
"integrity": "sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ=="
|
||||
},
|
||||
"arm64": {
|
||||
"package": "opencode-linux-arm64",
|
||||
"integrity": "sha512-2IN4lLjhx2FICcMDnBsKgwrey0AvAM0SlNzzj7L71uakNxWvrhcqPYVpEhrEYUjIn+uQGMY5PjA+uupXigJE2A=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"keys": [
|
||||
{
|
||||
"keyId": "goodbuddy-signing-2026-08",
|
||||
"publicKeySpkiBase64": "MCowBQYDK2VwAyEAfUkXqa3WYwb1QevpHW/XWNhGo59dETJYjrJONx6SKEQ=",
|
||||
"environment": "production"
|
||||
}
|
||||
],
|
||||
"revocations": []
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,6 +1,218 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.11.8",
|
||||
"releasedAt": "2026-08-29",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.8 修复正式环境中的 Host 更新失败,并显著缩短 Agent 与 OpenCode Runtime 的安装、恢复和日常启动校验。"
|
||||
],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Host 更新恢复可用。** 修复已发布 OpenCode Runtime 在采用阶段被桌面内置锁重复校验而失败的问题。Host 现在信任签名复合包中的版本锁和身份,并在同一条固定 SSH 连接上完成 Agent 启动、健康检查与 Runtime 激活。",
|
||||
"**不再重复扫描远端包体。** Host 在解包时完成完整 SHA-256 与签名校验;commit 只检查签名元数据。Main 不再通过 SFTP 逐文件读取 Agent/Runtime,后续健康检查、能力发现和每次提问只使用已登记 identity、manifest 与入口元数据。仍保留 SHA-256,提速来自消除重复读取。",
|
||||
"**中断与清理更可靠。** 中断的 commit 可从已认证准备状态继续,待恢复操作会保留五份元数据快照并在采用失败时回滚。环境已健康后,暂存清理失败只留待重试,不再错误回滚;安装错误会显示具体的有界原因。"
|
||||
],
|
||||
"notices": [
|
||||
"**技术预览与版本要求。** 远程项目仍默认关闭且为技术预览。Host 需要安装单独发布的 Agent `0.11.8`,提供 Linux x64 和 arm64;桌面包不内置 Agent 或远程 Runtime,本次不构建 LoongArch 预览。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.8 fixes Host updates in production and substantially reduces Agent and OpenCode Runtime verification during installation, recovery, and normal startup."
|
||||
],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Host updates work again.** Fixed published OpenCode Runtime adoption failing because it was redundantly checked against the desktop-bundled lock. The Host now uses the signed compound package's lock and identity, then starts and health-checks the Agent and activates Runtime on one pinned SSH connection.",
|
||||
"**No repeated remote payload scans.** The Host completes full SHA-256 and signature verification while extracting, and commit checks only signed metadata. Main no longer reads Agent or Runtime file trees over SFTP. Later health, capability, and prompt paths use registered identities, manifests, and entrypoint metadata. SHA-256 remains; the speedup comes from eliminating rereads.",
|
||||
"**Reliable interruption and cleanup recovery.** Interrupted commits resume from authenticated prepared state. Pending recovery keeps snapshots of all five metadata files and restores them if adoption fails. Once the environment is healthy, staging cleanup failures are retried instead of rolling it back, and bounded installer errors retain their concrete cause."
|
||||
],
|
||||
"notices": [
|
||||
"**Technical preview and version requirement.** Remote Projects remain disabled by default and in technical preview. Hosts require the separately published Agent `0.11.8` for Linux x64 or arm64. Desktop packages do not embed the Agent or remote Runtime, and this release does not build a LoongArch preview."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.11.7",
|
||||
"releasedAt": "2026-08-29",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.7 让托管 SSH 项目切换与多会话使用更顺畅,重做 Host 远程环境准备,并修复中断后排队消息重复发送。"
|
||||
],
|
||||
"features": [
|
||||
"**更清楚的 Host 远程环境准备。** “设置 → 平台功能 → 远程项目”的 Host 卡片现在通过一个主按钮完成安装、更新或重装,并可选择“自动”“Host 下载”或“GoodBuddy 传输”。“自动”会在 Host 可直连时由 Host 下载,否则通过 GoodBuddy 传输;已验证的本地或离线导入 Agent 包可以复用,已检查到更高的线上兼容版本时仍使用新版。只有点击主按钮才会开始准备。",
|
||||
"**Host 与项目管理。** 可从项目选择器直接打开远程项目设置;即使 Host 不可达或远端目录已不存在,也能删除本地项目记录。删除 Host 会先列出关联项目,并在确认后清理这些项目及其本机会话、任务、成果、记忆、计划和心跳关联;不会连接 Host 或删除远端文件。"
|
||||
],
|
||||
"fixes": [
|
||||
"**更快、更可靠的托管 SSH 会话。** 切换已有远程项目现在只更新本地选择,同一 Host 上兼容的项目与对话会共用连接。重连后空闲会话不再因 `stale-controller` 无法继续,正在执行的提问仍按原位置恢复或重放。",
|
||||
"**排队输入不再重复发送。** 如果应用在消息已写入对话、但队列尚未完成时中断,重启恢复会识别已显示的用户消息并清除对应队列项,不会再次发送同一内容;会话分支也不会继承该队列身份。",
|
||||
"**中文下载入口。** 中文官网下载卡片在北京 OSS 索引加载期间保持不可点击,不再短暂暴露 GitHub 回退;仅在索引请求或校验失败后切换到 GitHub。英文下载行为不变。"
|
||||
],
|
||||
"notices": [
|
||||
"**技术预览与版本要求。** 远程项目仍默认关闭且为技术预览。守护进程端的重连恢复需要 Host 安装单独发布的 Agent `0.11.7`,提供 Linux x64 和 arm64;桌面包不内置 Agent 或远程 Runtime,本次不提供 LoongArch 预览。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.7 makes managed SSH project switching and multi-conversation use smoother, overhauls Host remote-environment setup, and prevents queued input from being sent twice after an interruption."
|
||||
],
|
||||
"features": [
|
||||
"**Clearer Host remote-environment setup.** Under Settings → Platform Features → Remote Projects, each Host card uses one action for install, update, or reinstall, plus Automatic, Host download, and GoodBuddy transfer methods. Automatic downloads on the Host when direct access works; otherwise GoodBuddy transfers it. Verified local or imported Agent packages can be reused, but a known newer compatible online version still wins. Preparation starts only when you select the action.",
|
||||
"**Host and project management.** Open a remote project's settings directly from the project switcher, and remove its local record even when the Host is unreachable or the remote folder is gone. Deleting a Host now lists linked projects first and, after confirmation, removes their local conversations, Tasks, results, memories, schedules, and Heartbeat links without connecting to the Host or deleting remote files."
|
||||
],
|
||||
"fixes": [
|
||||
"**Faster, more resilient managed SSH sessions.** Switching an existing remote project is now local-only, and compatible projects and conversations share one Host connection. Reconnection no longer leaves idle sessions blocked by `stale-controller`, while active prompts preserve exact resume or replay behavior.",
|
||||
"**Queued input is no longer sent twice.** If the app is interrupted after a message reaches conversation history but before its queue item completes, startup recovery recognizes the displayed user message and removes the matching queue item instead of sending it again. Conversation branches also discard that queue identity.",
|
||||
"**Chinese download entry.** Download cards on the Chinese website now remain disabled while the Beijing OSS index is loading instead of briefly exposing the GitHub fallback. They switch to GitHub only if the OSS request or validation fails; English downloads are unchanged."
|
||||
],
|
||||
"notices": [
|
||||
"**Technical preview and version requirement.** Remote Projects remain disabled by default and in technical preview. Daemon-side reconnection recovery requires the separately published Agent `0.11.7` for Linux x64 or arm64. Desktop packages do not embed the Agent or remote Runtime, and this release has no LoongArch preview."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.11.6",
|
||||
"releasedAt": "2026-08-27",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.6 新增侧栏品牌定制,并修复 Anthropic 输出上限与关键界面提示。"
|
||||
],
|
||||
"features": [
|
||||
"**侧栏品牌定制。** “设置 → 外观 → 品牌定制”现在可修改左上角品牌名称、中英文副标题和本地 Logo,并提供实时预览、默认 Logo、恢复默认及本机持久化。"
|
||||
],
|
||||
"fixes": [
|
||||
"**Anthropic 输出上限。** Anthropic 直连模型现在可单独配置单次最大输出,不再错误复用上下文窗口;OpenAI 直连请求和工具调用轮次不增加额外配额。",
|
||||
"**输入框与品牌提示。** 恢复聊天和生图输入框中的发送、换行与粘贴快捷键说明,并恢复左上角品牌副标题。"
|
||||
],
|
||||
"notices": [
|
||||
"品牌定制仅保存在当前设备并只影响左上角品牌区;安装包、更新、Agent 和协议身份仍为 GoodBuddy。",
|
||||
"Anthropic 单次最大输出未填写时默认使用 32K Token;实际可用上限仍取决于所选模型和服务商。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.6 adds sidebar brand customization while fixing Anthropic output limits and essential interface guidance."
|
||||
],
|
||||
"features": [
|
||||
"**Sidebar brand customization.** Settings → Appearance → Brand customization now lets you change the top-left brand name, Chinese and English subtitles, and a local logo, with live preview, default-logo, full-reset, and on-device persistence."
|
||||
],
|
||||
"fixes": [
|
||||
"**Anthropic output limits.** Direct Anthropic connections now have an independent per-response maximum output setting instead of incorrectly reusing the context window. OpenAI direct requests and tool rounds receive no additional quota.",
|
||||
"**Composer and brand guidance.** Restored send, newline, and paste shortcuts in chat and image-generation input hints, along with the subtitle beneath the top-left brand name."
|
||||
],
|
||||
"notices": [
|
||||
"Brand customization is stored only on the current device and affects only the top-left brand area. Installer, update, Agent, and protocol identities remain GoodBuddy.",
|
||||
"Anthropic per-response maximum output defaults to 32K tokens when left unset. The selected model and provider may enforce a lower limit."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.11.5",
|
||||
"releasedAt": "2026-08-26",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.5 修复 Agent 下载兼容性,显示可用 Agent 更新,并调整新安装的默认模型连接。"
|
||||
],
|
||||
"features": [
|
||||
"**Agent 更新提示。** “设置 → 平台功能 → 远程项目”现在会显示本地 Agent 版本、在线最新兼容版本以及“有更新 / 已是最新”。打开页面只检查小型签名目录,不会自动下载 Agent 包。"
|
||||
],
|
||||
"fixes": [
|
||||
"**Agent 公钥目录。** 修复 Windows 发布包使用 CRLF 换行时,合法 Agent 下载被拒绝的问题。JSON 排版不再影响信任,Ed25519 公钥、签名、撤销状态和 SHA-256 校验保持不变。",
|
||||
"**默认模型连接。** 新安装改用本机 Ollama 兼容连接;升级只替换未配置凭据的旧默认项,用户显式保存的连接保持不变。",
|
||||
"**侧栏菜单稳定性。** 修复魔法笔记未完成待办数字出现或隐藏时左侧菜单行高变化的问题;相邻入口现在保持固定位置。"
|
||||
],
|
||||
"notices": [
|
||||
"本次正式 Agent 版本为 `0.11.5`,提供 Linux x64 与 arm64;大型包继续使用流式 SHA-256 校验,不会降级为 MD5。",
|
||||
"`0.11.5` 将另外提供独立的实验性 LoongArch 桌面预览包。它不进入标准更新索引,且不包含 Agent、Remote Runtime、OpenCode 和原生 Canvas。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.5 fixes Agent download compatibility, shows available Agent updates, and updates the default model connection for new installations."
|
||||
],
|
||||
"features": [
|
||||
"**Agent update status.** Settings → Platform Features → Remote Projects now shows the local Agent version, latest compatible online version, and Update available / Up to date status. Opening the page checks only the small signed catalog and never downloads an Agent package automatically."
|
||||
],
|
||||
"fixes": [
|
||||
"**Agent public-key registry.** Fixed valid Agent downloads being rejected when Windows release packages used CRLF line endings. JSON formatting no longer affects trust, while Ed25519 keys, signatures, revocations, and SHA-256 verification remain unchanged.",
|
||||
"**Default model connection.** New installations now use a local Ollama-compatible connection. Upgrades replace only the old default when it has no configured credential, while explicitly saved user connections remain unchanged.",
|
||||
"**Stable sidebar navigation.** Fixed the Magic Notes incomplete-todo count changing the sidebar row height when the badge appeared or disappeared. Neighboring navigation items now remain in place."
|
||||
],
|
||||
"notices": [
|
||||
"The formal Agent version is `0.11.5`, with Linux x64 and arm64 packages. Large packages continue to use streaming SHA-256 and are not downgraded to MD5.",
|
||||
"A separate experimental LoongArch desktop preview will be provided for `0.11.5`. It remains outside the standard update index and excludes Agent, Remote Runtime, OpenCode, and native Canvas."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.11.4",
|
||||
"releasedAt": "2026-08-26",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.4 新增托管 SSH 远程项目、内置 Granite 向量模型、会话分支和应用内反馈,并改进魔法笔记、知识图谱、模型下载及桌面布局。"
|
||||
],
|
||||
"features": [
|
||||
"**托管 SSH 远程项目(技术预览)。** 在“设置 → 平台功能”中启用后,可管理 Linux x64/arm64 Host,并通过 Ask 或 Execute 使用签名 Agent 与兼容的 OpenCode Runtime。Agent 包需在设置中手动下载、更新或离线导入,打开项目不会自动下载;SSH 断线后,Host 上的任务可继续运行并恢复未同步输出。",
|
||||
"**内置 Granite 向量模型。** 可下载、校验、离线导入、移除并运行内置 Granite 多语言 Embedding 模型,同时继续支持自定义 OpenAI 兼容向量连接和检索诊断。",
|
||||
"**独立会话分支。** 本地会话可通过“在新会话中继续”创建独立分支并保留来源,不复制原会话的任务、队列、运行记录、记忆或成果归属。",
|
||||
"**应用内反馈。** 可在“关于与更新”中提交问题、建议或体验反馈;邮箱和单张截图均为可选项,失败后会保留草稿供重试。",
|
||||
"**魔法笔记待办数量。** 左侧导航可显示未完成待办数量,并可在“平台功能 → 魔法笔记”中关闭;超过 99 项时显示“99+”。",
|
||||
"**DeepSeek Harness Runtime。** 内置 DeepSeek Harness 已从 `0.1.0-rc.6` 升级至 `0.1.0-rc.8`;GoodBuddy 同步适配新版图片附件限制,现有受控 Host、Ask/Execute 与插件边界保持不变。",
|
||||
"**Linux 与 LoongArch 构建。** Linux x64/arm64 桌面版新增 RPM 安装包,并提供独立、可复现的 Debian loong64 实验性预览构建流程。"
|
||||
],
|
||||
"fixes": [
|
||||
"**向量模型下载反馈。** 修复点击内置向量模型“下载”后界面看似无响应的问题;现在会立即显示下载来源、模型信息、实时进度和取消操作,行为与语音模型下载一致。",
|
||||
"**长文档知识图谱。** 图谱抽取改为按 Token 分批、重试和超时处理;图谱失败不再导致新文档全文检索一并失败,并改进中文检索与关系上下文。",
|
||||
"**ModelScope 语音模型。** 修复选择 ModelScope 后部分语音模型仍无法下载的问题,六个支持的模型现在均使用固定且经过完整性验证的来源。",
|
||||
"**魔法笔记与平台功能。** 待办勾选会立即同步源笔记,同时修复暗色模式输入提示过暗,以及“平台功能”页签可能不可见的问题。",
|
||||
"**助手工作栏与主导航。** 工作栏保持停靠和可调整大小,统一切换与任务入口,恢复左侧全高导航,并避免遮挡聊天输入区。",
|
||||
"**界面信息与会话列表。** 恢复关键页面说明和视觉层级;会话更多操作仅在悬停、键盘聚焦或菜单打开时显示,为标题和时间保留更多空间。"
|
||||
],
|
||||
"notices": [
|
||||
"远程项目默认关闭且完全可选。首次使用前必须手动准备兼容的 `.gbagent` 包,下载源跟随“关于与更新”中的 GitHub 或镜像节点选择;本次正式 Agent 版本为 `0.11.4`。",
|
||||
"LoongArch 仍是独立实验性预览,不进入标准更新索引;当前不包含 Agent、Remote Runtime、OpenCode 和原生 Canvas。本次 `0.11.4` 不提供 LoongArch 预览包。",
|
||||
"反馈不会自动附带会话、日志、文件、设置或凭据。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.4 adds managed SSH remote projects, a built-in Granite embedding model, conversation branching, and in-app feedback, with improvements to Magic Notes, knowledge graphs, model downloads, and desktop layout."
|
||||
],
|
||||
"features": [
|
||||
"**Managed SSH remote projects (technical preview).** Enable Remote Projects under Settings → Platform Features to manage Linux x64/arm64 Hosts and use signed Agents with a compatible OpenCode Runtime in Ask or Execute. Agent packages must be downloaded, updated, or imported manually in Settings; opening a project never downloads one automatically. Host work can continue through an SSH disconnect and recover unsynchronized output.",
|
||||
"**Built-in Granite embedding model.** Download, verify, import offline, remove, and run the managed Granite multilingual embedding model while retaining custom OpenAI-compatible vector connections and retrieval diagnostics.",
|
||||
"**Independent conversation branches.** Continue a local conversation in a new branch that records its source without copying Tasks, queued input, runs, memories, or artifact ownership.",
|
||||
"**In-app feedback.** Submit problems, ideas, or experience notes from About & Updates. An email address and one screenshot are optional, and failed submissions retain the draft for retry.",
|
||||
"**Magic Notes todo count.** The primary navigation can show incomplete todo counts, with an option under Platform Features → Magic Notes to hide the badge. Counts above 99 display as “99+”.",
|
||||
"**DeepSeek Harness Runtime.** The bundled DeepSeek Harness is updated from `0.1.0-rc.6` to `0.1.0-rc.8`. GoodBuddy now matches the updated image attachment limits while preserving its controlled Host, Ask/Execute, and plugin boundaries.",
|
||||
"**Linux and LoongArch builds.** Linux x64/arm64 desktop releases add RPM installers, and the repository now provides a separate reproducible experimental Debian loong64 preview build."
|
||||
],
|
||||
"fixes": [
|
||||
"**Embedding model download feedback.** Fixed the built-in model Download action appearing unresponsive. It now immediately shows the source, model details, live progress, and cancellation, matching speech model downloads.",
|
||||
"**Long-document knowledge graphs.** Graph extraction now uses token-bounded batches, retries, and timeouts. Graph failures no longer discard a new document’s full-text index, and Chinese retrieval and relationship context are improved.",
|
||||
"**ModelScope speech models.** Fixed supported speech models remaining unavailable after selecting ModelScope. All six models now use fixed, integrity-verified sources.",
|
||||
"**Magic Notes and Platform Features.** Todo changes immediately refresh their source note. Dark-mode placeholder contrast and disappearing Platform Features tabs are also fixed.",
|
||||
"**Assistant workbar and primary navigation.** The workbar remains docked and resizable, uses consistent toggle and Task entry points, restores the full-height sidebar, and no longer crowds the composer.",
|
||||
"**Interface guidance and conversation rows.** Restored essential workflow guidance and visual hierarchy. Conversation actions now appear only on hover, keyboard focus, or while their menu is open, leaving more room for titles and timestamps."
|
||||
],
|
||||
"notices": [
|
||||
"Remote Projects are disabled by default and remain optional. A compatible `.gbagent` package must be prepared manually before first use, and its source follows the GitHub or mirror selection in About & Updates. This release uses the formal Agent version `0.11.4`.",
|
||||
"LoongArch remains a separate experimental preview outside the standard update index. It currently excludes Agent, Remote Runtime, OpenCode, and native Canvas. No LoongArch preview package is provided for `0.11.4`.",
|
||||
"Feedback never automatically includes conversations, logs, files, settings, or credentials."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.11.0",
|
||||
"releasedAt": "2026-08-20",
|
||||
|
||||
+71
-8
@@ -4,11 +4,12 @@
|
||||
|
||||
正式站点地址:<https://mesalogo.github.io/goodbuddy/>
|
||||
|
||||
首页将 GoodBuddy 定位为“免注册、支持信创软硬件的一站式 AI 助手”,
|
||||
优先展示三大桌面系统与双架构下载入口、统一 Agent Runtime,以及知识库、
|
||||
魔法笔记、智能心跳、桌面上下文和远程消息通道等桌面助手能力。下载区位于
|
||||
主要功能说明之前,并明确列出统信 UOS、银河麒麟、海光、兆芯、鲲鹏和飞腾
|
||||
对应的 Linux x64 / arm64 包。页面不重复设置底部下载推广区。
|
||||
首页将 GoodBuddy 定位为“首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品”和
|
||||
“免注册、支持信创软硬件的一站式 AI 助手”,优先展示三大桌面系统、正式
|
||||
x64 / arm64 下载入口、独立编译的龙芯 loong64 预览版、统一 Agent Runtime,
|
||||
以及知识库、魔法笔记、智能心跳、桌面上下文和远程消息通道等桌面助手能力。
|
||||
下载区位于主要功能说明之前,并明确列出统信 UOS、银河麒麟、海光、兆芯、
|
||||
鲲鹏、飞腾和龙芯。页面不重复设置底部下载推广区。
|
||||
英文页面位于 `en.html`,不展示信创适配文案,三个平台的下载按钮始终前往
|
||||
GitHub 最新正式 Release。
|
||||
首屏产品界面默认正面展示,在精确指针设备上使用克制的 3D 倾斜、
|
||||
@@ -70,6 +71,10 @@ Windows、macOS 和 Linux 下载卡片分别提供处理器架构与安装包类
|
||||
整组按钮会以 fail-closed 方式继续指向 GitHub 最新正式 Release,不会混用
|
||||
部分 OSS 数据:
|
||||
|
||||
中文页面等待 OSS 索引时,三个正式下载入口保持禁用并显示加载状态,不会先
|
||||
短暂渲染或跳转到 GitHub。只有索引请求或完整校验明确失败后,正式下载入口
|
||||
才会显示 GitHub Release 回退;英文页面仍始终直接使用 GitHub Release。
|
||||
|
||||
```text
|
||||
https://github.com/mesalogo/goodbuddy/releases/latest
|
||||
```
|
||||
@@ -85,9 +90,67 @@ https://github.com/mesalogo/goodbuddy/releases/latest
|
||||
安装包文件名必须与发布生成器完全一致:Windows 使用
|
||||
`GoodBuddy-${version}-windows-${arch}-setup.exe` 与
|
||||
`GoodBuddy-${version}-windows-${arch}-portable.zip`;macOS 使用
|
||||
`GoodBuddy-${version}-mac-${arch}.dmg|zip`;Linux x64 的 AppImage 与
|
||||
DEB 分别使用 electron-builder 的 `x86_64` 与 `amd64` 架构名,Linux
|
||||
arm64 使用 `arm64`。
|
||||
`GoodBuddy-${version}-mac-${arch}.dmg|zip`;Linux x64 的 AppImage、DEB 与
|
||||
RPM 分别使用 electron-builder 的 `x86_64`、`amd64` 与 `x86_64` 架构名。
|
||||
Linux arm64 的 AppImage/DEB 使用 `arm64`,RPM 使用 `aarch64`。
|
||||
Linux 处理器选择器另外提供龙芯 `loong64` 实验预览;选中后安装包类型固定为
|
||||
DEB,并改用下述独立预览索引,不会把 `loong64` 注入正式六目标索引。
|
||||
|
||||
### 龙芯实验预览通道
|
||||
|
||||
龙芯包不加入上述正式索引,而使用独立 OSS 前缀:
|
||||
|
||||
```text
|
||||
releases/
|
||||
└── loongarch-preview/
|
||||
├── latest.json
|
||||
└── v<GoodBuddy 版本>/
|
||||
├── GoodBuddy-<版本>-linux-loong64-preview.deb
|
||||
├── preview-manifest.json
|
||||
└── SHA256SUMS
|
||||
```
|
||||
|
||||
版本目录不可变。先上传并公开验证三个版本对象,最后才覆盖
|
||||
`releases/loongarch-preview/latest.json`;不得把预览包放入 `releases/v*`、
|
||||
`releases/latest.json`、标准 GitHub Release 资产或桌面自动更新索引。
|
||||
版本对象建议使用 OSS Standard 存储、继承 Bucket 的公开读取策略,并设置长期
|
||||
immutable 缓存;`latest.json` 应使用 `application/json` 和短缓存或
|
||||
`no-cache`。`SHA256SUMS` 必须只记录 DEB 文件名,不得记录构建容器绝对路径。
|
||||
|
||||
官网只读取以下严格结构,且仅接受北京 OSS
|
||||
`releases/loongarch-preview/v<goodBuddyVersion>/` 下的精确 HTTPS 地址:
|
||||
|
||||
```json
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"product": "GoodBuddy LoongArch Preview",
|
||||
"goodBuddyVersion": "0.11.5",
|
||||
"previewVersion": "0.11.5-loong64-preview.1",
|
||||
"architecture": "loong64",
|
||||
"format": "deb",
|
||||
"artifact": {
|
||||
"name": "GoodBuddy-0.11.5-linux-loong64-preview.deb",
|
||||
"size": 186853872,
|
||||
"sha256": "672321e314fbdb91c6d7b2b549a838e556bd7e597746a3e6d346b936f2ff8369",
|
||||
"url": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/GoodBuddy-0.11.5-linux-loong64-preview.deb"
|
||||
},
|
||||
"manifestUrl": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/preview-manifest.json",
|
||||
"checksumUrl": "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/SHA256SUMS"
|
||||
}
|
||||
```
|
||||
|
||||
上传仍应使用 GitHub OIDC 获取的短期 STS 凭据和固定 `ossutil 2.3.0`,Bucket
|
||||
为 `goodbuddy`,Endpoint 为 `https://oss-cn-beijing.aliyuncs.com`,Region
|
||||
为 `cn-beijing`。上传版本对象前先读取目标对象元数据:不存在时才上传,已存在
|
||||
且大小和 SHA-256 相同时跳过,任一字节不同时必须停止;`--update` 只用于传输
|
||||
优化,不能代替不可变性检查。也可为版本前缀启用 OSS 保留策略/WORM。指针仅在
|
||||
所有公开 GET、大小、SHA-256、manifest 和 DEB 元数据检查通过后使用
|
||||
`--force` 更新。手工上传时也应遵循同一顺序,不使用长期 AccessKey。
|
||||
|
||||
若独立索引不存在、超时、过大、字段多缺、版本不稳定、摘要格式错误或任一
|
||||
URL 不匹配受信任前缀,中文官网选择龙芯处理器时会保持下载按钮禁用;Linux
|
||||
x64 / arm64 及另外两个正式平台入口不受影响。英文页面继续只展示正式
|
||||
GitHub Release。
|
||||
|
||||
## 字体与可访问性
|
||||
|
||||
|
||||
+224
-64
@@ -17,6 +17,8 @@
|
||||
const isEnglish = root.lang.toLowerCase().startsWith("en");
|
||||
const releaseManifestUrl =
|
||||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json";
|
||||
const loongArchPreviewManifestUrl =
|
||||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json";
|
||||
const releaseFallbackUrl =
|
||||
"https://github.com/mesalogo/goodbuddy/releases/latest";
|
||||
const releaseRequestTimeoutMs = 10_000;
|
||||
@@ -24,6 +26,10 @@
|
||||
const downloadCards = [
|
||||
...document.querySelectorAll("[data-download-card]"),
|
||||
];
|
||||
let verifiedRelease;
|
||||
let verifiedLoongArchPreview;
|
||||
let releaseLoadState = "loading";
|
||||
let loongArchPreviewLoadState = "loading";
|
||||
const interfaceCopy = isEnglish
|
||||
? {
|
||||
themeDark: "Switch to dark theme",
|
||||
@@ -49,6 +55,7 @@
|
||||
zip: "ZIP",
|
||||
AppImage: "AppImage",
|
||||
deb: "DEB",
|
||||
rpm: "RPM",
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
@@ -64,8 +71,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const readBoundedJson = async (response) => {
|
||||
const maximumBytes = releaseIndexApi?.maximumIndexBytes;
|
||||
const readBoundedJson = async (response, maximumBytes) => {
|
||||
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
|
||||
throw new Error("发布索引大小上限无效");
|
||||
}
|
||||
@@ -110,30 +116,189 @@
|
||||
return JSON.parse(new TextDecoder().decode(bytes));
|
||||
};
|
||||
|
||||
const setReleaseLink = (link, url, visibleText) => {
|
||||
const setLinkText = (link, visibleText) => {
|
||||
const newWindowNotice = link.querySelector(".sr-only");
|
||||
link.href = url;
|
||||
link.replaceChildren(document.createTextNode(visibleText));
|
||||
if (newWindowNotice) {
|
||||
link.append(newWindowNotice);
|
||||
}
|
||||
};
|
||||
|
||||
const setFallbackDownloads = () => {
|
||||
for (const card of downloadCards) {
|
||||
const platform = card.dataset.downloadCard;
|
||||
const link = card.closest(".download-card")?.querySelector("[data-release-link]");
|
||||
const meta = card.closest(".download-card")?.querySelector("[data-download-meta]");
|
||||
if (link instanceof HTMLAnchorElement) {
|
||||
const setReleaseLink = (link, url, visibleText) => {
|
||||
link.href = url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
link.removeAttribute("aria-disabled");
|
||||
link.removeAttribute("tabindex");
|
||||
link.classList.remove("is-disabled");
|
||||
setLinkText(link, visibleText);
|
||||
};
|
||||
|
||||
const getDownloadCardControls = (card) => {
|
||||
const platform = card.dataset.downloadCard;
|
||||
const archSelect = card.querySelector("[data-download-arch]");
|
||||
const formatSelect = card.querySelector("[data-download-format]");
|
||||
const container = card.closest(".download-card");
|
||||
const link = container?.querySelector("[data-release-link]");
|
||||
const meta = container?.querySelector("[data-download-meta]");
|
||||
if (
|
||||
!platform ||
|
||||
!(archSelect instanceof HTMLSelectElement) ||
|
||||
!(formatSelect instanceof HTMLSelectElement) ||
|
||||
!(link instanceof HTMLAnchorElement) ||
|
||||
!(meta instanceof HTMLElement)
|
||||
) {
|
||||
throw new Error("下载卡片结构无效");
|
||||
}
|
||||
return { platform, archSelect, formatSelect, link, meta };
|
||||
};
|
||||
|
||||
const setDownloadUnavailable = (link, meta) => {
|
||||
link.removeAttribute("href");
|
||||
link.removeAttribute("target");
|
||||
link.removeAttribute("rel");
|
||||
link.setAttribute("aria-disabled", "true");
|
||||
link.tabIndex = -1;
|
||||
link.classList.add("is-disabled");
|
||||
setLinkText(link, "龙芯预览包暂不可用");
|
||||
meta.textContent = "实验预览 · 仅 DEB · 索引尚未发布或校验失败。";
|
||||
};
|
||||
|
||||
const setDownloadPending = (link, meta, visibleText) => {
|
||||
link.removeAttribute("href");
|
||||
link.removeAttribute("target");
|
||||
link.removeAttribute("rel");
|
||||
link.setAttribute("aria-disabled", "true");
|
||||
link.tabIndex = -1;
|
||||
link.classList.add("is-disabled");
|
||||
setLinkText(link, visibleText);
|
||||
meta.textContent = "正在从 OSS 获取最新版本。";
|
||||
};
|
||||
|
||||
const setLoongArchFormat = (formatSelect, selected) => {
|
||||
for (const option of formatSelect.options) {
|
||||
const unavailable = selected && option.value !== "deb";
|
||||
option.hidden = unavailable;
|
||||
option.disabled = unavailable;
|
||||
}
|
||||
if (selected) {
|
||||
formatSelect.value = "deb";
|
||||
}
|
||||
formatSelect.disabled = selected;
|
||||
};
|
||||
|
||||
const updateDownloadCard = (card) => {
|
||||
const {
|
||||
platform,
|
||||
archSelect,
|
||||
formatSelect,
|
||||
link,
|
||||
meta,
|
||||
} = getDownloadCardControls(card);
|
||||
const loongArchSelected =
|
||||
platform === "linux" && archSelect.value === "loong64";
|
||||
setLoongArchFormat(formatSelect, loongArchSelected);
|
||||
|
||||
if (loongArchSelected) {
|
||||
if (!verifiedLoongArchPreview) {
|
||||
if (loongArchPreviewLoadState === "loading") {
|
||||
setDownloadPending(link, meta, "正在获取龙芯预览版下载信息…");
|
||||
} else {
|
||||
setDownloadUnavailable(link, meta);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setReleaseLink(
|
||||
link,
|
||||
verifiedLoongArchPreview.artifact.url,
|
||||
"下载 Linux 龙芯 LoongArch DEB 预览版 →",
|
||||
);
|
||||
meta.textContent =
|
||||
`GoodBuddy ${verifiedLoongArchPreview.goodBuddyVersion} · ` +
|
||||
`${formatFileSize(verifiedLoongArchPreview.artifact.size)} · ` +
|
||||
`实验预览 · SHA-256 ` +
|
||||
`${verifiedLoongArchPreview.artifact.sha256.slice(0, 12)}…`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!verifiedRelease) {
|
||||
if (releaseLoadState === "loading") {
|
||||
setDownloadPending(
|
||||
link,
|
||||
meta,
|
||||
`正在获取 ${platformNames[platform] ?? platform} 下载信息…`,
|
||||
);
|
||||
} else {
|
||||
setReleaseLink(
|
||||
link,
|
||||
releaseFallbackUrl,
|
||||
`前往 GitHub 下载 ${platformNames[platform] ?? platform ?? ""} →`,
|
||||
`前往 GitHub 下载 ${platformNames[platform] ?? platform} →`,
|
||||
);
|
||||
}
|
||||
if (meta instanceof HTMLElement) {
|
||||
meta.textContent = "请在 GitHub Release 中选择对应的安装文件。";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const target =
|
||||
verifiedRelease.targets[`${platform}-${archSelect.value}`];
|
||||
const file = target?.files?.[formatSelect.value];
|
||||
if (!file) {
|
||||
throw new Error("下载选项不在已校验的发布索引中");
|
||||
}
|
||||
const platformName = platformNames[platform] ?? platform;
|
||||
const archName =
|
||||
platform === "macos" && archSelect.value === "arm64"
|
||||
? "Apple 芯片"
|
||||
: archSelect.value === "arm64"
|
||||
? "ARM64"
|
||||
: "x64";
|
||||
const formatName = formatNames[formatSelect.value] ?? formatSelect.value;
|
||||
setReleaseLink(
|
||||
link,
|
||||
file.url,
|
||||
`下载 ${platformName} ${archName} ${formatName} →`,
|
||||
);
|
||||
meta.textContent =
|
||||
`GoodBuddy ${verifiedRelease.version} · ` +
|
||||
`${formatFileSize(file.size)} · ` +
|
||||
`${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`;
|
||||
};
|
||||
|
||||
const updateAllDownloadCards = () => {
|
||||
for (const card of downloadCards) {
|
||||
updateDownloadCard(card);
|
||||
}
|
||||
};
|
||||
|
||||
const setFallbackDownloads = () => {
|
||||
verifiedRelease = undefined;
|
||||
releaseLoadState = "fallback";
|
||||
updateAllDownloadCards();
|
||||
};
|
||||
|
||||
const setLoongArchPreviewUnavailable = () => {
|
||||
verifiedLoongArchPreview = undefined;
|
||||
loongArchPreviewLoadState = "unavailable";
|
||||
const linuxCard = downloadCards.find(
|
||||
(card) => card.dataset.downloadCard === "linux",
|
||||
);
|
||||
if (linuxCard) {
|
||||
updateDownloadCard(linuxCard);
|
||||
}
|
||||
};
|
||||
|
||||
const configureLoongArchPreview = (payload) => {
|
||||
if (!releaseIndexApi?.validateLoongArchPreviewIndex) {
|
||||
throw new Error("龙芯预览索引校验器不可用");
|
||||
}
|
||||
verifiedLoongArchPreview =
|
||||
releaseIndexApi.validateLoongArchPreviewIndex(payload);
|
||||
loongArchPreviewLoadState = "ready";
|
||||
const linuxCard = downloadCards.find(
|
||||
(card) => card.dataset.downloadCard === "linux",
|
||||
);
|
||||
if (linuxCard) {
|
||||
updateDownloadCard(linuxCard);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -141,71 +306,38 @@
|
||||
if (!releaseIndexApi?.validateReleaseIndex) {
|
||||
throw new Error("发布索引校验器不可用");
|
||||
}
|
||||
const release = releaseIndexApi.validateReleaseIndex(payload);
|
||||
|
||||
const updateCard = (card) => {
|
||||
const platform = card.dataset.downloadCard;
|
||||
const archSelect = card.querySelector("[data-download-arch]");
|
||||
const formatSelect = card.querySelector("[data-download-format]");
|
||||
const link = card.closest(".download-card")?.querySelector("[data-release-link]");
|
||||
const meta = card.closest(".download-card")?.querySelector("[data-download-meta]");
|
||||
if (
|
||||
!platform ||
|
||||
!(archSelect instanceof HTMLSelectElement) ||
|
||||
!(formatSelect instanceof HTMLSelectElement) ||
|
||||
!(link instanceof HTMLAnchorElement) ||
|
||||
!(meta instanceof HTMLElement)
|
||||
) {
|
||||
throw new Error("下载卡片结构无效");
|
||||
}
|
||||
|
||||
const target = release.targets[`${platform}-${archSelect.value}`];
|
||||
const file = target?.files?.[formatSelect.value];
|
||||
if (!file) {
|
||||
throw new Error("下载选项不在已校验的发布索引中");
|
||||
}
|
||||
|
||||
const platformName = platformNames[platform] ?? platform;
|
||||
const archName =
|
||||
platform === "macos" && archSelect.value === "arm64"
|
||||
? "Apple 芯片"
|
||||
: archSelect.value === "arm64"
|
||||
? "ARM64"
|
||||
: "x64";
|
||||
const formatName = formatNames[formatSelect.value] ?? formatSelect.value;
|
||||
setReleaseLink(
|
||||
link,
|
||||
file.url,
|
||||
`下载 ${platformName} ${archName} ${formatName} →`,
|
||||
);
|
||||
meta.textContent =
|
||||
`GoodBuddy ${release.version} · ${formatFileSize(file.size)} · ` +
|
||||
`${archSelect.options[archSelect.selectedIndex]?.text ?? archSelect.value}`;
|
||||
};
|
||||
verifiedRelease = releaseIndexApi.validateReleaseIndex(payload);
|
||||
releaseLoadState = "ready";
|
||||
updateAllDownloadCards();
|
||||
};
|
||||
|
||||
const initializeDownloadCards = () => {
|
||||
for (const card of downloadCards) {
|
||||
const selects = card.querySelectorAll("select");
|
||||
for (const select of selects) {
|
||||
for (const select of card.querySelectorAll("select")) {
|
||||
select.addEventListener("change", () => {
|
||||
try {
|
||||
updateCard(card);
|
||||
updateDownloadCard(card);
|
||||
} catch {
|
||||
setFallbackDownloads();
|
||||
}
|
||||
});
|
||||
}
|
||||
updateCard(card);
|
||||
updateDownloadCard(card);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRelease = async () => {
|
||||
const fetchBoundedIndex = async (
|
||||
url,
|
||||
maximumBytes,
|
||||
errorLabel,
|
||||
) => {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(
|
||||
() => controller.abort(),
|
||||
releaseRequestTimeoutMs,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(releaseManifestUrl, {
|
||||
const response = await fetch(url, {
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
@@ -213,16 +345,42 @@
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`发布索引请求失败:${response.status}`);
|
||||
throw new Error(`${errorLabel}请求失败:${response.status}`);
|
||||
}
|
||||
configureDownloads(await readBoundedJson(response));
|
||||
} catch {
|
||||
setFallbackDownloads();
|
||||
return await readBoundedJson(response, maximumBytes);
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRelease = async () => {
|
||||
try {
|
||||
configureDownloads(
|
||||
await fetchBoundedIndex(
|
||||
releaseManifestUrl,
|
||||
releaseIndexApi?.maximumIndexBytes,
|
||||
"发布索引",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setFallbackDownloads();
|
||||
}
|
||||
};
|
||||
|
||||
const loadLoongArchPreview = async () => {
|
||||
try {
|
||||
configureLoongArchPreview(
|
||||
await fetchBoundedIndex(
|
||||
loongArchPreviewManifestUrl,
|
||||
releaseIndexApi?.maximumPreviewIndexBytes,
|
||||
"龙芯预览索引",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setLoongArchPreviewUnavailable();
|
||||
}
|
||||
};
|
||||
|
||||
const getSavedTheme = () => {
|
||||
try {
|
||||
const savedTheme = localStorage.getItem("goodbuddy-site-theme");
|
||||
@@ -310,8 +468,10 @@
|
||||
|
||||
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
|
||||
setHeaderState();
|
||||
initializeDownloadCards();
|
||||
if (!isEnglish) {
|
||||
void loadRelease();
|
||||
void loadLoongArchPreview();
|
||||
}
|
||||
|
||||
themeToggle?.addEventListener("click", () => {
|
||||
|
||||
+1
-1
@@ -274,7 +274,7 @@
|
||||
<div class="platform-icon">
|
||||
<img src="./assets/linux-plain.svg" alt="" />
|
||||
</div>
|
||||
<div><h3>Linux</h3><p>x64 / arm64 · AppImage / DEB</p></div>
|
||||
<div><h3>Linux</h3><p>x64 / arm64 · AppImage / DEB / RPM</p></div>
|
||||
<a
|
||||
class="button button--download"
|
||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||
|
||||
+40
-29
@@ -5,14 +5,14 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="description"
|
||||
content="GoodBuddy 是免注册、支持信创软硬件的一站式 AI 助手,以统一 Agent Runtime 连接直连模型、OpenCode、Continue 与 DeepSeek Harness。"
|
||||
content="GoodBuddy 是首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品,也是免注册、支持信创软硬件的一站式 AI 助手。"
|
||||
/>
|
||||
<link rel="canonical" href="https://mesalogo.github.io/goodbuddy/" />
|
||||
<link rel="alternate" hreflang="zh-CN" href="https://mesalogo.github.io/goodbuddy/" />
|
||||
<link rel="alternate" hreflang="en" href="https://mesalogo.github.io/goodbuddy/en.html" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://mesalogo.github.io/goodbuddy/en.html" />
|
||||
<meta name="theme-color" content="#f6f8fb" />
|
||||
<title>GoodBuddy|免注册、支持信创软硬件的一站式 AI 助手</title>
|
||||
<title>GoodBuddy|首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</title>
|
||||
<link
|
||||
rel="icon"
|
||||
href="./assets/goodbuddy-light.png"
|
||||
@@ -116,7 +116,7 @@
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow">
|
||||
<span class="status-dot" aria-hidden="true"></span>
|
||||
Windows · macOS · Linux
|
||||
Windows · macOS · Linux · 龙芯预览版
|
||||
</div>
|
||||
<h1 id="hero-title">
|
||||
免注册,<br />
|
||||
@@ -124,7 +124,8 @@
|
||||
<span>一站式 AI 助手。</span>
|
||||
</h1>
|
||||
<p class="hero-lead">
|
||||
作为桌面助手与 AI 编程工具台,GoodBuddy 管理对话、知识、笔记与任务,
|
||||
GoodBuddy 是首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品。作为桌面助手与
|
||||
AI 编程工具台,GoodBuddy 管理对话、知识、笔记与任务,
|
||||
也通过独创的统一 Agent Runtime 接入直连模型、OpenCode、Continue 和 DeepSeek Harness。
|
||||
无需反复配置命令行,选择工具和项目即可开始。
|
||||
</p>
|
||||
@@ -132,11 +133,15 @@
|
||||
<a class="button button--primary" href="#download">立即下载</a>
|
||||
<a class="button button--secondary" href="#features">了解 Agent Runtime</a>
|
||||
</div>
|
||||
<ul class="hero-facts" aria-label="产品特性概览">
|
||||
<ul class="hero-facts hero-facts--aligned" aria-label="产品特性概览">
|
||||
<li>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
|
||||
3 大桌面系统,x64 / arm64
|
||||
</li>
|
||||
<li>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
|
||||
龙芯 LoongArch 独立编译预览版
|
||||
</li>
|
||||
<li>
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 10 3 3 7-7" /></svg>
|
||||
4 类 Agent Runtime 统一接入
|
||||
@@ -234,7 +239,7 @@
|
||||
<div class="section-inner proof-grid">
|
||||
<div><strong>4 类 Runtime</strong><span>多种 AI 编程路径</span></div>
|
||||
<div><strong>3 大系统</strong><span>Windows / macOS / Linux</span></div>
|
||||
<div><strong>2 种架构</strong><span>x64 / arm64</span></div>
|
||||
<div><strong>3 种架构</strong><span>x64 / arm64 / loong64 预览</span></div>
|
||||
<div><strong>1 个入口</strong><span>选择、配置、运行、审计</span></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -266,14 +271,13 @@
|
||||
</label>
|
||||
</div>
|
||||
<a
|
||||
class="button button--download"
|
||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="button button--download is-disabled"
|
||||
data-release-link
|
||||
>下载 Windows 版 →<span class="sr-only">(在新窗口打开)</span></a>
|
||||
aria-disabled="true"
|
||||
tabindex="-1"
|
||||
>正在获取 Windows 下载信息…<span class="sr-only">(在新窗口打开)</span></a>
|
||||
<p class="download-meta" data-download-meta aria-live="polite">
|
||||
正在获取最新版本,暂时可前往 GitHub 下载。
|
||||
正在从 OSS 获取最新版本。
|
||||
</p>
|
||||
</article>
|
||||
<article class="download-card">
|
||||
@@ -300,27 +304,27 @@
|
||||
</label>
|
||||
</div>
|
||||
<a
|
||||
class="button button--download"
|
||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="button button--download is-disabled"
|
||||
data-release-link
|
||||
>下载 macOS 版 →<span class="sr-only">(在新窗口打开)</span></a>
|
||||
aria-disabled="true"
|
||||
tabindex="-1"
|
||||
>正在获取 macOS 下载信息…<span class="sr-only">(在新窗口打开)</span></a>
|
||||
<p class="download-meta" data-download-meta aria-live="polite">
|
||||
正在获取最新版本,暂时可前往 GitHub 下载。
|
||||
正在从 OSS 获取最新版本。
|
||||
</p>
|
||||
</article>
|
||||
<article class="download-card">
|
||||
<div class="platform-icon">
|
||||
<img src="./assets/linux-plain.svg" alt="" />
|
||||
</div>
|
||||
<div><h3>Linux</h3><p>x64 / arm64 · AppImage / DEB</p></div>
|
||||
<div><h3>Linux</h3><p>x64 / arm64 · AppImage / DEB / RPM</p></div>
|
||||
<div class="download-options" data-download-card="linux">
|
||||
<label>
|
||||
<span>处理器</span>
|
||||
<select data-download-arch aria-label="Linux 处理器架构">
|
||||
<option value="x64">x64(Intel / AMD)</option>
|
||||
<option value="arm64">ARM64</option>
|
||||
<option value="loong64">龙芯 LoongArch(实验预览)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -328,32 +332,39 @@
|
||||
<select data-download-format aria-label="Linux 安装包类型">
|
||||
<option value="AppImage">AppImage(推荐)</option>
|
||||
<option value="deb">DEB</option>
|
||||
<option value="rpm">RPM</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<a
|
||||
class="button button--download"
|
||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="button button--download is-disabled"
|
||||
data-release-link
|
||||
>下载 Linux 版 →<span class="sr-only">(在新窗口打开)</span></a>
|
||||
aria-disabled="true"
|
||||
tabindex="-1"
|
||||
>正在获取 Linux 下载信息…<span class="sr-only">(在新窗口打开)</span></a>
|
||||
<p class="download-meta" data-download-meta aria-live="polite">
|
||||
正在获取最新版本,暂时可前往 GitHub 下载。
|
||||
正在从 OSS 获取最新版本。
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
<aside class="domestic-support" aria-labelledby="domestic-support-title">
|
||||
<div>
|
||||
<p class="kicker">信创软硬件支持</p>
|
||||
<h3 id="domestic-support-title">统信 UOS、银河麒麟,覆盖国产 x64 与 ARM64</h3>
|
||||
<p>使用对应的 Linux x64 或 arm64 安装包。</p>
|
||||
<h3 id="domestic-support-title">首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</h3>
|
||||
<p>
|
||||
正式下载覆盖国产 x64 与 ARM64;龙芯 LoongArch 使用独立编译的
|
||||
loong64 预览版。
|
||||
</p>
|
||||
</div>
|
||||
<div class="domestic-support__items">
|
||||
<div><span>国产系统</span><strong>统信 UOS · 银河麒麟</strong></div>
|
||||
<div>
|
||||
<span>国产 CPU</span>
|
||||
<strong>海光 · 兆芯(x64)<br />鲲鹏 · 飞腾(ARM64)</strong>
|
||||
<strong>
|
||||
海光 · 兆芯(x64)<br />
|
||||
鲲鹏 · 飞腾(ARM64)<br />
|
||||
龙芯(loong64 预览版)
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -511,7 +522,7 @@
|
||||
</span>
|
||||
<span>GoodBuddy</span>
|
||||
</a>
|
||||
<p>免注册、支持信创软硬件的一站式 AI 助手</p>
|
||||
<p>首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</p>
|
||||
<div class="footer-links">
|
||||
<a href="#download">下载</a>
|
||||
<a href="#features">Agent Runtime</a>
|
||||
|
||||
+96
-6
@@ -3,9 +3,12 @@
|
||||
|
||||
const mirrorIndexUrl =
|
||||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/latest.json";
|
||||
const loongArchPreviewIndexUrl =
|
||||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json";
|
||||
const fallbackUrl =
|
||||
"https://github.com/mesalogo/goodbuddy/releases/latest";
|
||||
const maximumIndexBytes = 512 * 1024;
|
||||
const maximumPreviewIndexBytes = 16 * 1024;
|
||||
const semVerPattern =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+((?:[0-9a-zA-Z-]+)(?:\.[0-9a-zA-Z-]+)*))?$/u;
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||
@@ -34,12 +37,12 @@
|
||||
"linux-x64": Object.freeze({
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
formats: Object.freeze(["AppImage", "deb"]),
|
||||
formats: Object.freeze(["AppImage", "deb", "rpm"]),
|
||||
}),
|
||||
"linux-arm64": Object.freeze({
|
||||
platform: "linux",
|
||||
arch: "arm64",
|
||||
formats: Object.freeze(["AppImage", "deb"]),
|
||||
formats: Object.freeze(["AppImage", "deb", "rpm"]),
|
||||
}),
|
||||
});
|
||||
const targetKeys = Object.freeze(Object.keys(targetDefinitions));
|
||||
@@ -78,10 +81,12 @@
|
||||
|
||||
const artifactArch =
|
||||
arch === "x64"
|
||||
? format === "AppImage"
|
||||
? "x86_64"
|
||||
: "amd64"
|
||||
: arch;
|
||||
? format === "deb"
|
||||
? "amd64"
|
||||
: "x86_64"
|
||||
: format === "rpm"
|
||||
? "aarch64"
|
||||
: "arm64";
|
||||
return `GoodBuddy-${version}-linux-${artifactArch}.${format}`;
|
||||
};
|
||||
|
||||
@@ -199,8 +204,93 @@
|
||||
return index;
|
||||
};
|
||||
|
||||
const validateLoongArchPreviewIndex = (index) => {
|
||||
assert(
|
||||
hasExactKeys(index, [
|
||||
"formatVersion",
|
||||
"product",
|
||||
"goodBuddyVersion",
|
||||
"previewVersion",
|
||||
"architecture",
|
||||
"format",
|
||||
"artifact",
|
||||
"manifestUrl",
|
||||
"checksumUrl",
|
||||
]),
|
||||
"龙芯预览索引结构无效",
|
||||
);
|
||||
assert(index.formatVersion === 1, "龙芯预览索引版本无效");
|
||||
assert(
|
||||
index.product === "GoodBuddy LoongArch Preview",
|
||||
"龙芯预览产品名称无效",
|
||||
);
|
||||
assert(
|
||||
typeof index.goodBuddyVersion === "string" &&
|
||||
index.goodBuddyVersion.length <= 256,
|
||||
"龙芯预览 GoodBuddy 版本无效",
|
||||
);
|
||||
const parsedVersion = semVerPattern.exec(index.goodBuddyVersion);
|
||||
assert(
|
||||
parsedVersion && !parsedVersion[4],
|
||||
"龙芯预览必须基于稳定 GoodBuddy 版本",
|
||||
);
|
||||
const previewPrefix = `${index.goodBuddyVersion}-loong64-preview.`;
|
||||
assert(
|
||||
typeof index.previewVersion === "string" &&
|
||||
index.previewVersion.startsWith(previewPrefix) &&
|
||||
/^[1-9]\d*$/u.test(index.previewVersion.slice(previewPrefix.length)),
|
||||
"龙芯预览版本无效",
|
||||
);
|
||||
assert(index.architecture === "loong64", "龙芯预览架构无效");
|
||||
assert(index.format === "deb", "龙芯预览格式无效");
|
||||
assert(
|
||||
hasExactKeys(index.artifact, ["name", "size", "sha256", "url"]),
|
||||
"龙芯预览文件结构无效",
|
||||
);
|
||||
|
||||
const expectedName =
|
||||
`GoodBuddy-${index.goodBuddyVersion}-linux-loong64-preview.deb`;
|
||||
assert(
|
||||
index.artifact.name === expectedName &&
|
||||
safeFileNamePattern.test(index.artifact.name),
|
||||
"龙芯预览文件名无效",
|
||||
);
|
||||
assert(
|
||||
Number.isSafeInteger(index.artifact.size) && index.artifact.size > 0,
|
||||
"龙芯预览文件大小无效",
|
||||
);
|
||||
assert(
|
||||
typeof index.artifact.sha256 === "string" &&
|
||||
sha256Pattern.test(index.artifact.sha256),
|
||||
"龙芯预览 SHA-256 无效",
|
||||
);
|
||||
|
||||
const previewBase = new URL(
|
||||
`v${index.goodBuddyVersion}/`,
|
||||
loongArchPreviewIndexUrl,
|
||||
);
|
||||
assertExactUrl(
|
||||
index.artifact.url,
|
||||
new URL(encodeURIComponent(expectedName), previewBase).href,
|
||||
"龙芯预览文件地址",
|
||||
);
|
||||
assertExactUrl(
|
||||
index.manifestUrl,
|
||||
new URL("preview-manifest.json", previewBase).href,
|
||||
"龙芯预览 manifest 地址",
|
||||
);
|
||||
assertExactUrl(
|
||||
index.checksumUrl,
|
||||
new URL("SHA256SUMS", previewBase).href,
|
||||
"龙芯预览校验清单地址",
|
||||
);
|
||||
return index;
|
||||
};
|
||||
|
||||
window.GoodBuddyReleaseIndex = Object.freeze({
|
||||
maximumIndexBytes,
|
||||
maximumPreviewIndexBytes,
|
||||
validateReleaseIndex,
|
||||
validateLoongArchPreviewIndex,
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -86,6 +86,99 @@ const renderApp = (legacy = false) => {
|
||||
return { dom, queries, window };
|
||||
};
|
||||
|
||||
const renderLoongArchPreview = (payload) => {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta name="theme-color" content="#f6f8fb"></head>
|
||||
<body>
|
||||
<header data-site-header>
|
||||
<button type="button" data-menu-toggle>菜单</button>
|
||||
<nav data-navigation></nav>
|
||||
<button type="button" data-theme-toggle>主题</button>
|
||||
</header>
|
||||
<div data-menu-backdrop></div>
|
||||
<main>
|
||||
<article class="download-card">
|
||||
<div data-download-card="linux">
|
||||
<select data-download-arch>
|
||||
<option value="x64">x64</option>
|
||||
<option value="arm64">ARM64</option>
|
||||
<option value="loong64">龙芯 LoongArch(实验预览)</option>
|
||||
</select>
|
||||
<select data-download-format>
|
||||
<option value="AppImage">AppImage</option>
|
||||
<option value="deb">DEB</option>
|
||||
<option value="rpm">RPM</option>
|
||||
</select>
|
||||
</div>
|
||||
<a
|
||||
class="button button--download"
|
||||
href="https://github.com/mesalogo/goodbuddy/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-release-link
|
||||
>下载 Linux 版<span class="sr-only">(在新窗口打开)</span></a>
|
||||
<p data-download-meta></p>
|
||||
</article>
|
||||
</main>
|
||||
<span data-current-year></span>
|
||||
</body>
|
||||
</html>`,
|
||||
{
|
||||
runScripts: "outside-only",
|
||||
url: "https://example.test/",
|
||||
},
|
||||
);
|
||||
const { window } = dom;
|
||||
createMediaQueries(window, false);
|
||||
window.TextDecoder = TextDecoder;
|
||||
window.GoodBuddyReleaseIndex = {
|
||||
maximumIndexBytes: 512 * 1024,
|
||||
maximumPreviewIndexBytes: 16 * 1024,
|
||||
validateReleaseIndex() {
|
||||
throw new Error("正式发布索引在此测试中不可用");
|
||||
},
|
||||
validateLoongArchPreviewIndex(value) {
|
||||
return value;
|
||||
},
|
||||
};
|
||||
window.fetch = async (url) => {
|
||||
if (!String(url).includes("/loongarch-preview/")) {
|
||||
throw new Error("正式发布索引在此测试中不可用");
|
||||
}
|
||||
if (!payload) {
|
||||
throw new Error("龙芯预览索引不可用");
|
||||
}
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
||||
let delivered = false;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get(name) {
|
||||
return name === "content-length" ? String(bytes.length) : null;
|
||||
},
|
||||
},
|
||||
body: {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
if (delivered) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
delivered = true;
|
||||
return { done: false, value: bytes };
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
window.eval(source);
|
||||
return { dom, window };
|
||||
};
|
||||
|
||||
for (const legacy of [false, true]) {
|
||||
test(
|
||||
`mobile menu isolates content and restores focus with ${
|
||||
@@ -149,3 +242,105 @@ for (const legacy of [false, true]) {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("enables only a validated LoongArch preview download", async () => {
|
||||
const artifactUrl =
|
||||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/GoodBuddy-0.11.5-linux-loong64-preview.deb";
|
||||
const { dom, window } = renderLoongArchPreview({
|
||||
goodBuddyVersion: "0.11.5",
|
||||
artifact: {
|
||||
size: 186_853_872,
|
||||
sha256: "a".repeat(64),
|
||||
url: artifactUrl,
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
|
||||
const arch = window.document.querySelector("[data-download-arch]");
|
||||
const format = window.document.querySelector(
|
||||
"[data-download-format]",
|
||||
);
|
||||
const link = window.document.querySelector("[data-release-link]");
|
||||
const meta = window.document.querySelector("[data-download-meta]");
|
||||
arch.value = "loong64";
|
||||
arch.dispatchEvent(new window.Event("change"));
|
||||
|
||||
assert.equal(format.value, "deb");
|
||||
assert.equal(format.disabled, true);
|
||||
assert.equal(format.options[0].hidden, true);
|
||||
assert.equal(format.options[1].hidden, false);
|
||||
assert.equal(format.options[2].hidden, true);
|
||||
assert.equal(link.getAttribute("href"), artifactUrl);
|
||||
assert.equal(link.getAttribute("target"), "_blank");
|
||||
assert.equal(link.getAttribute("rel"), "noreferrer");
|
||||
assert.equal(link.hasAttribute("aria-disabled"), false);
|
||||
assert.equal(link.classList.contains("is-disabled"), false);
|
||||
assert.equal(
|
||||
meta.textContent,
|
||||
"GoodBuddy 0.11.5 · 178 MB · 实验预览 · SHA-256 aaaaaaaaaaaa…",
|
||||
);
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test("keeps Chinese downloads disabled while the OSS indexes are loading", async () => {
|
||||
const { dom, window } = renderLoongArchPreview({
|
||||
goodBuddyVersion: "0.11.5",
|
||||
artifact: {
|
||||
size: 186_853_872,
|
||||
sha256: "a".repeat(64),
|
||||
url: "https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v0.11.5/GoodBuddy-0.11.5-linux-loong64-preview.deb",
|
||||
},
|
||||
});
|
||||
const arch = window.document.querySelector("[data-download-arch]");
|
||||
const link = window.document.querySelector("[data-release-link]");
|
||||
const meta = window.document.querySelector("[data-download-meta]");
|
||||
|
||||
assert.equal(link.hasAttribute("href"), false);
|
||||
assert.equal(link.getAttribute("aria-disabled"), "true");
|
||||
assert.equal(link.tabIndex, -1);
|
||||
assert.equal(link.classList.contains("is-disabled"), true);
|
||||
assert.equal(link.textContent, "正在获取 Linux 下载信息…(在新窗口打开)");
|
||||
assert.equal(meta.textContent, "正在从 OSS 获取最新版本。");
|
||||
|
||||
arch.value = "loong64";
|
||||
arch.dispatchEvent(new window.Event("change"));
|
||||
assert.equal(link.hasAttribute("href"), false);
|
||||
assert.equal(link.getAttribute("aria-disabled"), "true");
|
||||
assert.equal(link.textContent, "正在获取龙芯预览版下载信息…(在新窗口打开)");
|
||||
assert.equal(meta.textContent, "正在从 OSS 获取最新版本。");
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test("keeps the LoongArch preview disabled when its index is unavailable", async () => {
|
||||
const { dom, window } = renderLoongArchPreview(null);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
|
||||
const arch = window.document.querySelector("[data-download-arch]");
|
||||
const format = window.document.querySelector(
|
||||
"[data-download-format]",
|
||||
);
|
||||
const link = window.document.querySelector("[data-release-link]");
|
||||
const meta = window.document.querySelector("[data-download-meta]");
|
||||
arch.value = "loong64";
|
||||
arch.dispatchEvent(new window.Event("change"));
|
||||
|
||||
assert.equal(format.value, "deb");
|
||||
assert.equal(format.disabled, true);
|
||||
assert.equal(link.hasAttribute("href"), false);
|
||||
assert.equal(link.getAttribute("aria-disabled"), "true");
|
||||
assert.equal(link.tabIndex, -1);
|
||||
assert.equal(link.classList.contains("is-disabled"), true);
|
||||
assert.equal(
|
||||
meta.textContent,
|
||||
"实验预览 · 仅 DEB · 索引尚未发布或校验失败。",
|
||||
);
|
||||
|
||||
arch.value = "x64";
|
||||
arch.dispatchEvent(new window.Event("change"));
|
||||
assert.equal(format.disabled, false);
|
||||
assert.equal(format.options[0].hidden, false);
|
||||
assert.equal(link.getAttribute("href"), "https://github.com/mesalogo/goodbuddy/releases/latest");
|
||||
assert.equal(link.hasAttribute("aria-disabled"), false);
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
@@ -9,15 +9,18 @@ const { test } = process.env.VITEST
|
||||
const source = await readFile(path.resolve("sites/release-index.js"), "utf8");
|
||||
const context = vm.createContext({ URL, window: {} });
|
||||
vm.runInContext(source, context, { filename: "release-index.js" });
|
||||
const { validateReleaseIndex } = context.window.GoodBuddyReleaseIndex;
|
||||
const {
|
||||
validateReleaseIndex,
|
||||
validateLoongArchPreviewIndex,
|
||||
} = context.window.GoodBuddyReleaseIndex;
|
||||
|
||||
const definitions = [
|
||||
["windows", "x64", ["nsis", "portable"]],
|
||||
["windows", "arm64", ["nsis", "portable"]],
|
||||
["macos", "x64", ["dmg", "zip"]],
|
||||
["macos", "arm64", ["dmg", "zip"]],
|
||||
["linux", "x64", ["AppImage", "deb"]],
|
||||
["linux", "arm64", ["AppImage", "deb"]],
|
||||
["linux", "x64", ["AppImage", "deb", "rpm"]],
|
||||
["linux", "arm64", ["AppImage", "deb", "rpm"]],
|
||||
];
|
||||
|
||||
const canonicalFileName = (version, platform, arch, format) => {
|
||||
@@ -41,10 +44,12 @@ const canonicalFileName = (version, platform, arch, format) => {
|
||||
"linux-x64": {
|
||||
AppImage: `GoodBuddy-${version}-linux-x86_64.AppImage`,
|
||||
deb: `GoodBuddy-${version}-linux-amd64.deb`,
|
||||
rpm: `GoodBuddy-${version}-linux-x86_64.rpm`,
|
||||
},
|
||||
"linux-arm64": {
|
||||
AppImage: `GoodBuddy-${version}-linux-arm64.AppImage`,
|
||||
deb: `GoodBuddy-${version}-linux-arm64.deb`,
|
||||
rpm: `GoodBuddy-${version}-linux-aarch64.rpm`,
|
||||
},
|
||||
};
|
||||
return names[`${platform}-${arch}`][format];
|
||||
@@ -88,6 +93,36 @@ const expectRejected = (mutate) => {
|
||||
assert.throws(() => validateReleaseIndex(index));
|
||||
};
|
||||
|
||||
const validLoongArchPreviewIndex = () => {
|
||||
const goodBuddyVersion = "1.2.3";
|
||||
const previewBase =
|
||||
`https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/v${goodBuddyVersion}/`;
|
||||
const name =
|
||||
`GoodBuddy-${goodBuddyVersion}-linux-loong64-preview.deb`;
|
||||
return {
|
||||
formatVersion: 1,
|
||||
product: "GoodBuddy LoongArch Preview",
|
||||
goodBuddyVersion,
|
||||
previewVersion: `${goodBuddyVersion}-loong64-preview.1`,
|
||||
architecture: "loong64",
|
||||
format: "deb",
|
||||
artifact: {
|
||||
name,
|
||||
size: 186_000_000,
|
||||
sha256: "a".repeat(64),
|
||||
url: new URL(encodeURIComponent(name), previewBase).href,
|
||||
},
|
||||
manifestUrl: new URL("preview-manifest.json", previewBase).href,
|
||||
checksumUrl: new URL("SHA256SUMS", previewBase).href,
|
||||
};
|
||||
};
|
||||
|
||||
const expectLoongArchPreviewRejected = (mutate) => {
|
||||
const index = validLoongArchPreviewIndex();
|
||||
mutate(index);
|
||||
assert.throws(() => validateLoongArchPreviewIndex(index));
|
||||
};
|
||||
|
||||
test("accepts the canonical stable six-target release index", () => {
|
||||
const index = validIndex();
|
||||
assert.equal(validateReleaseIndex(index), index);
|
||||
@@ -202,3 +237,49 @@ test("requires exact checksum and GitHub fallback URLs", () => {
|
||||
index.fallbackUrl = "https://github.com/mesalogo/goodbuddy/releases";
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts the isolated immutable LoongArch preview index", () => {
|
||||
const index = validLoongArchPreviewIndex();
|
||||
assert.equal(validateLoongArchPreviewIndex(index), index);
|
||||
});
|
||||
|
||||
test("binds the LoongArch preview to its stable version and exact OSS prefix", () => {
|
||||
for (const version of ["v1.2.3", "1.2.3-rc.1", "1.2"]) {
|
||||
expectLoongArchPreviewRejected((index) => {
|
||||
index.goodBuddyVersion = version;
|
||||
});
|
||||
}
|
||||
expectLoongArchPreviewRejected((index) => {
|
||||
index.previewVersion = "1.2.3-loong64-preview.0";
|
||||
});
|
||||
expectLoongArchPreviewRejected((index) => {
|
||||
index.artifact.url = index.artifact.url.replace(
|
||||
"/releases/loongarch-preview/v1.2.3/",
|
||||
"/releases/v1.2.3/",
|
||||
);
|
||||
});
|
||||
expectLoongArchPreviewRejected((index) => {
|
||||
index.manifestUrl += "?download=1";
|
||||
});
|
||||
expectLoongArchPreviewRejected((index) => {
|
||||
index.unexpected = true;
|
||||
});
|
||||
});
|
||||
|
||||
test("requires one canonical LoongArch DEB with bounded metadata", () => {
|
||||
expectLoongArchPreviewRejected((index) => {
|
||||
index.architecture = "arm64";
|
||||
});
|
||||
expectLoongArchPreviewRejected((index) => {
|
||||
index.format = "rpm";
|
||||
});
|
||||
expectLoongArchPreviewRejected((index) => {
|
||||
index.artifact.name = "GoodBuddy-1.2.3-linux-loong64.deb";
|
||||
});
|
||||
expectLoongArchPreviewRejected((index) => {
|
||||
index.artifact.size = 0;
|
||||
});
|
||||
expectLoongArchPreviewRejected((index) => {
|
||||
index.artifact.sha256 = "A".repeat(64);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,6 +250,9 @@ for (const breakpoint of ["1199px", "959px", "719px"]) {
|
||||
const requiredCopy = [
|
||||
"免注册",
|
||||
"支持信创软硬件的一站式 AI 助手",
|
||||
"首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品",
|
||||
"龙芯 LoongArch 独立编译预览版",
|
||||
"龙芯 LoongArch(实验预览)",
|
||||
"桌面助手",
|
||||
"AI 编程工具台",
|
||||
"Windows、macOS、Linux",
|
||||
@@ -308,11 +311,12 @@ const releaseLinks = [
|
||||
report(releaseLinks.length >= 3, "缺少三个桌面系统的官方下载入口");
|
||||
for (const link of releaseLinks) {
|
||||
report(
|
||||
/href="https:\/\/github\.com\/mesalogo\/goodbuddy\/releases\/latest"/.test(link),
|
||||
`下载入口必须指向官方最新 Release:${link}`,
|
||||
!/\shref=/.test(link),
|
||||
`中文下载入口加载期间不得预先指向 GitHub:${link}`,
|
||||
);
|
||||
report(/target="_blank"/.test(link), `下载入口必须在新窗口打开:${link}`);
|
||||
report(/rel="[^"]*noreferrer[^"]*"/.test(link), `下载入口缺少 noreferrer:${link}`);
|
||||
report(/aria-disabled="true"/.test(link), `中文下载入口加载期间必须禁用:${link}`);
|
||||
report(/tabindex="-1"/.test(link), `禁用的中文下载入口不得进入 Tab 顺序:${link}`);
|
||||
report(/\bis-disabled\b/.test(link), `中文下载入口缺少禁用样式:${link}`);
|
||||
}
|
||||
report(
|
||||
(html.match(/data-download-card="(?:windows|macos|linux)"/g) ?? []).length === 3,
|
||||
@@ -326,6 +330,18 @@ report(
|
||||
(html.match(/data-download-format/g) ?? []).length === 3,
|
||||
"每个平台必须提供安装包类型选择器",
|
||||
);
|
||||
report(
|
||||
/<option\s+value="loong64">龙芯 LoongArch(实验预览)<\/option>/.test(
|
||||
html,
|
||||
),
|
||||
"Linux 处理器选项必须包含龙芯实验预览",
|
||||
);
|
||||
report(
|
||||
/const setLoongArchFormat =[\s\S]*option\.value !== "deb"[\s\S]*formatSelect\.value = "deb"[\s\S]*formatSelect\.disabled = selected/.test(
|
||||
appJs,
|
||||
),
|
||||
"龙芯处理器必须只允许选择 DEB",
|
||||
);
|
||||
report(
|
||||
!/data-release-status|download-release-status/.test(`${html}\n${englishHtml}\n${css}\n${appJs}`),
|
||||
"官网不得显示下载源状态提示",
|
||||
@@ -360,6 +376,27 @@ report(
|
||||
),
|
||||
"官网必须从 GoodBuddy OSS 加载最新发布索引",
|
||||
);
|
||||
report(
|
||||
appJs.includes(
|
||||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json",
|
||||
),
|
||||
"官网必须从独立 OSS 索引加载龙芯预览包",
|
||||
);
|
||||
report(
|
||||
/validateLoongArchPreviewIndex\(payload\)/.test(appJs),
|
||||
"龙芯预览下载链接必须先通过独立索引校验",
|
||||
);
|
||||
report(
|
||||
/catch\s*\{[\s\S]*setLoongArchPreviewUnavailable\(\)/.test(appJs),
|
||||
"龙芯预览索引失败时必须保持下载入口禁用",
|
||||
);
|
||||
report(
|
||||
releaseIndexJs.includes(
|
||||
"https://goodbuddy.oss-cn-beijing.aliyuncs.com/releases/loongarch-preview/latest.json",
|
||||
) &&
|
||||
/const validateLoongArchPreviewIndex =/.test(releaseIndexJs),
|
||||
"龙芯预览索引必须使用独立受信任前缀和校验器",
|
||||
);
|
||||
report(
|
||||
appJs.includes("https://github.com/mesalogo/goodbuddy/releases/latest"),
|
||||
"官网必须保留 GitHub Release 回退地址",
|
||||
@@ -500,8 +537,8 @@ const expectedDownloadOptions = {
|
||||
formats: ["dmg", "zip"],
|
||||
},
|
||||
linux: {
|
||||
arches: ["x64", "arm64"],
|
||||
formats: ["AppImage", "deb"],
|
||||
arches: ["x64", "arm64", "loong64"],
|
||||
formats: ["AppImage", "deb", "rpm"],
|
||||
},
|
||||
};
|
||||
for (const [platform, expected] of Object.entries(expectedDownloadOptions)) {
|
||||
@@ -598,7 +635,7 @@ for (const asset of cssAssets) {
|
||||
}
|
||||
|
||||
report(
|
||||
!/<a\b[^>]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb)(?:[?#][^"]*)?"/i.test(
|
||||
!/<a\b[^>]*href="[^"]+\.(?:exe|dmg|zip|AppImage|deb|rpm)(?:[?#][^"]*)?"/i.test(
|
||||
`${html}\n${englishHtml}`,
|
||||
),
|
||||
"具体安装资产链接应由 OSS 发布索引动态提供",
|
||||
|
||||
+17
-1
@@ -586,6 +586,11 @@ p {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.hero-facts--aligned {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, max-content));
|
||||
}
|
||||
|
||||
.hero-facts li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1274,7 +1279,7 @@ p {
|
||||
|
||||
.download-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@@ -1385,6 +1390,13 @@ p {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.button--download.is-disabled {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.download-meta {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 18px;
|
||||
@@ -1832,6 +1844,10 @@ p {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.hero-facts--aligned {
|
||||
grid-template-columns: minmax(0, max-content);
|
||||
}
|
||||
|
||||
.product-stage {
|
||||
min-height: 365px;
|
||||
margin-top: var(--space-4);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
export async function raceWithAbort<T>(
|
||||
pending: Promise<T>,
|
||||
signal: AbortSignal
|
||||
): Promise<T> {
|
||||
signal.throwIfAborted()
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
cleanup()
|
||||
reject(signal.reason)
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void pending.then(
|
||||
(value) => {
|
||||
cleanup()
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function closeServer(server: {
|
||||
close(callback: (error?: Error) => void): unknown
|
||||
}): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
server.close((error) => {
|
||||
if (error === undefined) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (
|
||||
isNodeError(error) &&
|
||||
error.code === 'ERR_SERVER_NOT_RUNNING'
|
||||
) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function settleWithin(
|
||||
pending: Promise<unknown>,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
await Promise.race([
|
||||
pending,
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(resolve, timeoutMs)
|
||||
timeout.unref?.()
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function boundedInteger(
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
label: string
|
||||
): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < minimum ||
|
||||
value > maximum
|
||||
) {
|
||||
throw new RangeError(`${label} is invalid`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function isNodeError(
|
||||
error: unknown
|
||||
): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && 'code' in error
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { open } from 'node:fs/promises'
|
||||
import type {
|
||||
AgentArchitecture
|
||||
} from '../shared/agent-installation-contracts'
|
||||
|
||||
export async function sha256File(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(filePath)) {
|
||||
hash.update(chunk as Buffer)
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
export async function assertElfArchitecture(
|
||||
filePath: string,
|
||||
expected: AgentArchitecture,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
const header = Buffer.alloc(64)
|
||||
const { bytesRead } = await handle.read(
|
||||
header,
|
||||
0,
|
||||
header.length,
|
||||
0
|
||||
)
|
||||
const actual = detectElfArchitecture(
|
||||
header.subarray(0, bytesRead)
|
||||
)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`${label} architecture mismatch: expected ${expected}, received ${actual ?? 'unknown'}`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function detectElfArchitecture(
|
||||
header: Buffer
|
||||
): AgentArchitecture | undefined {
|
||||
if (
|
||||
header.length < 20 ||
|
||||
header[0] !== 0x7f ||
|
||||
header.toString('ascii', 1, 4) !== 'ELF' ||
|
||||
(header[5] !== 1 && header[5] !== 2)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const machine =
|
||||
header[5] === 2
|
||||
? header.readUInt16BE(18)
|
||||
: header.readUInt16LE(18)
|
||||
return machine === 62
|
||||
? 'x64'
|
||||
: machine === 183
|
||||
? 'arm64'
|
||||
: undefined
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from '../shared/agent-protocol/channel-state'
|
||||
@@ -0,0 +1,656 @@
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_PROTOCOL_FAILURE_RECORD_NAME,
|
||||
AGENT_PROTOCOL_FAILURE_STDERR_PREFIX,
|
||||
AGENT_PROTOCOL_VERSION
|
||||
} from '../shared/agent-protocol'
|
||||
import {
|
||||
buildFixedAgentCliArgv,
|
||||
verifyAgentInstallationId
|
||||
} from '../main/ssh/ssh-agent-command'
|
||||
import {
|
||||
deriveManagedInstallationPaths,
|
||||
deriveManagedRuntimePaths,
|
||||
runAgentCli,
|
||||
type AgentCliDependencies,
|
||||
type ManagedInstallationPaths
|
||||
} from './cli'
|
||||
import { InstallationRegistry } from './installation-registry'
|
||||
import {
|
||||
RegisteredAgentBundleError,
|
||||
type VerifiedInstalledAgentBundle
|
||||
} from './installed-bundle-verifier'
|
||||
import { RuntimeBundleRegistry } from './runtime-bundle-registry'
|
||||
import type { VerifiedRuntimeBundle } from './runtime-bundle-verifier'
|
||||
import type { RemoteRuntimeLock } from '../shared/remote-runtime-launch-contracts'
|
||||
|
||||
const temporaryPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of temporaryPaths.splice(0)) {
|
||||
rmSync(path, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Agent CLI fixed command contract', () => {
|
||||
it('accepts only the fixed model bridge helper argv', async () => {
|
||||
const runModelBridgeHelper = vi.fn(async () => 7)
|
||||
await expect(
|
||||
runAgentCli(
|
||||
[
|
||||
'model-bridge-helper',
|
||||
'--socket-path',
|
||||
'/private/bridge/model-bridge.sock',
|
||||
'--protocol',
|
||||
'openai-responses',
|
||||
'--model',
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--opencode-entrypoint',
|
||||
'/runtime/bin/opencode'
|
||||
],
|
||||
{ runModelBridgeHelper, io: cliIo() }
|
||||
)
|
||||
).resolves.toBe(7)
|
||||
expect(runModelBridgeHelper).toHaveBeenCalledWith({
|
||||
socketPath: '/private/bridge/model-bridge.sock',
|
||||
protocol: 'openai-responses',
|
||||
model: 'private-model',
|
||||
supportsImageInput: false,
|
||||
opencodeEntrypoint: '/runtime/bin/opencode'
|
||||
})
|
||||
await expect(
|
||||
runAgentCli(
|
||||
[
|
||||
'model-bridge-helper',
|
||||
'--socket-path',
|
||||
'/private/bridge/model-bridge.sock',
|
||||
'--protocol',
|
||||
'openai-responses',
|
||||
'--model',
|
||||
'private-model',
|
||||
'--supports-image-input',
|
||||
'false',
|
||||
'--opencode-entrypoint',
|
||||
'/runtime/bin/opencode',
|
||||
'--api-key',
|
||||
'secret'
|
||||
],
|
||||
{ runModelBridgeHelper, io: cliIo() }
|
||||
)
|
||||
).resolves.toBe(2)
|
||||
expect(runModelBridgeHelper).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('starts the daemon from Host-managed registry evidence without full verification', async () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const paths: ManagedInstallationPaths = {
|
||||
executablePath: resolve(root, 'install-1', 'goodbuddy-agent'),
|
||||
stateDirectory: resolve(root, 'state'),
|
||||
socketPath: resolve(root, 'run', 'agent.sock')
|
||||
}
|
||||
const releaseKeyRegistry = {
|
||||
formatVersion: 1 as const,
|
||||
keys: [],
|
||||
revocations: []
|
||||
}
|
||||
const verified = verifiedInstallation(paths, 'install-1')
|
||||
const registry = new InstallationRegistry({
|
||||
storagePath: resolve(root, 'registry.json')
|
||||
})
|
||||
registry.stageCandidate(verified)
|
||||
const start = vi.fn(async () => undefined)
|
||||
const stop = vi.fn(async () => undefined)
|
||||
const createDaemon = vi.fn(() => ({
|
||||
start,
|
||||
stop,
|
||||
status: () => ({ daemonBootId: 'daemon-boot-1' })
|
||||
}))
|
||||
const recordCurrentDaemonReady = vi.fn(async () => undefined)
|
||||
const verifyInstallation = vi.fn(async () => verified)
|
||||
const loadRegisteredInstallation = vi.fn(async () => verified)
|
||||
const io = cliIo()
|
||||
|
||||
const result = await runAgentCli(
|
||||
['daemon', '--installation-id', 'install-1'],
|
||||
{
|
||||
installationPaths: () => paths,
|
||||
releaseKeyRegistry,
|
||||
installationRegistry: registry,
|
||||
verifyInstallation,
|
||||
loadRegisteredInstallation,
|
||||
peerIdentityProvider: {
|
||||
async getPeerIdentity() {
|
||||
return { uid: 1 }
|
||||
}
|
||||
},
|
||||
createDaemon:
|
||||
createDaemon as unknown as NonNullable<
|
||||
AgentCliDependencies['createDaemon']
|
||||
>,
|
||||
createLifecycle: (() => ({
|
||||
recordCurrentDaemonReady
|
||||
})) as unknown as NonNullable<
|
||||
AgentCliDependencies['createLifecycle']
|
||||
>,
|
||||
waitForShutdown: async () => undefined,
|
||||
io
|
||||
}
|
||||
)
|
||||
expect(result, io.error.read()?.toString()).toBe(0)
|
||||
expect(createDaemon).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
installationId: 'install-1',
|
||||
binaryDigest: verified.binaryDigest,
|
||||
agentVersion: verified.manifest.agentVersion,
|
||||
protocol: verified.manifest.protocol
|
||||
})
|
||||
)
|
||||
expect(start).toHaveBeenCalledOnce()
|
||||
expect(stop).toHaveBeenCalledOnce()
|
||||
expect(recordCurrentDaemonReady).toHaveBeenCalledWith(
|
||||
'daemon-boot-1'
|
||||
)
|
||||
expect(loadRegisteredInstallation).toHaveBeenCalledOnce()
|
||||
expect(verifyInstallation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches generated doctor and lifecycle argv through the real parser', async () => {
|
||||
const installationId = verifyAgentInstallationId('install-1')
|
||||
const registryRoot = privateTemporaryDirectory()
|
||||
const installationPaths = {
|
||||
executablePath: resolve(registryRoot, 'agent', 'goodbuddy-agent'),
|
||||
stateDirectory: resolve(registryRoot, 'state', 'install-1'),
|
||||
socketPath: resolve(registryRoot, 'run', 'install-1.sock')
|
||||
}
|
||||
const installationRegistry = new InstallationRegistry({
|
||||
storagePath: resolve(registryRoot, 'registry.json')
|
||||
})
|
||||
const releaseKeyRegistry = {
|
||||
formatVersion: 1 as const,
|
||||
keys: [],
|
||||
revocations: []
|
||||
}
|
||||
const verified = verifiedInstallation(
|
||||
installationPaths,
|
||||
installationId
|
||||
)
|
||||
installationRegistry.stageCandidate(verified)
|
||||
const status = vi.fn(async () => ({ state: 'absent' as const }))
|
||||
const bootstrap = vi.fn(async () => ({
|
||||
state: 'ready' as const,
|
||||
installationId
|
||||
}))
|
||||
const verifyInstallation = vi.fn(async () => verified)
|
||||
const dependencies: AgentCliDependencies = {
|
||||
installationPaths: () => installationPaths,
|
||||
releaseKeyRegistry,
|
||||
installationRegistry,
|
||||
verifyInstallation,
|
||||
loadRegisteredInstallation: async () => verified,
|
||||
createLifecycle: (() => ({
|
||||
status,
|
||||
bootstrap
|
||||
})) as unknown as NonNullable<
|
||||
AgentCliDependencies['createLifecycle']
|
||||
>
|
||||
}
|
||||
const lifecycleIo = cliIo()
|
||||
expect(
|
||||
await runAgentCli(
|
||||
buildFixedAgentCliArgv(installationId, { kind: 'doctor' }),
|
||||
{ ...dependencies, io: cliIo() }
|
||||
)
|
||||
).toBe(0)
|
||||
const lifecycleResult = await runAgentCli(
|
||||
buildFixedAgentCliArgv(installationId, {
|
||||
kind: 'lifecycle',
|
||||
action: 'bootstrap'
|
||||
}),
|
||||
{
|
||||
...dependencies,
|
||||
io: lifecycleIo
|
||||
}
|
||||
)
|
||||
expect(
|
||||
lifecycleResult,
|
||||
lifecycleIo.error.read()?.toString()
|
||||
).toBe(0)
|
||||
expect(status).toHaveBeenCalledOnce()
|
||||
expect(bootstrap).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
installationRegistry.snapshot().current?.installationId
|
||||
).toBe(installationId)
|
||||
|
||||
expect(
|
||||
await runAgentCli(
|
||||
buildFixedAgentCliArgv(installationId, {
|
||||
kind: 'lifecycle',
|
||||
action: 'adopt'
|
||||
}),
|
||||
{ ...dependencies, io: cliIo() }
|
||||
)
|
||||
).toBe(0)
|
||||
expect(verifyInstallation).toHaveBeenCalledOnce()
|
||||
expect(bootstrap).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('dispatches generated attach argv using only derived managed paths', async () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const paths: ManagedInstallationPaths = {
|
||||
executablePath: resolve(root, 'goodbuddy-agent'),
|
||||
stateDirectory: resolve(root, 'state'),
|
||||
socketPath: resolve(root, 'run', 'agent.sock')
|
||||
}
|
||||
mkdirSync(paths.stateDirectory, { mode: 0o700 })
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(paths.stateDirectory, 0o700)
|
||||
}
|
||||
writeFileSync(
|
||||
resolve(paths.stateDirectory, 'installation-identity'),
|
||||
Buffer.alloc(32, 7),
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
const attach = vi.fn(async (options) => {
|
||||
const connectionId = 'connection-diagnostic'
|
||||
writeFileSync(
|
||||
resolve(
|
||||
paths.stateDirectory,
|
||||
AGENT_PROTOCOL_FAILURE_RECORD_NAME
|
||||
),
|
||||
`${JSON.stringify({
|
||||
formatVersion: 1,
|
||||
connectionId,
|
||||
category: 'dispatch/process',
|
||||
createdAt: 1
|
||||
})}\n`,
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
options.onWelcome?.({
|
||||
type: 'goodbuddy-agent-welcome',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
connectionId,
|
||||
generation: 1,
|
||||
installationId: 'install-1',
|
||||
binaryDigest: `sha256:${'a'.repeat(64)}`,
|
||||
daemonBootId: 'boot-1',
|
||||
serverNonce: 'nonce-1'
|
||||
})
|
||||
})
|
||||
const installationId = verifyAgentInstallationId('install-1')
|
||||
const releaseKeyRegistry = {
|
||||
formatVersion: 1 as const,
|
||||
keys: [],
|
||||
revocations: []
|
||||
}
|
||||
const verified = verifiedInstallation(paths, installationId)
|
||||
const installationRegistry = new InstallationRegistry({
|
||||
storagePath: resolve(root, 'registry.json')
|
||||
})
|
||||
installationRegistry.stageCandidate(verified)
|
||||
const verifyInstallation = vi.fn(async () => verified)
|
||||
const loadRegisteredInstallation = vi.fn(async () => verified)
|
||||
const io = cliIo()
|
||||
|
||||
const attachResult = await runAgentCli(
|
||||
buildFixedAgentCliArgv(installationId, { kind: 'attach' }),
|
||||
{
|
||||
attach,
|
||||
installationPaths: () => paths,
|
||||
releaseKeyRegistry,
|
||||
installationRegistry,
|
||||
verifyInstallation,
|
||||
loadRegisteredInstallation,
|
||||
io
|
||||
}
|
||||
)
|
||||
expect(attachResult).toBe(0)
|
||||
expect(io.error.read()?.toString()).toBe(
|
||||
`${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}dispatch/process\n`
|
||||
)
|
||||
expect(attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
socketPath: paths.socketPath,
|
||||
secret: Buffer.alloc(32, 7)
|
||||
})
|
||||
)
|
||||
expect(loadRegisteredInstallation).toHaveBeenCalledOnce()
|
||||
expect(verifyInstallation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits a fixed repair diagnostic when registered attach metadata is invalid', async () => {
|
||||
const installationId = verifyAgentInstallationId('install-1')
|
||||
const root = privateTemporaryDirectory()
|
||||
const paths = {
|
||||
executablePath: resolve(root, 'agent', 'goodbuddy-agent'),
|
||||
stateDirectory: resolve(root, 'state', 'install-1'),
|
||||
socketPath: resolve(root, 'run', 'install-1.sock')
|
||||
}
|
||||
const verified = verifiedInstallation(paths, installationId)
|
||||
const installationRegistry = new InstallationRegistry({
|
||||
storagePath: resolve(root, 'registry.json')
|
||||
})
|
||||
installationRegistry.stageCandidate(verified)
|
||||
const io = cliIo()
|
||||
|
||||
const result = await runAgentCli(
|
||||
buildFixedAgentCliArgv(installationId, { kind: 'attach' }),
|
||||
{
|
||||
installationPaths: () => paths,
|
||||
installationRegistry,
|
||||
loadRegisteredInstallation: vi.fn(async () => {
|
||||
throw new RegisteredAgentBundleError(
|
||||
'private installation detail'
|
||||
)
|
||||
}),
|
||||
io
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toBe(2)
|
||||
expect(io.error.read()?.toString()).toBe(
|
||||
`${AGENT_PROTOCOL_FAILURE_STDERR_PREFIX}installation-repair-required\n`
|
||||
)
|
||||
})
|
||||
|
||||
it('derives fixed Linux paths and rejects untrusted installation IDs', () => {
|
||||
expect(
|
||||
deriveManagedInstallationPaths('install-1', {
|
||||
platform: 'linux',
|
||||
homeDirectory: '/home/tester',
|
||||
uid: 1234
|
||||
})
|
||||
).toEqual({
|
||||
executablePath:
|
||||
'/home/tester/.goodbuddy/agent/installations/install-1/goodbuddy-agent',
|
||||
stateDirectory: '/home/tester/.goodbuddy/state/install-1',
|
||||
socketPath:
|
||||
'/tmp/goodbuddy-1234-33847abc68e72629c30d/caab28ec6ec9597dfdd5bc3c2398a893.sock'
|
||||
})
|
||||
expect(() =>
|
||||
deriveManagedInstallationPaths('../escape', {
|
||||
platform: 'linux',
|
||||
homeDirectory: '/home/tester',
|
||||
uid: 1234
|
||||
})
|
||||
).toThrow('installation ID')
|
||||
expect(() =>
|
||||
deriveManagedInstallationPaths('install-1', {
|
||||
platform: 'win32',
|
||||
homeDirectory: 'C:\\Users\\tester',
|
||||
uid: 1234
|
||||
})
|
||||
).toThrow('current Linux UID')
|
||||
})
|
||||
|
||||
it('rejects injected installation IDs before lifecycle dispatch', async () => {
|
||||
const createLifecycle = vi.fn()
|
||||
expect(
|
||||
await runAgentCli(
|
||||
[
|
||||
'stop',
|
||||
'--installation-id',
|
||||
'install; shutdown'
|
||||
],
|
||||
{ createLifecycle, io: cliIo() }
|
||||
)
|
||||
).toBe(2)
|
||||
expect(createLifecycle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('verifies and atomically registers the fixed Runtime activation', async () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const runtimeRoot = resolve(root, 'runtimes')
|
||||
const bundleDigest = `sha256:${'f'.repeat(64)}`
|
||||
const paths = {
|
||||
runtimeRoot,
|
||||
bundleDirectory: resolve(
|
||||
runtimeRoot,
|
||||
'opencode',
|
||||
bundleDigest.slice('sha256:'.length)
|
||||
),
|
||||
releaseKeyRegistryPath: resolve(
|
||||
runtimeRoot,
|
||||
'release-keys.json'
|
||||
),
|
||||
runtimeLockPath: resolve(
|
||||
runtimeRoot,
|
||||
'remote-runtime-lock.json'
|
||||
),
|
||||
registryPath: resolve(runtimeRoot, 'registry.json')
|
||||
}
|
||||
const releaseKeyRegistry = {
|
||||
formatVersion: 1 as const,
|
||||
keys: [],
|
||||
revocations: []
|
||||
}
|
||||
const registry = new RuntimeBundleRegistry({
|
||||
runtimeRoot
|
||||
})
|
||||
const verifyRuntime = vi.fn(async () =>
|
||||
verifiedRuntime(paths.bundleDirectory, bundleDigest)
|
||||
)
|
||||
const io = cliIo()
|
||||
|
||||
expect(
|
||||
await runAgentCli(
|
||||
buildFixedAgentCliArgv(
|
||||
verifyAgentInstallationId('install-1'),
|
||||
{
|
||||
kind: 'runtime-activate',
|
||||
runtimeId: 'opencode',
|
||||
bundleDigest,
|
||||
architecture: 'x64'
|
||||
}
|
||||
),
|
||||
{
|
||||
runtimePaths: () => paths,
|
||||
runtimeReleaseKeyRegistry: releaseKeyRegistry,
|
||||
runtimeLock: {} as RemoteRuntimeLock,
|
||||
runtimeRegistry: registry,
|
||||
verifyRuntime,
|
||||
currentArchitecture: () => 'x64',
|
||||
io
|
||||
}
|
||||
)
|
||||
).toBe(0)
|
||||
expect(verifyRuntime).toHaveBeenCalledWith(
|
||||
paths.bundleDirectory,
|
||||
expect.objectContaining({
|
||||
architecture: 'x64',
|
||||
verificationEnvironment: 'production'
|
||||
})
|
||||
)
|
||||
expect(registry.current('x64')).toEqual([
|
||||
expect.objectContaining({
|
||||
runtimeId: 'opencode',
|
||||
bundleDigest,
|
||||
architecture: 'x64'
|
||||
})
|
||||
])
|
||||
expect(JSON.parse(io.output.read()!.toString())).toEqual({
|
||||
activated: true,
|
||||
runtimeId: 'opencode',
|
||||
bundleDigest,
|
||||
architecture: 'x64',
|
||||
runtimeVersion: '1.18.9'
|
||||
})
|
||||
|
||||
expect(
|
||||
await runAgentCli(
|
||||
buildFixedAgentCliArgv(
|
||||
verifyAgentInstallationId('install-1'),
|
||||
{
|
||||
kind: 'runtime-activate',
|
||||
runtimeId: 'opencode',
|
||||
bundleDigest,
|
||||
architecture: 'x64',
|
||||
forceVerification: true
|
||||
}
|
||||
),
|
||||
{
|
||||
runtimePaths: () => paths,
|
||||
runtimeReleaseKeyRegistry: releaseKeyRegistry,
|
||||
runtimeLock: {} as RemoteRuntimeLock,
|
||||
runtimeRegistry: registry,
|
||||
verifyRuntime,
|
||||
currentArchitecture: () => 'x64',
|
||||
io: cliIo()
|
||||
}
|
||||
)
|
||||
).toBe(0)
|
||||
expect(verifyRuntime).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('derives Runtime paths and rejects activation argv injection', async () => {
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
expect(
|
||||
deriveManagedRuntimePaths('install-1', 'opencode', digest, {
|
||||
homeDirectory: '/home/tester'
|
||||
})
|
||||
).toEqual({
|
||||
runtimeRoot: '/home/tester/.goodbuddy/runtimes',
|
||||
bundleDirectory:
|
||||
`/home/tester/.goodbuddy/runtimes/opencode/${'a'.repeat(64)}`,
|
||||
releaseKeyRegistryPath:
|
||||
'/home/tester/.goodbuddy/runtimes/release-keys.json',
|
||||
runtimeLockPath:
|
||||
'/home/tester/.goodbuddy/runtimes/remote-runtime-lock.json',
|
||||
registryPath:
|
||||
'/home/tester/.goodbuddy/runtimes/registry.json'
|
||||
})
|
||||
const verifyRuntime = vi.fn()
|
||||
expect(
|
||||
await runAgentCli(
|
||||
[
|
||||
'runtime',
|
||||
'activate',
|
||||
'--installation-id',
|
||||
'install-1',
|
||||
'--runtime-id',
|
||||
'opencode;id',
|
||||
'--bundle-digest',
|
||||
digest,
|
||||
'--architecture',
|
||||
'x64'
|
||||
],
|
||||
{ verifyRuntime, io: cliIo() }
|
||||
)
|
||||
).toBe(2)
|
||||
expect(verifyRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
function verifiedInstallation(
|
||||
paths: ManagedInstallationPaths,
|
||||
installationId: string
|
||||
): VerifiedInstalledAgentBundle {
|
||||
const manifestSha256 = 'a'.repeat(64)
|
||||
return {
|
||||
installationId,
|
||||
installationDirectory: resolve(paths.executablePath, '..'),
|
||||
executablePath: paths.executablePath,
|
||||
manifestSha256,
|
||||
binaryDigest: `sha256:${manifestSha256}`,
|
||||
manifest: {
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
agentVersion: '0.11.0',
|
||||
platform: 'linux',
|
||||
arch: process.arch === 'arm64' ? 'arm64' : 'x64',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
signingKeyId: 'test-key',
|
||||
entrypoint: {
|
||||
path: 'goodbuddy-agent',
|
||||
runtimePath: 'node',
|
||||
scriptPath: 'lib/agent.cjs'
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'goodbuddy-agent',
|
||||
size: 1,
|
||||
sha256: 'b'.repeat(64),
|
||||
mode: '0755'
|
||||
},
|
||||
{
|
||||
path: 'node',
|
||||
size: 1,
|
||||
sha256: 'c'.repeat(64),
|
||||
mode: '0755'
|
||||
},
|
||||
{
|
||||
path: 'lib/agent.cjs',
|
||||
size: 1,
|
||||
sha256: 'd'.repeat(64),
|
||||
mode: '0644'
|
||||
},
|
||||
{
|
||||
path: 'licenses/GoodBuddy-0BSD.txt',
|
||||
size: 1,
|
||||
sha256: 'e'.repeat(64),
|
||||
mode: '0644'
|
||||
}
|
||||
],
|
||||
licenses: [
|
||||
{
|
||||
package: 'GoodBuddy',
|
||||
version: '0.11.0',
|
||||
spdx: '0BSD',
|
||||
path: 'licenses/GoodBuddy-0BSD.txt'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cliIo(): {
|
||||
input: PassThrough
|
||||
output: PassThrough
|
||||
error: PassThrough
|
||||
} {
|
||||
return {
|
||||
input: new PassThrough(),
|
||||
output: new PassThrough(),
|
||||
error: new PassThrough()
|
||||
}
|
||||
}
|
||||
|
||||
function privateTemporaryDirectory(): string {
|
||||
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-cli-'))
|
||||
temporaryPaths.push(path)
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(path, 0o700)
|
||||
}
|
||||
return resolve(path)
|
||||
}
|
||||
|
||||
function verifiedRuntime(
|
||||
bundleDirectory: string,
|
||||
bundleDigest: string
|
||||
): VerifiedRuntimeBundle {
|
||||
return {
|
||||
bundleDirectory,
|
||||
executablePath: resolve(bundleDirectory, 'bin', 'opencode'),
|
||||
manifestDigest: `sha256:${'e'.repeat(64)}`,
|
||||
manifest: {
|
||||
runtimeId: 'opencode',
|
||||
provider: 'opencode',
|
||||
runtimeVersion: '1.18.9',
|
||||
architecture: 'x64',
|
||||
signingKeyId: 'test-key',
|
||||
bundleDigest,
|
||||
acpCapabilitiesDigest: `sha256:${'d'.repeat(64)}`
|
||||
}
|
||||
} as VerifiedRuntimeBundle
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,371 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { agentIdentifierSchema } from '../shared/agent-protocol/contracts'
|
||||
import {
|
||||
assertPrivateRegularFile,
|
||||
writePrivateFileAtomic
|
||||
} from './managed-paths'
|
||||
|
||||
export type ControllerLease = {
|
||||
controllerId: string
|
||||
connectionId: string
|
||||
generation: number
|
||||
leaseExpiresAt: number
|
||||
capabilityGeneration: number
|
||||
ownedObjects: Readonly<Record<string, readonly string[]>>
|
||||
}
|
||||
|
||||
type MutableController = {
|
||||
generation: number
|
||||
leaseExpiresAt: number
|
||||
capabilityGeneration: number
|
||||
connectionId?: string
|
||||
attached: boolean
|
||||
previousGeneration?: number
|
||||
previousConnectionId?: string
|
||||
ownedObjects: Map<string, Set<string>>
|
||||
}
|
||||
|
||||
export class ControllerRegistryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code:
|
||||
| 'stale-generation'
|
||||
| 'not-owner'
|
||||
| 'lease-expired'
|
||||
| 'not-attached'
|
||||
| 'takeover-mismatch'
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ControllerRegistryError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ControllerRegistry {
|
||||
readonly #controllers = new Map<string, MutableController>()
|
||||
readonly #now: () => number
|
||||
readonly #leaseMs: number
|
||||
readonly #storagePath?: string
|
||||
|
||||
constructor(options: {
|
||||
now?: () => number
|
||||
leaseMs?: number
|
||||
storagePath?: string
|
||||
} = {}) {
|
||||
this.#now = options.now ?? Date.now
|
||||
this.#leaseMs = options.leaseMs ?? 24 * 60 * 60 * 1000
|
||||
this.#storagePath = options.storagePath
|
||||
if (!Number.isSafeInteger(this.#leaseMs) || this.#leaseMs < 1) {
|
||||
throw new RangeError('Invalid controller lease duration')
|
||||
}
|
||||
this.#load()
|
||||
}
|
||||
|
||||
attach(controllerIdInput: string): ControllerLease {
|
||||
const controllerId = agentIdentifierSchema.parse(controllerIdInput)
|
||||
let record = this.#controllers.get(controllerId)
|
||||
if (record === undefined) {
|
||||
record = {
|
||||
generation: 0,
|
||||
leaseExpiresAt: 0,
|
||||
capabilityGeneration: 1,
|
||||
attached: false,
|
||||
ownedObjects: new Map()
|
||||
}
|
||||
this.#controllers.set(controllerId, record)
|
||||
}
|
||||
const previousGeneration =
|
||||
record.generation === 0 ? undefined : record.generation
|
||||
const previousConnectionId = record.connectionId
|
||||
record.generation += 1
|
||||
if (record.generation > 0xffff_ffff) {
|
||||
throw new RangeError('Controller generation exhausted')
|
||||
}
|
||||
record.connectionId = `conn-${randomBytes(18).toString('base64url')}`
|
||||
record.attached = true
|
||||
record.previousGeneration = previousGeneration
|
||||
record.previousConnectionId = previousConnectionId
|
||||
record.leaseExpiresAt = this.#now() + this.#leaseMs
|
||||
this.#persist()
|
||||
return snapshot(controllerId, record)
|
||||
}
|
||||
|
||||
resume(
|
||||
controllerId: string,
|
||||
generation: number,
|
||||
capabilityGeneration?: number
|
||||
): ControllerLease {
|
||||
const record = this.#assertCurrent(controllerId, generation)
|
||||
if (
|
||||
capabilityGeneration !== undefined &&
|
||||
capabilityGeneration !== record.capabilityGeneration
|
||||
) {
|
||||
throw new ControllerRegistryError(
|
||||
'Controller capability generation is stale',
|
||||
'stale-generation'
|
||||
)
|
||||
}
|
||||
record.leaseExpiresAt = this.#now() + this.#leaseMs
|
||||
this.#persist()
|
||||
return snapshot(controllerId, record)
|
||||
}
|
||||
|
||||
takeover(
|
||||
controllerId: string,
|
||||
generation: number,
|
||||
input: {
|
||||
previousGeneration: number
|
||||
previousConnectionId: string
|
||||
capabilityGeneration: number
|
||||
}
|
||||
): ControllerLease {
|
||||
const record = this.#assertCurrent(controllerId, generation)
|
||||
if (
|
||||
generation <= input.previousGeneration ||
|
||||
record.previousGeneration !== input.previousGeneration ||
|
||||
record.previousConnectionId !== input.previousConnectionId ||
|
||||
record.capabilityGeneration !== input.capabilityGeneration
|
||||
) {
|
||||
throw new ControllerRegistryError(
|
||||
'Controller takeover does not exactly match the recorded predecessor',
|
||||
'takeover-mismatch'
|
||||
)
|
||||
}
|
||||
record.leaseExpiresAt = this.#now() + this.#leaseMs
|
||||
this.#persist()
|
||||
return snapshot(controllerId, record)
|
||||
}
|
||||
|
||||
assertCurrent(controllerId: string, generation: number): ControllerLease {
|
||||
return snapshot(
|
||||
controllerId,
|
||||
this.#assertCurrent(controllerId, generation)
|
||||
)
|
||||
}
|
||||
|
||||
claim(
|
||||
controllerId: string,
|
||||
generation: number,
|
||||
kind: string,
|
||||
objectIdInput: string
|
||||
): void {
|
||||
const objectId = agentIdentifierSchema.parse(objectIdInput)
|
||||
const record = this.#assertCurrent(controllerId, generation)
|
||||
const objects = record.ownedObjects.get(kind) ?? new Set<string>()
|
||||
objects.add(objectId)
|
||||
record.ownedObjects.set(kind, objects)
|
||||
this.#persist()
|
||||
}
|
||||
|
||||
assertOwner(
|
||||
controllerId: string,
|
||||
generation: number,
|
||||
kind: string,
|
||||
objectId: string
|
||||
): void {
|
||||
const record = this.#assertCurrent(controllerId, generation)
|
||||
if (!record.ownedObjects.get(kind)?.has(objectId)) {
|
||||
throw new ControllerRegistryError(
|
||||
'Object belongs to another controller',
|
||||
'not-owner'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
release(
|
||||
controllerId: string,
|
||||
generation: number,
|
||||
kind: string,
|
||||
objectId: string
|
||||
): void {
|
||||
this.assertOwner(controllerId, generation, kind, objectId)
|
||||
this.#controllers.get(controllerId)?.ownedObjects.get(kind)?.delete(objectId)
|
||||
this.#persist()
|
||||
}
|
||||
|
||||
revokeCapabilities(controllerId: string): number {
|
||||
const record = this.#controllers.get(controllerId)
|
||||
if (record === undefined) {
|
||||
throw new ControllerRegistryError(
|
||||
'Controller has not attached',
|
||||
'not-attached'
|
||||
)
|
||||
}
|
||||
record.capabilityGeneration += 1
|
||||
this.#persist()
|
||||
return record.capabilityGeneration
|
||||
}
|
||||
|
||||
disconnect(controllerId: string, generation: number): void {
|
||||
const record = this.#assertCurrent(controllerId, generation)
|
||||
record.attached = false
|
||||
this.#persist()
|
||||
}
|
||||
|
||||
#assertCurrent(controllerIdInput: string, generation: number): MutableController {
|
||||
const controllerId = agentIdentifierSchema.parse(controllerIdInput)
|
||||
const record = this.#controllers.get(controllerId)
|
||||
if (record === undefined) {
|
||||
throw new ControllerRegistryError(
|
||||
'Controller has not attached',
|
||||
'not-attached'
|
||||
)
|
||||
}
|
||||
if (record.generation !== generation) {
|
||||
throw new ControllerRegistryError(
|
||||
'Connection generation is stale',
|
||||
'stale-generation'
|
||||
)
|
||||
}
|
||||
if (!record.attached) {
|
||||
throw new ControllerRegistryError(
|
||||
'Controller is not currently attached',
|
||||
'not-attached'
|
||||
)
|
||||
}
|
||||
if (record.leaseExpiresAt < this.#now()) {
|
||||
throw new ControllerRegistryError(
|
||||
'Controller lease has expired',
|
||||
'lease-expired'
|
||||
)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
#load(): void {
|
||||
if (this.#storagePath === undefined) {
|
||||
return
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
assertPrivateRegularFile(this.#storagePath)
|
||||
parsed = JSON.parse(readFileSync(this.#storagePath, 'utf8'))
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
) {
|
||||
this.#persist()
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('Controller registry is corrupt')
|
||||
}
|
||||
for (const item of parsed) {
|
||||
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
||||
throw new Error('Controller registry is corrupt')
|
||||
}
|
||||
const value = item as Record<string, unknown>
|
||||
const controllerId = agentIdentifierSchema.parse(value.controllerId)
|
||||
if (
|
||||
!Number.isInteger(value.generation) ||
|
||||
(value.generation as number) < 0 ||
|
||||
(value.generation as number) > 0xffff_ffff ||
|
||||
!Number.isSafeInteger(value.leaseExpiresAt) ||
|
||||
!Number.isInteger(value.capabilityGeneration) ||
|
||||
(value.capabilityGeneration as number) < 1 ||
|
||||
value.ownedObjects === null ||
|
||||
typeof value.ownedObjects !== 'object' ||
|
||||
Array.isArray(value.ownedObjects)
|
||||
) {
|
||||
throw new Error('Controller registry is corrupt')
|
||||
}
|
||||
const ownedObjects = new Map<string, Set<string>>()
|
||||
for (const [kind, objectIds] of Object.entries(
|
||||
value.ownedObjects as Record<string, unknown>
|
||||
)) {
|
||||
if (!Array.isArray(objectIds)) {
|
||||
throw new Error('Controller registry is corrupt')
|
||||
}
|
||||
ownedObjects.set(
|
||||
kind,
|
||||
new Set(objectIds.map((objectId) => agentIdentifierSchema.parse(objectId)))
|
||||
)
|
||||
}
|
||||
this.#controllers.set(controllerId, {
|
||||
generation: value.generation as number,
|
||||
leaseExpiresAt: value.leaseExpiresAt as number,
|
||||
capabilityGeneration: value.capabilityGeneration as number,
|
||||
connectionId:
|
||||
value.connectionId === undefined
|
||||
? undefined
|
||||
: agentIdentifierSchema.parse(value.connectionId),
|
||||
attached: false,
|
||||
previousGeneration:
|
||||
value.previousGeneration === undefined
|
||||
? undefined
|
||||
: generation(value.previousGeneration),
|
||||
previousConnectionId:
|
||||
value.previousConnectionId === undefined
|
||||
? undefined
|
||||
: agentIdentifierSchema.parse(value.previousConnectionId),
|
||||
ownedObjects
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#persist(): void {
|
||||
if (this.#storagePath === undefined) {
|
||||
return
|
||||
}
|
||||
writePrivateFileAtomic(
|
||||
this.#storagePath,
|
||||
JSON.stringify(
|
||||
[...this.#controllers].map(([controllerId, record]) => ({
|
||||
controllerId,
|
||||
generation: record.generation,
|
||||
leaseExpiresAt: record.leaseExpiresAt,
|
||||
capabilityGeneration: record.capabilityGeneration,
|
||||
connectionId: record.connectionId,
|
||||
previousGeneration: record.previousGeneration,
|
||||
previousConnectionId: record.previousConnectionId,
|
||||
ownedObjects: Object.fromEntries(
|
||||
[...record.ownedObjects].map(([kind, objects]) => [
|
||||
kind,
|
||||
[...objects]
|
||||
])
|
||||
)
|
||||
}))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
controllerId: string,
|
||||
record: MutableController
|
||||
): ControllerLease {
|
||||
if (record.connectionId === undefined || !record.attached) {
|
||||
throw new ControllerRegistryError(
|
||||
'Controller is not currently attached',
|
||||
'not-attached'
|
||||
)
|
||||
}
|
||||
return {
|
||||
controllerId,
|
||||
connectionId: record.connectionId,
|
||||
generation: record.generation,
|
||||
leaseExpiresAt: record.leaseExpiresAt,
|
||||
capabilityGeneration: record.capabilityGeneration,
|
||||
ownedObjects: Object.fromEntries(
|
||||
[...record.ownedObjects].map(([kind, objects]) => [
|
||||
kind,
|
||||
[...objects]
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function generation(value: unknown): number {
|
||||
if (
|
||||
!Number.isInteger(value) ||
|
||||
(value as number) < 1 ||
|
||||
(value as number) > 0xffff_ffff
|
||||
) {
|
||||
throw new Error('Controller registry is corrupt')
|
||||
}
|
||||
return value as number
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { chmodSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_PROTOCOL_VERSION,
|
||||
type AgentFrame,
|
||||
daemonStatusSchema
|
||||
} from '../shared/agent-protocol'
|
||||
import { AgentProtocolServer } from './protocol-server'
|
||||
import { AgentDaemon } from './daemon'
|
||||
import { EventJournal } from './event-journal'
|
||||
import { PrivateEndpoint } from './private-endpoint'
|
||||
import { WorkspaceRegistry } from './workspace-registry'
|
||||
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
const temporaryPaths: string[] = []
|
||||
const nativePlatform = process.platform
|
||||
let originalPlatform: PropertyDescriptor | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
let firstRead = true
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get() {
|
||||
if (nativePlatform === 'linux') {
|
||||
return nativePlatform
|
||||
}
|
||||
if (firstRead) {
|
||||
firstRead = false
|
||||
return 'linux'
|
||||
}
|
||||
return nativePlatform
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
if (originalPlatform !== undefined) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
for (const path of temporaryPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Agent daemon lifecycle', () => {
|
||||
it('returns the strict manifest-bound Linux daemon status', () => {
|
||||
const daemon = daemonForTest()
|
||||
const status = daemonStatusSchema.parse(daemon.status())
|
||||
|
||||
expect(status).toEqual({
|
||||
state: 'offline',
|
||||
installationId: 'installation-a',
|
||||
binaryDigest: digest,
|
||||
daemonBootId: expect.stringMatching(/^boot-/u),
|
||||
agentVersion: '0.11.0',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
platform: 'linux',
|
||||
architecture: process.arch === 'arm64' ? 'arm64' : 'x64',
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity:
|
||||
process.getuid === undefined
|
||||
? 'uid:unavailable'
|
||||
: `uid:${process.getuid()}`,
|
||||
draining: false
|
||||
})
|
||||
expect(Object.keys(status).sort()).toEqual([
|
||||
'agentVersion',
|
||||
'architecture',
|
||||
'binaryDigest',
|
||||
'daemonBootId',
|
||||
'draining',
|
||||
'installationId',
|
||||
'platform',
|
||||
'protocol',
|
||||
'remoteUserIdentity',
|
||||
'state',
|
||||
'supervisor'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not schedule unused generic journal maintenance', async () => {
|
||||
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
|
||||
vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue()
|
||||
vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue()
|
||||
const daemon = daemonForTest()
|
||||
|
||||
await daemon.start()
|
||||
expect(setIntervalSpy).not.toHaveBeenCalled()
|
||||
await daemon.stop()
|
||||
})
|
||||
|
||||
it('retires workspace handles before closing journals', async () => {
|
||||
vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue()
|
||||
vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue()
|
||||
const closeWorkspaces = vi.spyOn(
|
||||
WorkspaceRegistry.prototype,
|
||||
'closeAll'
|
||||
)
|
||||
const closeEvents = vi.spyOn(EventJournal.prototype, 'close')
|
||||
const daemon = daemonForTest()
|
||||
|
||||
await daemon.start()
|
||||
await daemon.stop()
|
||||
|
||||
expect(closeWorkspaces).toHaveBeenCalledOnce()
|
||||
expect(closeEvents).toHaveBeenCalledOnce()
|
||||
expect(closeWorkspaces.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
closeEvents.mock.invocationCallOrder[0]!
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps injected Runtime methods in their protocol namespace', async () => {
|
||||
vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue()
|
||||
vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue()
|
||||
const daemon = daemonForTest({
|
||||
runtimeProtocol: {
|
||||
runtimes: () => [],
|
||||
methods: {
|
||||
'agent/status': () => null
|
||||
},
|
||||
onAcpFrame: async () => {}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(daemon.start()).rejects.toThrow(
|
||||
/runtime\/ namespace/iu
|
||||
)
|
||||
expect(daemon.status().state).toBe('offline')
|
||||
})
|
||||
|
||||
it('routes factory Runtime output and disposes Runtime before journals', async () => {
|
||||
vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue()
|
||||
vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue()
|
||||
const sendAcpFrame = vi
|
||||
.spyOn(AgentProtocolServer.prototype, 'sendAcpFrame')
|
||||
.mockResolvedValue()
|
||||
const dispose = vi.fn(async () => undefined)
|
||||
let outputSink:
|
||||
| Parameters<NonNullable<
|
||||
ConstructorParameters<typeof AgentDaemon>[0]['runtimeFactory']
|
||||
>>[0]['outputSink']
|
||||
| undefined
|
||||
const runtimeFactory = vi.fn(async (context) => {
|
||||
outputSink = context.outputSink
|
||||
return {
|
||||
runtimes: () => [],
|
||||
methods: Object.fromEntries(
|
||||
[
|
||||
'openAcpChannel',
|
||||
'closeAcpChannel',
|
||||
'preparePrompt',
|
||||
'getAcpCursors',
|
||||
'escalateCancellation',
|
||||
'reconcilePrompt'
|
||||
].map((method) => [`runtime/${method}`, () => null])
|
||||
),
|
||||
onAcpFrame: async () => {},
|
||||
dispose
|
||||
}
|
||||
})
|
||||
const daemon = daemonForTest({ runtimeFactory })
|
||||
|
||||
await daemon.start()
|
||||
const frame = {
|
||||
header: {
|
||||
protocolMajor: 1,
|
||||
protocolMinor: 0,
|
||||
connectionId: 'connection-1',
|
||||
generation: 1,
|
||||
channelId: 'binding-1',
|
||||
channelEpoch: '1',
|
||||
direction: 'agent-to-main',
|
||||
sequence: '1',
|
||||
kind: 'acp',
|
||||
payloadLength: 2
|
||||
},
|
||||
payload: Buffer.from('ok')
|
||||
} satisfies AgentFrame
|
||||
await outputSink!(frame, {
|
||||
bindingId: 'binding-1',
|
||||
controllerId: 'controller-1',
|
||||
controllerGeneration: 1
|
||||
})
|
||||
expect(sendAcpFrame).toHaveBeenCalledWith(frame)
|
||||
|
||||
await daemon.stop()
|
||||
expect(runtimeFactory).toHaveBeenCalledOnce()
|
||||
expect(dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
function daemonForTest(
|
||||
overrides: Partial<ConstructorParameters<typeof AgentDaemon>[0]> = {}
|
||||
): AgentDaemon {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-daemon-'))
|
||||
chmodSync(root, 0o700)
|
||||
temporaryPaths.push(root)
|
||||
return new AgentDaemon({
|
||||
installationId: 'installation-a',
|
||||
binaryDigest: digest,
|
||||
agentVersion: '0.11.0',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
stateDirectory: resolve(root, 'state'),
|
||||
socketPath: resolve(root, 'runtime', 'agent.sock'),
|
||||
peerIdentityProvider: {
|
||||
async getPeerIdentity() {
|
||||
return { uid: 1 }
|
||||
}
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { arch } from 'node:os'
|
||||
import {
|
||||
daemonStatusSchema,
|
||||
agentIdentifierSchema,
|
||||
AGENT_PROTOCOL_FAILURE_RECORD_NAME,
|
||||
agentProtocolFailureRecordSchema,
|
||||
protocolVersionSchema,
|
||||
sha256DigestSchema
|
||||
} from '../shared/agent-protocol'
|
||||
import type {
|
||||
DaemonStatus,
|
||||
AttachPreface,
|
||||
DaemonCapabilities,
|
||||
AgentFrame
|
||||
} from '../shared/agent-protocol'
|
||||
import { ControllerRegistry } from './controller-registry'
|
||||
import { EventJournal } from './event-journal'
|
||||
import { InstallationChallengeVerifier } from './installation-challenge'
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
ensurePrivateDirectoryTree,
|
||||
readOrCreatePrivateSecret,
|
||||
writePrivateFileAtomic
|
||||
} from './managed-paths'
|
||||
import {
|
||||
PrivateEndpoint,
|
||||
type UnixPeerIdentityProvider
|
||||
} from './private-endpoint'
|
||||
import {
|
||||
AgentProtocolServer,
|
||||
type ProtocolMethodHandler
|
||||
} from './protocol-server'
|
||||
import { AgentUnsupportedError } from './errors'
|
||||
import { WorkspaceGitService } from './workspace-git-service'
|
||||
import { createWorkspaceProtocolMethods } from './workspace-protocol-methods'
|
||||
import { WorkspaceRegistry } from './workspace-registry'
|
||||
|
||||
export type AgentDaemonOptions = {
|
||||
installationId: string
|
||||
binaryDigest: string
|
||||
agentVersion: string
|
||||
protocol: AttachPreface['protocol']
|
||||
stateDirectory: string
|
||||
socketPath: string
|
||||
peerIdentityProvider: UnixPeerIdentityProvider
|
||||
now?: () => number
|
||||
gitExecutable?: string
|
||||
workspaceRequestTimeoutMs?: number
|
||||
runtimeProtocol?: {
|
||||
runtimes: () =>
|
||||
| DaemonCapabilities['runtimes']
|
||||
| Promise<DaemonCapabilities['runtimes']>
|
||||
methods: Readonly<Record<string, ProtocolMethodHandler>>
|
||||
onAcpFrame: NonNullable<ConstructorParameters<
|
||||
typeof AgentProtocolServer
|
||||
>[0]['onAcpFrame']>
|
||||
onBlobFrame?: NonNullable<ConstructorParameters<
|
||||
typeof AgentProtocolServer
|
||||
>[0]['onBlobFrame']>
|
||||
authorizeBlobFrame?: NonNullable<ConstructorParameters<
|
||||
typeof AgentProtocolServer
|
||||
>[0]['authorizeBlobFrame']>
|
||||
dispose?: () => void | Promise<void>
|
||||
}
|
||||
runtimeFactory?: (context: {
|
||||
events: EventJournal
|
||||
workspaces: WorkspaceRegistry
|
||||
outputSink: (
|
||||
frame: AgentFrame,
|
||||
context: {
|
||||
bindingId: string
|
||||
controllerId: string
|
||||
controllerGeneration: number
|
||||
}
|
||||
) => void | Promise<void>
|
||||
blobSink: (
|
||||
frame: AgentFrame,
|
||||
context: {
|
||||
bindingId: string
|
||||
controllerId: string
|
||||
controllerGeneration: number
|
||||
}
|
||||
) => void | Promise<void>
|
||||
}) =>
|
||||
| AgentDaemonOptions['runtimeProtocol']
|
||||
| undefined
|
||||
| Promise<AgentDaemonOptions['runtimeProtocol'] | undefined>
|
||||
reportError?: (message: string, error: unknown) => void
|
||||
}
|
||||
|
||||
export type AgentDaemonStatus = DaemonStatus
|
||||
|
||||
export class AgentDaemon {
|
||||
readonly #installationId: string
|
||||
readonly #binaryDigest: string
|
||||
readonly #agentVersion: string
|
||||
readonly #protocol: AttachPreface['protocol']
|
||||
readonly #architecture: 'x64' | 'arm64'
|
||||
readonly #remoteUserIdentity: string
|
||||
readonly #stateDirectory: string
|
||||
readonly #socketPath: string
|
||||
readonly #bootId = `boot-${randomBytes(18).toString('base64url')}`
|
||||
readonly #peerIdentityProvider: UnixPeerIdentityProvider
|
||||
readonly #now: () => number
|
||||
readonly #gitExecutable?: string
|
||||
readonly #workspaceRequestTimeoutMs?: number
|
||||
readonly #runtimeProtocol?: AgentDaemonOptions['runtimeProtocol']
|
||||
readonly #runtimeFactory?: AgentDaemonOptions['runtimeFactory']
|
||||
readonly #reportError: (message: string, error: unknown) => void
|
||||
#state: 'starting' | 'ready' | 'stopped' = 'stopped'
|
||||
#endpoint?: PrivateEndpoint
|
||||
#events?: EventJournal
|
||||
#workspaces?: WorkspaceRegistry
|
||||
#activeRuntimeProtocol?: AgentDaemonOptions['runtimeProtocol']
|
||||
|
||||
constructor(options: AgentDaemonOptions) {
|
||||
this.#installationId = agentIdentifierSchema.parse(options.installationId)
|
||||
this.#binaryDigest = sha256DigestSchema.parse(options.binaryDigest)
|
||||
this.#agentVersion =
|
||||
daemonStatusSchema.shape.agentVersion.parse(options.agentVersion)
|
||||
this.#protocol = protocolVersionSchema.parse(options.protocol)
|
||||
const hostArchitecture = arch()
|
||||
if (hostArchitecture !== 'x64' && hostArchitecture !== 'arm64') {
|
||||
throw new AgentUnsupportedError(
|
||||
`GoodBuddy Agent architecture is unsupported: ${hostArchitecture}`,
|
||||
'platform-incompatible'
|
||||
)
|
||||
}
|
||||
this.#architecture = hostArchitecture
|
||||
this.#remoteUserIdentity = agentIdentifierSchema.parse(
|
||||
process.getuid === undefined
|
||||
? 'uid:unavailable'
|
||||
: `uid:${process.getuid()}`
|
||||
)
|
||||
this.#stateDirectory = resolve(options.stateDirectory)
|
||||
this.#socketPath = resolve(options.socketPath)
|
||||
this.#peerIdentityProvider = options.peerIdentityProvider
|
||||
this.#now = options.now ?? Date.now
|
||||
this.#gitExecutable = options.gitExecutable
|
||||
this.#workspaceRequestTimeoutMs = options.workspaceRequestTimeoutMs
|
||||
this.#runtimeProtocol = options.runtimeProtocol
|
||||
this.#runtimeFactory = options.runtimeFactory
|
||||
if (
|
||||
this.#runtimeProtocol !== undefined &&
|
||||
this.#runtimeFactory !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent daemon accepts either Runtime protocol or Runtime factory'
|
||||
)
|
||||
}
|
||||
this.#reportError =
|
||||
options.reportError ??
|
||||
((message, error) => {
|
||||
console.error(message, error)
|
||||
})
|
||||
}
|
||||
|
||||
status(): AgentDaemonStatus {
|
||||
return daemonStatusSchema.parse({
|
||||
state: this.#state === 'stopped' ? 'offline' : this.#state,
|
||||
installationId: this.#installationId,
|
||||
binaryDigest: this.#binaryDigest,
|
||||
daemonBootId: this.#bootId,
|
||||
agentVersion: this.#agentVersion,
|
||||
protocol: this.#protocol,
|
||||
platform: 'linux',
|
||||
architecture: this.#architecture,
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity: this.#remoteUserIdentity,
|
||||
draining: false
|
||||
})
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new AgentUnsupportedError(
|
||||
'GoodBuddy Agent daemon is certified only on Linux',
|
||||
'platform-incompatible'
|
||||
)
|
||||
}
|
||||
if (this.#state !== 'stopped') {
|
||||
throw new Error('GoodBuddy Agent daemon is already started')
|
||||
}
|
||||
this.#state = 'starting'
|
||||
try {
|
||||
ensurePrivateDirectory(this.#stateDirectory)
|
||||
ensurePrivateDirectoryTree(
|
||||
resolve(this.#stateDirectory, 'journal'),
|
||||
this.#stateDirectory
|
||||
)
|
||||
ensurePrivateDirectory(dirname(this.#socketPath))
|
||||
const secret = readOrCreatePrivateSecret(
|
||||
resolve(this.#stateDirectory, 'installation-identity')
|
||||
)
|
||||
const controllers = new ControllerRegistry({
|
||||
now: this.#now,
|
||||
storagePath: resolve(this.#stateDirectory, 'controllers.json')
|
||||
})
|
||||
this.#events = new EventJournal(
|
||||
resolve(this.#stateDirectory, 'journal', 'events.sqlite'),
|
||||
{ now: this.#now }
|
||||
)
|
||||
const git = new WorkspaceGitService({
|
||||
...(this.#gitExecutable === undefined
|
||||
? {}
|
||||
: { gitExecutable: this.#gitExecutable })
|
||||
})
|
||||
this.#workspaces = new WorkspaceRegistry({
|
||||
controllers,
|
||||
now: this.#now,
|
||||
storagePath: resolve(
|
||||
this.#stateDirectory,
|
||||
'workspaces.json'
|
||||
),
|
||||
inspectGit: async (access, io) => await git.inspect(access, io)
|
||||
})
|
||||
const workspaceMethods = createWorkspaceProtocolMethods({
|
||||
workspaces: this.#workspaces,
|
||||
git,
|
||||
...(this.#workspaceRequestTimeoutMs === undefined
|
||||
? {}
|
||||
: {
|
||||
requestTimeoutMs:
|
||||
this.#workspaceRequestTimeoutMs
|
||||
})
|
||||
})
|
||||
const protocolBox: {
|
||||
current?: AgentProtocolServer
|
||||
} = {}
|
||||
this.#activeRuntimeProtocol =
|
||||
this.#runtimeProtocol ??
|
||||
await this.#runtimeFactory?.({
|
||||
events: this.#events,
|
||||
workspaces: this.#workspaces,
|
||||
outputSink: async (frame) => {
|
||||
if (protocolBox.current === undefined) {
|
||||
throw new Error(
|
||||
'Agent Runtime output sink is not ready'
|
||||
)
|
||||
}
|
||||
await protocolBox.current.sendAcpFrame(frame)
|
||||
},
|
||||
blobSink: async (frame) => {
|
||||
if (protocolBox.current === undefined) {
|
||||
throw new Error(
|
||||
'Agent Runtime blob sink is not ready'
|
||||
)
|
||||
}
|
||||
await protocolBox.current.sendBlobFrame(frame)
|
||||
}
|
||||
})
|
||||
const protocol = new AgentProtocolServer({
|
||||
controllers,
|
||||
events: this.#events,
|
||||
status: () => this.status(),
|
||||
...(this.#activeRuntimeProtocol === undefined
|
||||
? {}
|
||||
: {
|
||||
runtimes: this.#activeRuntimeProtocol.runtimes,
|
||||
onAcpFrame: this.#activeRuntimeProtocol.onAcpFrame,
|
||||
...(this.#activeRuntimeProtocol.onBlobFrame === undefined
|
||||
? {}
|
||||
: {
|
||||
onBlobFrame:
|
||||
this.#activeRuntimeProtocol.onBlobFrame
|
||||
}),
|
||||
...(this.#activeRuntimeProtocol.authorizeBlobFrame === undefined
|
||||
? {}
|
||||
: {
|
||||
authorizeBlobFrame:
|
||||
this.#activeRuntimeProtocol.authorizeBlobFrame
|
||||
})
|
||||
}),
|
||||
onProtocolFailure: ({
|
||||
connectionId,
|
||||
category
|
||||
}) => {
|
||||
try {
|
||||
writePrivateFileAtomic(
|
||||
resolve(
|
||||
this.#stateDirectory,
|
||||
AGENT_PROTOCOL_FAILURE_RECORD_NAME
|
||||
),
|
||||
`${JSON.stringify(
|
||||
agentProtocolFailureRecordSchema.parse({
|
||||
formatVersion: 1,
|
||||
connectionId,
|
||||
category,
|
||||
createdAt: this.#now()
|
||||
})
|
||||
)}\n`
|
||||
)
|
||||
} catch (error) {
|
||||
this.#reportError(
|
||||
'Agent protocol failure category could not be persisted',
|
||||
error
|
||||
)
|
||||
}
|
||||
},
|
||||
methods: mergeProtocolMethods(
|
||||
workspaceMethods,
|
||||
this.#activeRuntimeProtocol?.methods
|
||||
)
|
||||
})
|
||||
protocolBox.current = protocol
|
||||
this.#endpoint = new PrivateEndpoint({
|
||||
socketPath: this.#socketPath,
|
||||
peerIdentity: this.#peerIdentityProvider,
|
||||
challenge: new InstallationChallengeVerifier(secret, {
|
||||
now: this.#now
|
||||
}),
|
||||
controllers,
|
||||
installationId: this.#installationId,
|
||||
binaryDigest: this.#binaryDigest,
|
||||
daemonBootId: this.#bootId,
|
||||
protocol: this.#protocol,
|
||||
onAttach: ({ socket, controller }) => {
|
||||
protocol.accept(socket, controller)
|
||||
}
|
||||
})
|
||||
await this.#endpoint.listen()
|
||||
this.#state = 'ready'
|
||||
} catch (error) {
|
||||
await this.stop()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const endpoint = this.#endpoint
|
||||
const events = this.#events
|
||||
const workspaces = this.#workspaces
|
||||
const runtimeProtocol = this.#activeRuntimeProtocol
|
||||
this.#endpoint = undefined
|
||||
this.#events = undefined
|
||||
this.#workspaces = undefined
|
||||
this.#activeRuntimeProtocol = undefined
|
||||
try {
|
||||
if (endpoint !== undefined) {
|
||||
await endpoint.close()
|
||||
}
|
||||
} finally {
|
||||
if (runtimeProtocol?.dispose !== undefined) {
|
||||
await Promise.resolve(runtimeProtocol.dispose()).catch(
|
||||
(error: unknown) => {
|
||||
this.#reportRuntimeShutdownError(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
workspaces?.closeAll()
|
||||
events?.close()
|
||||
this.#state = 'stopped'
|
||||
}
|
||||
}
|
||||
|
||||
#reportRuntimeShutdownError(error: unknown): void {
|
||||
try {
|
||||
this.#reportError('Agent Runtime shutdown failed', error)
|
||||
} catch (reportError) {
|
||||
console.error(
|
||||
'Agent Runtime shutdown failure reporting failed',
|
||||
reportError,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function mergeProtocolMethods(
|
||||
workspaceMethods: Readonly<Record<string, ProtocolMethodHandler>>,
|
||||
runtimeMethods:
|
||||
| Readonly<Record<string, ProtocolMethodHandler>>
|
||||
| undefined
|
||||
): Readonly<Record<string, ProtocolMethodHandler>> {
|
||||
if (runtimeMethods === undefined) {
|
||||
return workspaceMethods
|
||||
}
|
||||
for (const method of Object.keys(runtimeMethods)) {
|
||||
if (!method.startsWith('runtime/')) {
|
||||
throw new Error(
|
||||
`Runtime protocol method must use the runtime/ namespace: ${method}`
|
||||
)
|
||||
}
|
||||
if (workspaceMethods[method] !== undefined) {
|
||||
throw new Error(
|
||||
`Runtime protocol method conflicts with a Workspace method: ${method}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return { ...workspaceMethods, ...runtimeMethods }
|
||||
}
|
||||
|
||||
export class UnsupportedPeerIdentityProvider
|
||||
implements UnixPeerIdentityProvider
|
||||
{
|
||||
async getPeerIdentity(): Promise<never> {
|
||||
throw new AgentUnsupportedError(
|
||||
'A certified SO_PEERCRED helper is unavailable',
|
||||
'peer-identity-unavailable'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DetachedAgentLifecycle,
|
||||
detachedAgentLifecycleRecordSchema,
|
||||
type DetachedAgentLifecycleOptions,
|
||||
type DetachedAgentLifecycleRecord,
|
||||
type DetachedSocketInspector,
|
||||
type DetachedSpawn
|
||||
} from './detached-agent-lifecycle'
|
||||
import {
|
||||
LinuxProcessInspector,
|
||||
type LinuxProcessIdentity
|
||||
} from './linux-process-identity'
|
||||
import { writePrivateFileAtomic } from './managed-paths'
|
||||
import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier'
|
||||
|
||||
const parentPid = 1101
|
||||
const childPid = 2202
|
||||
const temporaryPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
for (const path of temporaryPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('detached Agent lifecycle', () => {
|
||||
it('serializes concurrent bootstrap and records endpoint readiness', async () => {
|
||||
const harness = lifecycleHarness()
|
||||
harness.onSleep(async () => {
|
||||
const starting = harness.readRecord()
|
||||
if (starting?.daemonBootId === null) {
|
||||
await harness.daemon.recordCurrentDaemonReady('daemon-boot-1')
|
||||
}
|
||||
})
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
harness.lifecycle.bootstrap(),
|
||||
harness.lifecycle.bootstrap()
|
||||
])
|
||||
|
||||
expect(first.daemonBootId).toBe('daemon-boot-1')
|
||||
expect(second).toEqual(first)
|
||||
expect(harness.spawn).toHaveBeenCalledOnce()
|
||||
expect(harness.spawn).toHaveBeenCalledWith(
|
||||
harness.verified.executablePath,
|
||||
['daemon', '--installation-id', 'install-1'],
|
||||
{
|
||||
detached: true,
|
||||
shell: false,
|
||||
stdio: 'ignore',
|
||||
cwd: harness.verified.installationDirectory,
|
||||
env: {
|
||||
HOME: '/home/tester',
|
||||
LANG: 'zh_CN.UTF-8',
|
||||
PATH: '/usr/bin',
|
||||
TMPDIR: '/tmp'
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(harness.unref).toHaveBeenCalledOnce()
|
||||
expect(harness.probe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates the managed state parent before bootstrapping', async () => {
|
||||
const harness = lifecycleHarness({
|
||||
nestedStateDirectory: true
|
||||
})
|
||||
const stateRoot = resolve(harness.stateDirectory, '..')
|
||||
expect(existsSync(stateRoot)).toBe(false)
|
||||
|
||||
await expect(harness.becomeReady()).resolves.toMatchObject({
|
||||
daemonBootId: 'daemon-boot-1'
|
||||
})
|
||||
|
||||
expect(existsSync(stateRoot)).toBe(true)
|
||||
expect(existsSync(harness.stateDirectory)).toBe(true)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(lstatSync(stateRoot).mode & 0o777).toBe(0o700)
|
||||
expect(
|
||||
lstatSync(harness.stateDirectory).mode & 0o777
|
||||
).toBe(0o700)
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers stale lifecycle state without signalling a reused PID', async () => {
|
||||
const harness = lifecycleHarness()
|
||||
await harness.becomeReady()
|
||||
harness.signals.length = 0
|
||||
harness.identities.set(
|
||||
childPid,
|
||||
harness.childIdentity({ starttime: '99999' })
|
||||
)
|
||||
harness.onSleep(async () => {
|
||||
const starting = harness.readRecord()
|
||||
if (starting?.daemonBootId === null) {
|
||||
await harness.daemon.recordCurrentDaemonReady('daemon-after-reuse')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(harness.lifecycle.status()).resolves.toMatchObject({
|
||||
state: 'stale',
|
||||
reason: 'process-identity-mismatch'
|
||||
})
|
||||
const recovered = await harness.lifecycle.bootstrap()
|
||||
expect(recovered.daemonBootId).toBe('daemon-after-reuse')
|
||||
expect(harness.spawn).toHaveBeenCalledTimes(2)
|
||||
expect(harness.signals).toEqual([])
|
||||
expect(harness.socket.removed).toBe(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['start time', { starttime: '99999' }],
|
||||
['executable', { executablePath: resolve('foreign', 'runtime') }]
|
||||
])('never signals a PID with mismatched %s', async (_label, mismatch) => {
|
||||
const harness = lifecycleHarness()
|
||||
await harness.becomeReady()
|
||||
harness.identities.set(childPid, harness.childIdentity(mismatch))
|
||||
harness.signals.length = 0
|
||||
|
||||
await expect(harness.lifecycle.stop()).resolves.toEqual({
|
||||
state: 'absent'
|
||||
})
|
||||
expect(harness.signals).toEqual([])
|
||||
expect(harness.socket.removed).toBe(1)
|
||||
})
|
||||
|
||||
it('reclaims a stale exclusive lock and preserves a live lock', async () => {
|
||||
const harness = lifecycleHarness()
|
||||
harness.writeLock(harness.childIdentity({ starttime: '1' }))
|
||||
harness.onSleep(async () => {
|
||||
if (harness.readRecord()?.daemonBootId === null) {
|
||||
await harness.daemon.recordCurrentDaemonReady('after-stale-lock')
|
||||
}
|
||||
})
|
||||
await expect(harness.lifecycle.bootstrap()).resolves.toMatchObject({
|
||||
daemonBootId: 'after-stale-lock'
|
||||
})
|
||||
|
||||
const live = lifecycleHarness({ readinessTimeoutMs: 100 })
|
||||
live.writeLock(live.parentIdentity)
|
||||
await expect(live.lifecycle.bootstrap()).rejects.toThrow(
|
||||
'Timed out waiting'
|
||||
)
|
||||
expect(existsSync(live.lockPath)).toBe(true)
|
||||
expect(live.spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects corrupt and permissive lifecycle state', async () => {
|
||||
const corrupt = lifecycleHarness()
|
||||
mkdirSync(corrupt.stateDirectory, { mode: 0o700 })
|
||||
writeFileSync(corrupt.lifecyclePath, '{not-json\n', { mode: 0o600 })
|
||||
await expect(corrupt.lifecycle.status()).rejects.toThrow('corrupt')
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const permissive = lifecycleHarness()
|
||||
mkdirSync(permissive.stateDirectory, { mode: 0o700 })
|
||||
writeFileSync(permissive.lifecyclePath, '{}\n', { mode: 0o644 })
|
||||
await expect(permissive.lifecycle.status()).rejects.toThrow('corrupt')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses TERM then KILL and cleans lifecycle/socket state', async () => {
|
||||
const harness = lifecycleHarness({
|
||||
readinessTimeoutMs: 100,
|
||||
stopTimeoutMs: 100,
|
||||
termExits: false,
|
||||
killExits: true
|
||||
})
|
||||
|
||||
await expect(harness.lifecycle.bootstrap()).rejects.toThrow(
|
||||
'readiness timed out'
|
||||
)
|
||||
expect(harness.signals.map(({ signal }) => signal)).toEqual([
|
||||
'SIGTERM',
|
||||
'SIGKILL'
|
||||
])
|
||||
expect(existsSync(harness.lifecyclePath)).toBe(false)
|
||||
expect(harness.socket.removed).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps lifecycle state when TERM and KILL cannot stop the process', async () => {
|
||||
const harness = lifecycleHarness({
|
||||
readinessTimeoutMs: 100,
|
||||
stopTimeoutMs: 100,
|
||||
termExits: false,
|
||||
killExits: false
|
||||
})
|
||||
|
||||
await expect(harness.lifecycle.bootstrap()).rejects.toThrow(
|
||||
'did not stop'
|
||||
)
|
||||
expect(harness.signals.map(({ signal }) => signal)).toEqual([
|
||||
'SIGTERM',
|
||||
'SIGKILL'
|
||||
])
|
||||
expect(existsSync(harness.lifecyclePath)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
type HarnessOptions = {
|
||||
readinessTimeoutMs?: number
|
||||
stopTimeoutMs?: number
|
||||
termExits?: boolean
|
||||
killExits?: boolean
|
||||
nestedStateDirectory?: boolean
|
||||
}
|
||||
|
||||
function lifecycleHarness(options: HarnessOptions = {}) {
|
||||
const root = privateTemporaryDirectory()
|
||||
const installationDirectory = resolve(root, 'installation')
|
||||
const stateDirectory = options.nestedStateDirectory
|
||||
? resolve(root, 'state', 'install-1')
|
||||
: resolve(root, 'state')
|
||||
const socketPath = resolve(root, 'endpoint', 'agent.sock')
|
||||
const lifecyclePath = resolve(stateDirectory, 'detached-lifecycle.json')
|
||||
const lockPath = resolve(stateDirectory, 'detached-bootstrap.lock')
|
||||
const verified = verifiedInstallation(installationDirectory)
|
||||
const parentIdentity: LinuxProcessIdentity = {
|
||||
pid: parentPid,
|
||||
starttime: '101',
|
||||
executablePath: resolve(root, 'host', 'node')
|
||||
}
|
||||
const expectedChildIdentity = (
|
||||
changes: Partial<LinuxProcessIdentity> = {}
|
||||
): LinuxProcessIdentity => ({
|
||||
pid: childPid,
|
||||
starttime: '202',
|
||||
executablePath: resolve(installationDirectory, 'node'),
|
||||
...changes
|
||||
})
|
||||
const identities = new Map<number, LinuxProcessIdentity>([
|
||||
[parentPid, parentIdentity],
|
||||
[childPid, expectedChildIdentity()]
|
||||
])
|
||||
const inspector = new LinuxProcessInspector()
|
||||
vi.spyOn(inspector, 'inspect').mockImplementation((pid) =>
|
||||
identities.get(pid)
|
||||
)
|
||||
vi.spyOn(inspector, 'matches').mockImplementation((expected) => {
|
||||
const actual = identities.get(expected.pid)
|
||||
return actual !== undefined && sameIdentity(actual, expected)
|
||||
})
|
||||
const signals: Array<{
|
||||
identity: LinuxProcessIdentity
|
||||
signal: NodeJS.Signals
|
||||
}> = []
|
||||
vi.spyOn(inspector, 'signal').mockImplementation((identity, signal) => {
|
||||
const actual = identities.get(identity.pid)
|
||||
if (actual === undefined || !sameIdentity(actual, identity)) {
|
||||
return false
|
||||
}
|
||||
signals.push({ identity, signal })
|
||||
if (
|
||||
(signal === 'SIGTERM' && options.termExits !== false) ||
|
||||
(signal === 'SIGKILL' && options.killExits !== false)
|
||||
) {
|
||||
identities.delete(identity.pid)
|
||||
}
|
||||
return true
|
||||
})
|
||||
const socket = new FakeSocketInspector()
|
||||
const unref = vi.fn()
|
||||
const spawn = vi.fn<DetachedSpawn>(() => {
|
||||
identities.set(childPid, expectedChildIdentity())
|
||||
return { pid: childPid, unref }
|
||||
})
|
||||
let time = 1_000
|
||||
let sleepHook: (() => void | Promise<void>) | undefined
|
||||
let hookActive = false
|
||||
const sleep = async (milliseconds: number): Promise<void> => {
|
||||
time += milliseconds
|
||||
await Promise.resolve()
|
||||
if (sleepHook !== undefined && !hookActive) {
|
||||
hookActive = true
|
||||
try {
|
||||
await sleepHook()
|
||||
} finally {
|
||||
hookActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
const probe = vi.fn(async () => ({
|
||||
daemonBootId:
|
||||
detachedAgentLifecycleRecordSchema.parse(
|
||||
JSON.parse(readFileSync(lifecyclePath, 'utf8'))
|
||||
).daemonBootId ?? 'not-ready'
|
||||
}))
|
||||
const common: Omit<
|
||||
DetachedAgentLifecycleOptions,
|
||||
'currentPid' | 'spawnDetached'
|
||||
> = {
|
||||
installationId: 'install-1',
|
||||
executablePath: verified.executablePath,
|
||||
stateDirectory,
|
||||
socketPath,
|
||||
verifyInstallation: async () => verified,
|
||||
processInspector: inspector,
|
||||
socketInspector: socket,
|
||||
now: () => time,
|
||||
sleep,
|
||||
readinessTimeoutMs: options.readinessTimeoutMs ?? 500,
|
||||
stopTimeoutMs: options.stopTimeoutMs ?? 100,
|
||||
environment: {
|
||||
HOME: '/home/tester',
|
||||
LANG: 'zh_CN.UTF-8',
|
||||
PATH: '/usr/bin',
|
||||
TMPDIR: '/tmp',
|
||||
NODE_OPTIONS: '--inspect',
|
||||
GOODBUDDY_SECRET: 'must-not-leak'
|
||||
}
|
||||
}
|
||||
const lifecycle = new DetachedAgentLifecycle({
|
||||
...common,
|
||||
currentPid: parentPid,
|
||||
spawnDetached: spawn,
|
||||
probeEndpoint: probe
|
||||
})
|
||||
const daemon = new DetachedAgentLifecycle({
|
||||
...common,
|
||||
currentPid: childPid,
|
||||
spawnDetached: () => {
|
||||
throw new Error('Daemon must not spawn another daemon')
|
||||
}
|
||||
})
|
||||
|
||||
const readRecord = (): DetachedAgentLifecycleRecord | undefined => {
|
||||
if (!existsSync(lifecyclePath)) {
|
||||
return undefined
|
||||
}
|
||||
return detachedAgentLifecycleRecordSchema.parse(
|
||||
JSON.parse(readFileSync(lifecyclePath, 'utf8'))
|
||||
)
|
||||
}
|
||||
return {
|
||||
lifecycle,
|
||||
daemon,
|
||||
verified,
|
||||
stateDirectory,
|
||||
lifecyclePath,
|
||||
lockPath,
|
||||
identities,
|
||||
parentIdentity,
|
||||
childIdentity: expectedChildIdentity,
|
||||
socket,
|
||||
spawn,
|
||||
unref,
|
||||
probe,
|
||||
signals,
|
||||
readRecord,
|
||||
onSleep(hook: () => void | Promise<void>) {
|
||||
sleepHook = hook
|
||||
},
|
||||
writeLock(processIdentity: LinuxProcessIdentity) {
|
||||
mkdirSync(stateDirectory, { mode: 0o700, recursive: true })
|
||||
writePrivateFileAtomic(
|
||||
lockPath,
|
||||
`${JSON.stringify({
|
||||
formatVersion: 1,
|
||||
installationId: 'install-1',
|
||||
process: processIdentity
|
||||
})}\n`
|
||||
)
|
||||
},
|
||||
async becomeReady(): Promise<DetachedAgentLifecycleRecord> {
|
||||
let completed = false
|
||||
sleepHook = async () => {
|
||||
if (readRecord()?.daemonBootId === null && !completed) {
|
||||
completed = true
|
||||
await daemon.recordCurrentDaemonReady('daemon-boot-1')
|
||||
}
|
||||
}
|
||||
return await lifecycle.bootstrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSocketInspector implements DetachedSocketInspector {
|
||||
removed = 0
|
||||
|
||||
removeStale(): boolean {
|
||||
this.removed += 1
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function sameIdentity(
|
||||
left: LinuxProcessIdentity,
|
||||
right: LinuxProcessIdentity
|
||||
): boolean {
|
||||
return (
|
||||
left.pid === right.pid &&
|
||||
left.starttime === right.starttime &&
|
||||
left.executablePath === right.executablePath
|
||||
)
|
||||
}
|
||||
|
||||
function privateTemporaryDirectory(): string {
|
||||
const path = mkdtempSync(resolve(tmpdir(), 'goodbuddy-lifecycle-'))
|
||||
temporaryPaths.push(path)
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(path, 0o700)
|
||||
}
|
||||
return resolve(path)
|
||||
}
|
||||
|
||||
function verifiedInstallation(
|
||||
installationDirectory: string
|
||||
): VerifiedInstalledAgentBundle {
|
||||
const digest = 'a'.repeat(64)
|
||||
return {
|
||||
installationId: 'install-1',
|
||||
installationDirectory,
|
||||
executablePath: resolve(installationDirectory, 'goodbuddy-agent'),
|
||||
manifestSha256: digest,
|
||||
binaryDigest: `sha256:${digest}`,
|
||||
manifest: {
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
agentVersion: '0.11.0',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
signingKeyId: 'test-key',
|
||||
entrypoint: {
|
||||
path: 'goodbuddy-agent',
|
||||
runtimePath: 'node',
|
||||
scriptPath: 'lib/agent.cjs'
|
||||
},
|
||||
files: [],
|
||||
licenses: []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import {
|
||||
closeSync,
|
||||
constants,
|
||||
fchmodSync,
|
||||
lstatSync,
|
||||
openSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier'
|
||||
import {
|
||||
LinuxProcessInspector,
|
||||
type LinuxProcessIdentity
|
||||
} from './linux-process-identity'
|
||||
import {
|
||||
assertAbsoluteManagedPath,
|
||||
assertPrivateRegularFile,
|
||||
ensurePrivateDirectory,
|
||||
readPrivateFile,
|
||||
writePrivateFileAtomic
|
||||
} from './managed-paths'
|
||||
import type { AttachWelcome } from '../shared/agent-protocol'
|
||||
|
||||
const DEFAULT_READINESS_TIMEOUT_MS = 15_000
|
||||
const DEFAULT_STOP_TIMEOUT_MS = 5_000
|
||||
const POLL_INTERVAL_MS = 50
|
||||
|
||||
const processIdentitySchema = z
|
||||
.object({
|
||||
pid: z.number().int().positive().safe(),
|
||||
starttime: z.string().regex(/^(?:0|[1-9][0-9]*)$/u),
|
||||
executablePath: z.string().min(1)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const bootstrapLockRecordSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
installationId: z.string().min(1).max(128),
|
||||
process: processIdentitySchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const detachedAgentLifecycleRecordSchema = z
|
||||
.object({
|
||||
formatVersion: z.literal(1),
|
||||
installationId: z.string().min(1).max(128),
|
||||
binaryDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/u),
|
||||
protocol: z
|
||||
.object({
|
||||
major: z.number().int().min(0).max(65_535),
|
||||
minor: z.number().int().min(0).max(65_535)
|
||||
})
|
||||
.strict(),
|
||||
process: processIdentitySchema,
|
||||
daemonBootId: z.string().min(1).max(128).nullable(),
|
||||
createdAt: z.number().int().nonnegative().safe()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type DetachedAgentLifecycleRecord = z.infer<
|
||||
typeof detachedAgentLifecycleRecordSchema
|
||||
>
|
||||
|
||||
export type DetachedLifecycleStatus =
|
||||
| { state: 'absent' }
|
||||
| {
|
||||
state: 'starting' | 'ready'
|
||||
record: DetachedAgentLifecycleRecord
|
||||
}
|
||||
| {
|
||||
state: 'stale'
|
||||
reason: string
|
||||
record: DetachedAgentLifecycleRecord
|
||||
}
|
||||
|
||||
export type DetachedSpawn = (
|
||||
executable: string,
|
||||
argv: readonly string[],
|
||||
options: {
|
||||
env: Readonly<NodeJS.ProcessEnv>
|
||||
detached: true
|
||||
shell: false
|
||||
stdio: 'ignore'
|
||||
cwd: string
|
||||
}
|
||||
) => {
|
||||
pid?: number
|
||||
unref(): void
|
||||
}
|
||||
|
||||
export interface DetachedSocketInspector {
|
||||
removeStale(path: string): boolean
|
||||
}
|
||||
|
||||
export type DetachedAgentLifecycleOptions = {
|
||||
installationId: string
|
||||
executablePath: string
|
||||
stateDirectory: string
|
||||
socketPath: string
|
||||
verifyInstallation: () => Promise<VerifiedInstalledAgentBundle>
|
||||
processInspector?: LinuxProcessInspector
|
||||
socketInspector?: DetachedSocketInspector
|
||||
spawnDetached?: DetachedSpawn
|
||||
currentPid?: number
|
||||
now?: () => number
|
||||
sleep?: (milliseconds: number) => Promise<void>
|
||||
readinessTimeoutMs?: number
|
||||
stopTimeoutMs?: number
|
||||
environment?: Readonly<NodeJS.ProcessEnv>
|
||||
probeEndpoint?: (
|
||||
verified: VerifiedInstalledAgentBundle
|
||||
) => Promise<Pick<AttachWelcome, 'daemonBootId'>>
|
||||
retireLegacyInstallation?: (
|
||||
verified: VerifiedInstalledAgentBundle
|
||||
) => void | Promise<void>
|
||||
}
|
||||
|
||||
export class DetachedAgentLifecycle {
|
||||
readonly #installationId: string
|
||||
readonly #executablePath: string
|
||||
readonly #stateDirectory: string
|
||||
readonly #socketPath: string
|
||||
readonly #lifecyclePath: string
|
||||
readonly #lockPath: string
|
||||
readonly #verifyInstallation: () => Promise<VerifiedInstalledAgentBundle>
|
||||
readonly #processInspector: LinuxProcessInspector
|
||||
readonly #socketInspector: DetachedSocketInspector
|
||||
readonly #spawnDetached: DetachedSpawn
|
||||
readonly #currentPid: number
|
||||
readonly #now: () => number
|
||||
readonly #sleep: (milliseconds: number) => Promise<void>
|
||||
readonly #readinessTimeoutMs: number
|
||||
readonly #stopTimeoutMs: number
|
||||
readonly #environment: Readonly<NodeJS.ProcessEnv>
|
||||
readonly #probeEndpoint?: DetachedAgentLifecycleOptions['probeEndpoint']
|
||||
readonly #retireLegacyInstallation?: (
|
||||
verified: VerifiedInstalledAgentBundle
|
||||
) => void | Promise<void>
|
||||
|
||||
constructor(options: DetachedAgentLifecycleOptions) {
|
||||
this.#installationId = validateInstallationId(options.installationId)
|
||||
this.#executablePath = assertAbsoluteManagedPath(resolve(options.executablePath))
|
||||
this.#stateDirectory = assertAbsoluteManagedPath(resolve(options.stateDirectory))
|
||||
this.#socketPath = assertAbsoluteManagedPath(resolve(options.socketPath))
|
||||
this.#lifecyclePath = resolve(this.#stateDirectory, 'detached-lifecycle.json')
|
||||
this.#lockPath = resolve(this.#stateDirectory, 'detached-bootstrap.lock')
|
||||
this.#verifyInstallation = options.verifyInstallation
|
||||
this.#processInspector =
|
||||
options.processInspector ?? new LinuxProcessInspector()
|
||||
this.#socketInspector =
|
||||
options.socketInspector ?? new FileSystemSocketInspector()
|
||||
this.#spawnDetached = options.spawnDetached ?? spawnDetachedProcess
|
||||
this.#currentPid = positivePid(options.currentPid ?? process.pid)
|
||||
this.#now = options.now ?? Date.now
|
||||
this.#sleep =
|
||||
options.sleep ??
|
||||
(async (milliseconds) =>
|
||||
await new Promise<void>((resolveSleep) =>
|
||||
setTimeout(resolveSleep, milliseconds)
|
||||
))
|
||||
this.#readinessTimeoutMs = boundedTimeout(
|
||||
options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS
|
||||
)
|
||||
this.#stopTimeoutMs = boundedTimeout(
|
||||
options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS
|
||||
)
|
||||
this.#environment = options.environment ?? process.env
|
||||
this.#probeEndpoint = options.probeEndpoint
|
||||
this.#retireLegacyInstallation = options.retireLegacyInstallation
|
||||
}
|
||||
|
||||
async status(): Promise<DetachedLifecycleStatus> {
|
||||
return this.#statusAfterVerification(await this.#verifyAndBind())
|
||||
}
|
||||
|
||||
async health(): Promise<DetachedAgentLifecycleRecord> {
|
||||
const verified = await this.#verifyAndBind()
|
||||
const status = this.#statusAfterVerification(verified)
|
||||
if (status.state !== 'ready') {
|
||||
throw new Error(`Detached Agent is not healthy: ${status.state}`)
|
||||
}
|
||||
await this.#assertEndpointReady(status.record, verified)
|
||||
return status.record
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<DetachedAgentLifecycleRecord> {
|
||||
const verified = await this.#verifyAndBind()
|
||||
ensurePrivateDirectory(dirname(this.#stateDirectory))
|
||||
ensurePrivateDirectory(this.#stateDirectory)
|
||||
ensurePrivateDirectory(dirname(this.#socketPath))
|
||||
const releaseLock = await this.#acquireBootstrapLock()
|
||||
try {
|
||||
await this.#retireLegacyInstallation?.(verified)
|
||||
const existing = this.#statusAfterVerification(verified)
|
||||
if (existing.state === 'ready') {
|
||||
try {
|
||||
await this.#assertEndpointReady(existing.record, verified)
|
||||
return existing.record
|
||||
} catch {
|
||||
await this.#retireLiveRecord(existing.record)
|
||||
}
|
||||
} else if (existing.state === 'starting') {
|
||||
const ready = await this.#waitForReady(verified)
|
||||
if (ready !== undefined) {
|
||||
return ready
|
||||
}
|
||||
await this.#retireLiveRecord(existing.record)
|
||||
} else if (existing.state === 'stale') {
|
||||
this.#cleanupRecord(existing.record)
|
||||
}
|
||||
|
||||
const launchArgv = ['daemon', '--installation-id', this.#installationId]
|
||||
const runtimePath = expectedRuntimePath(verified)
|
||||
const child = this.#spawnDetached(this.#executablePath, launchArgv, {
|
||||
env: detachedEnvironment(this.#environment),
|
||||
detached: true,
|
||||
shell: false,
|
||||
stdio: 'ignore',
|
||||
cwd: dirname(this.#executablePath)
|
||||
})
|
||||
if (child.pid === undefined) {
|
||||
throw new Error('Detached Agent spawn did not return a process ID')
|
||||
}
|
||||
child.unref()
|
||||
const processIdentity = await this.#waitForProcessIdentity(
|
||||
child.pid,
|
||||
runtimePath
|
||||
)
|
||||
const starting = detachedAgentLifecycleRecordSchema.parse({
|
||||
formatVersion: 1,
|
||||
installationId: this.#installationId,
|
||||
binaryDigest: verified.binaryDigest,
|
||||
protocol: verified.manifest.protocol,
|
||||
process: processIdentity,
|
||||
daemonBootId: null,
|
||||
createdAt: this.#now()
|
||||
})
|
||||
this.#writeRecord(starting)
|
||||
const ready = await this.#waitForReady(verified)
|
||||
if (ready === undefined) {
|
||||
await this.#retireLiveRecord(starting)
|
||||
throw new Error('Detached Agent readiness timed out')
|
||||
}
|
||||
return ready
|
||||
} finally {
|
||||
releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
async recordCurrentDaemonReady(
|
||||
daemonBootId: string
|
||||
): Promise<DetachedAgentLifecycleRecord> {
|
||||
const verified = await this.#verifyAndBind()
|
||||
const current = this.#processInspector.inspect(this.#currentPid)
|
||||
if (current === undefined) {
|
||||
throw new Error('Current detached Agent process identity is unavailable')
|
||||
}
|
||||
const deadline = this.#now() + this.#readinessTimeoutMs
|
||||
while (this.#now() <= deadline) {
|
||||
const record = this.#readRecord()
|
||||
if (
|
||||
record !== undefined &&
|
||||
record.daemonBootId === null &&
|
||||
sameProcessIdentity(record.process, current)
|
||||
) {
|
||||
this.#assertRecordBinding(record, verified)
|
||||
const ready = detachedAgentLifecycleRecordSchema.parse({
|
||||
...record,
|
||||
daemonBootId
|
||||
})
|
||||
this.#writeRecord(ready)
|
||||
return ready
|
||||
}
|
||||
await this.#sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
throw new Error('Detached Agent bootstrap record is unavailable')
|
||||
}
|
||||
|
||||
async stop(): Promise<DetachedLifecycleStatus> {
|
||||
const verified = await this.#verifyAndBind()
|
||||
ensurePrivateDirectory(dirname(this.#stateDirectory))
|
||||
ensurePrivateDirectory(this.#stateDirectory)
|
||||
const releaseLock = await this.#acquireBootstrapLock()
|
||||
try {
|
||||
const status = this.#statusAfterVerification(verified)
|
||||
if (status.state === 'absent') {
|
||||
return status
|
||||
}
|
||||
if (status.state === 'stale') {
|
||||
this.#cleanupRecord(status.record)
|
||||
} else {
|
||||
await this.#retireLiveRecord(status.record)
|
||||
}
|
||||
return { state: 'absent' }
|
||||
} finally {
|
||||
releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
async retire(): Promise<DetachedLifecycleStatus> {
|
||||
return await this.stop()
|
||||
}
|
||||
|
||||
#statusAfterVerification(
|
||||
verified: VerifiedInstalledAgentBundle
|
||||
): DetachedLifecycleStatus {
|
||||
const record = this.#readRecord()
|
||||
if (record === undefined) {
|
||||
return { state: 'absent' }
|
||||
}
|
||||
this.#assertRecordBinding(record, verified)
|
||||
if (!this.#processInspector.matches(record.process)) {
|
||||
return {
|
||||
state: 'stale',
|
||||
reason: 'process-identity-mismatch',
|
||||
record
|
||||
}
|
||||
}
|
||||
return {
|
||||
state: record.daemonBootId === null ? 'starting' : 'ready',
|
||||
record
|
||||
}
|
||||
}
|
||||
|
||||
async #verifyAndBind(): Promise<VerifiedInstalledAgentBundle> {
|
||||
const verified = await this.#verifyInstallation()
|
||||
if (
|
||||
verified.installationId !== this.#installationId ||
|
||||
resolve(verified.executablePath) !== this.#executablePath
|
||||
) {
|
||||
throw new Error(
|
||||
'Signed Agent verification does not match the lifecycle installation'
|
||||
)
|
||||
}
|
||||
return verified
|
||||
}
|
||||
|
||||
#assertRecordBinding(
|
||||
record: DetachedAgentLifecycleRecord,
|
||||
verified: VerifiedInstalledAgentBundle
|
||||
): void {
|
||||
if (
|
||||
record.installationId !== this.#installationId ||
|
||||
record.binaryDigest !== verified.binaryDigest ||
|
||||
record.protocol.major !== verified.manifest.protocol.major ||
|
||||
record.protocol.minor !== verified.manifest.protocol.minor ||
|
||||
record.process.executablePath !== expectedRuntimePath(verified)
|
||||
) {
|
||||
throw new Error(
|
||||
'Detached Agent lifecycle record does not match the signed installation'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#readRecord(): DetachedAgentLifecycleRecord | undefined {
|
||||
try {
|
||||
assertPrivateRegularFile(this.#lifecyclePath)
|
||||
return detachedAgentLifecycleRecordSchema.parse(
|
||||
JSON.parse(readBoundedPrivateFile(this.#lifecyclePath))
|
||||
)
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return undefined
|
||||
}
|
||||
throw new Error('Detached Agent lifecycle record is corrupt', {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#writeRecord(record: DetachedAgentLifecycleRecord): void {
|
||||
writePrivateFileAtomic(
|
||||
this.#lifecyclePath,
|
||||
`${JSON.stringify(detachedAgentLifecycleRecordSchema.parse(record), null, 2)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
async #waitForReady(
|
||||
verified: VerifiedInstalledAgentBundle
|
||||
): Promise<DetachedAgentLifecycleRecord | undefined> {
|
||||
const deadline = this.#now() + this.#readinessTimeoutMs
|
||||
while (this.#now() <= deadline) {
|
||||
const status = this.#statusAfterVerification(verified)
|
||||
if (status.state === 'ready') {
|
||||
try {
|
||||
await this.#assertEndpointReady(status.record, verified)
|
||||
return status.record
|
||||
} catch {
|
||||
// The process may have written readiness just before listen completed.
|
||||
}
|
||||
} else if (status.state === 'absent' || status.state === 'stale') {
|
||||
return undefined
|
||||
}
|
||||
await this.#sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async #assertEndpointReady(
|
||||
record: DetachedAgentLifecycleRecord,
|
||||
verified: VerifiedInstalledAgentBundle
|
||||
): Promise<void> {
|
||||
if (this.#probeEndpoint === undefined) {
|
||||
return
|
||||
}
|
||||
const welcome = await this.#probeEndpoint(verified)
|
||||
if (welcome.daemonBootId !== record.daemonBootId) {
|
||||
throw new Error(
|
||||
'Detached Agent endpoint boot identity does not match lifecycle state'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async #waitForProcessIdentity(
|
||||
pid: number,
|
||||
executablePath: string
|
||||
): Promise<LinuxProcessIdentity> {
|
||||
const deadline = this.#now() + this.#readinessTimeoutMs
|
||||
while (this.#now() <= deadline) {
|
||||
const identity = this.#processInspector.inspect(pid)
|
||||
if (
|
||||
identity !== undefined &&
|
||||
identity.executablePath === executablePath
|
||||
) {
|
||||
return identity
|
||||
}
|
||||
await this.#sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
throw new Error('Detached Agent process identity could not be established')
|
||||
}
|
||||
|
||||
async #waitForExit(
|
||||
identity: LinuxProcessIdentity,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
const deadline = this.#now() + timeoutMs
|
||||
while (this.#now() <= deadline) {
|
||||
if (!this.#processInspector.matches(identity)) {
|
||||
return true
|
||||
}
|
||||
await this.#sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
return !this.#processInspector.matches(identity)
|
||||
}
|
||||
|
||||
async #retireLiveRecord(
|
||||
record: DetachedAgentLifecycleRecord
|
||||
): Promise<void> {
|
||||
if (this.#processInspector.matches(record.process)) {
|
||||
this.#processInspector.signal(record.process, 'SIGTERM')
|
||||
if (!(await this.#waitForExit(record.process, this.#stopTimeoutMs))) {
|
||||
this.#processInspector.signal(record.process, 'SIGKILL')
|
||||
await this.#waitForExit(record.process, this.#stopTimeoutMs)
|
||||
}
|
||||
}
|
||||
if (this.#processInspector.matches(record.process)) {
|
||||
throw new Error('Detached Agent did not stop')
|
||||
}
|
||||
this.#cleanupRecord(record)
|
||||
}
|
||||
|
||||
#cleanupRecord(record: DetachedAgentLifecycleRecord): void {
|
||||
if (this.#processInspector.matches(record.process)) {
|
||||
throw new Error('Refusing to clean a live detached Agent lifecycle')
|
||||
}
|
||||
assertPrivateRegularFile(this.#lifecyclePath)
|
||||
const current = detachedAgentLifecycleRecordSchema.parse(
|
||||
JSON.parse(readBoundedPrivateFile(this.#lifecyclePath))
|
||||
)
|
||||
if (!sameLifecycleRecord(current, record)) {
|
||||
throw new Error('Detached Agent lifecycle changed during cleanup')
|
||||
}
|
||||
this.#socketInspector.removeStale(this.#socketPath)
|
||||
unlinkSync(this.#lifecyclePath)
|
||||
}
|
||||
|
||||
async #acquireBootstrapLock(): Promise<() => void> {
|
||||
const deadline = this.#now() + this.#readinessTimeoutMs
|
||||
while (this.#now() <= deadline) {
|
||||
const descriptor = tryCreateLock(this.#lockPath)
|
||||
if (descriptor !== undefined) {
|
||||
const owner = this.#processInspector.inspect(this.#currentPid)
|
||||
if (owner === undefined) {
|
||||
closeSync(descriptor)
|
||||
unlinkSync(this.#lockPath)
|
||||
throw new Error(
|
||||
'Current bootstrap lock process identity is unavailable'
|
||||
)
|
||||
}
|
||||
try {
|
||||
writeFileSync(
|
||||
descriptor,
|
||||
`${JSON.stringify({
|
||||
formatVersion: 1,
|
||||
installationId: this.#installationId,
|
||||
process: owner
|
||||
})}\n`,
|
||||
'utf8'
|
||||
)
|
||||
if (process.platform !== 'win32') {
|
||||
fchmodSync(descriptor, 0o600)
|
||||
}
|
||||
} finally {
|
||||
closeSync(descriptor)
|
||||
}
|
||||
return () => this.#releaseBootstrapLock(owner)
|
||||
}
|
||||
const lock = this.#readLock()
|
||||
if (lock === undefined) {
|
||||
continue
|
||||
}
|
||||
if (!this.#processInspector.matches(lock.process)) {
|
||||
const current = this.#readLock()
|
||||
if (
|
||||
current !== undefined &&
|
||||
sameProcessIdentity(current.process, lock.process)
|
||||
) {
|
||||
unlinkSync(this.#lockPath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
await this.#sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
throw new Error('Timed out waiting for detached Agent bootstrap lock')
|
||||
}
|
||||
|
||||
#readLock(): z.infer<typeof bootstrapLockRecordSchema> | undefined {
|
||||
try {
|
||||
assertPrivateRegularFile(this.#lockPath)
|
||||
const lock = bootstrapLockRecordSchema.parse(
|
||||
JSON.parse(readBoundedPrivateFile(this.#lockPath))
|
||||
)
|
||||
if (lock.installationId !== this.#installationId) {
|
||||
throw new Error(
|
||||
'Detached Agent bootstrap lock belongs to another installation'
|
||||
)
|
||||
}
|
||||
return lock
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return undefined
|
||||
}
|
||||
throw new Error('Detached Agent bootstrap lock is corrupt', {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#releaseBootstrapLock(owner: LinuxProcessIdentity): void {
|
||||
const lock = this.#readLock()
|
||||
if (
|
||||
lock !== undefined &&
|
||||
sameProcessIdentity(lock.process, owner)
|
||||
) {
|
||||
unlinkSync(this.#lockPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function spawnDetachedProcess(
|
||||
executable: string,
|
||||
argv: readonly string[],
|
||||
options: {
|
||||
env: Readonly<NodeJS.ProcessEnv>
|
||||
detached: true
|
||||
shell: false
|
||||
stdio: 'ignore'
|
||||
cwd: string
|
||||
}
|
||||
): ReturnType<DetachedSpawn> {
|
||||
return spawn(executable, [...argv], options)
|
||||
}
|
||||
|
||||
function detachedEnvironment(
|
||||
source: Readonly<NodeJS.ProcessEnv>
|
||||
): Readonly<NodeJS.ProcessEnv> {
|
||||
const allowed = [
|
||||
'HOME',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'LC_CTYPE',
|
||||
'LOGNAME',
|
||||
'PATH',
|
||||
'TMPDIR',
|
||||
'TZ',
|
||||
'USER'
|
||||
] as const
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const key of allowed) {
|
||||
const value = source[key]
|
||||
if (value !== undefined && !value.includes('\0')) {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function expectedRuntimePath(
|
||||
verified: VerifiedInstalledAgentBundle
|
||||
): string {
|
||||
return assertAbsoluteManagedPath(
|
||||
resolve(
|
||||
verified.installationDirectory,
|
||||
verified.manifest.entrypoint.runtimePath
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class FileSystemSocketInspector implements DetachedSocketInspector {
|
||||
removeStale(path: string): boolean {
|
||||
try {
|
||||
const stat = lstatSync(path)
|
||||
const uid = process.getuid?.()
|
||||
if (
|
||||
!stat.isSocket() ||
|
||||
stat.isSymbolicLink() ||
|
||||
(uid !== undefined && stat.uid !== uid)
|
||||
) {
|
||||
throw new Error('Refusing to remove an unrelated endpoint path')
|
||||
}
|
||||
unlinkSync(path)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryCreateLock(path: string): number | undefined {
|
||||
try {
|
||||
return openSync(
|
||||
path,
|
||||
constants.O_CREAT |
|
||||
constants.O_EXCL |
|
||||
constants.O_WRONLY |
|
||||
(constants.O_NOFOLLOW ?? 0),
|
||||
0o600
|
||||
)
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'EEXIST') {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function readBoundedPrivateFile(path: string): string {
|
||||
return readPrivateFile(path, 64 * 1024).toString('utf8')
|
||||
}
|
||||
|
||||
function sameProcessIdentity(
|
||||
left: LinuxProcessIdentity,
|
||||
right: LinuxProcessIdentity
|
||||
): boolean {
|
||||
return (
|
||||
left.pid === right.pid &&
|
||||
left.starttime === right.starttime &&
|
||||
left.executablePath === right.executablePath
|
||||
)
|
||||
}
|
||||
|
||||
function sameLifecycleRecord(
|
||||
left: DetachedAgentLifecycleRecord,
|
||||
right: DetachedAgentLifecycleRecord
|
||||
): boolean {
|
||||
return (
|
||||
sameProcessIdentity(left.process, right.process) &&
|
||||
left.daemonBootId === right.daemonBootId &&
|
||||
left.createdAt === right.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
function validateInstallationId(value: string): string {
|
||||
if (
|
||||
value.length > 128 ||
|
||||
!/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u.test(value)
|
||||
) {
|
||||
throw new Error('Invalid Agent installation ID')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function boundedTimeout(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 100 || value > 120_000) {
|
||||
throw new Error('Detached Agent timeout is invalid')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function positivePid(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error('Detached Agent process ID is invalid')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && 'code' in error
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { chmodSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
|
||||
import {
|
||||
launchDirectLinuxStdioProcessOwner,
|
||||
reconcileOrphanedDirectLinuxStdioProcesses,
|
||||
type DirectLinuxStdioChild,
|
||||
type DirectLinuxStdioSpawn
|
||||
} from './direct-linux-stdio-process-owner'
|
||||
import { RuntimeOwnerRegistry } from './runtime-owner-registry'
|
||||
import type { LinuxRuntimeProcessIdentity } from './runtime-process-identity'
|
||||
|
||||
const paths: string[] = []
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
for (const path of paths.splice(0)) rmSync(path, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('direct Linux stdio Runtime ownership', () => {
|
||||
it('spawns exact bubblewrap argv without systemd using detached fixed stdio', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
let launchedIdentity!: LinuxRuntimeProcessIdentity
|
||||
const spawn = vi.fn<DirectLinuxStdioSpawn>((executable, args, options) => {
|
||||
expect(executable).toBe('/usr/bin/bwrap')
|
||||
expect(args).not.toContain('--die-with-parent')
|
||||
expect(args).toContain('--unshare-all')
|
||||
expect(args).toContain('--new-session')
|
||||
expect(args).toContain('GOODBUDDY_RUNTIME_OWNER_TOKEN')
|
||||
expect(args.join('\0')).not.toMatch(/systemd-run|systemctl|DBUS/iu)
|
||||
expect(options).toEqual({
|
||||
shell: false,
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
cwd: '/state/scratch',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }
|
||||
})
|
||||
launchedIdentity = identity()
|
||||
queueMicrotask(() => child.emit('spawn'))
|
||||
return child
|
||||
})
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(),
|
||||
profile: profile(),
|
||||
identity: { launchId: 'launch-1', processId: 'process-1' },
|
||||
installationId: 'installation-1',
|
||||
registry,
|
||||
deadlineAt: '2030-01-01T00:00:00.000Z',
|
||||
maximumInputBytes: 1024,
|
||||
platform: 'linux',
|
||||
spawn,
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity: async () => launchedIdentity
|
||||
})
|
||||
expect(spawn).toHaveBeenCalledOnce()
|
||||
expect(owner.processIdentity).toEqual(launchedIdentity)
|
||||
expect(registry.get(owner.ownerId)).toMatchObject({
|
||||
state: 'running',
|
||||
installationId: 'installation-1',
|
||||
ownerToken: 'a'.repeat(32)
|
||||
})
|
||||
child.exitCode = 0
|
||||
child.emit('close', 0, null)
|
||||
expect(registry.get(owner.ownerId)).toBeUndefined()
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('directly launches and verifies the packaged Node bridge helper', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
const processIdentity = {
|
||||
...identity(),
|
||||
executablePath: '/agent/node'
|
||||
}
|
||||
const spawn = vi.fn<DirectLinuxStdioSpawn>(
|
||||
(executable, args) => {
|
||||
expect(executable).toBe('/agent/node')
|
||||
expect(args).toEqual([
|
||||
'/agent/lib/agent.cjs',
|
||||
'model-bridge-helper'
|
||||
])
|
||||
queueMicrotask(() => child.emit('spawn'))
|
||||
return child
|
||||
}
|
||||
)
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(),
|
||||
profile: {
|
||||
executable: '/agent/node',
|
||||
processExecutable: '/agent/node',
|
||||
args: ['/agent/lib/agent.cjs', 'model-bridge-helper'],
|
||||
cwd: '/workspace',
|
||||
env: { PATH: '/usr/bin:/bin' },
|
||||
workMode: 'execute'
|
||||
},
|
||||
identity: { launchId: 'launch-1', processId: 'process-1' },
|
||||
installationId: 'installation-1',
|
||||
registry,
|
||||
deadlineAt: '2030-01-01T00:00:00.000Z',
|
||||
maximumInputBytes: 1024,
|
||||
platform: 'linux',
|
||||
spawn,
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity: async () => processIdentity
|
||||
})
|
||||
|
||||
expect(owner.processIdentity.executablePath).toBe('/agent/node')
|
||||
expect(registry.get(owner.ownerId)).toMatchObject({
|
||||
state: 'running',
|
||||
processIdentity
|
||||
})
|
||||
child.exitCode = 0
|
||||
child.emit('close', 0, null)
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('rejects PID reuse before sending any signal', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
const original = identity()
|
||||
const spawn: DirectLinuxStdioSpawn = () => {
|
||||
queueMicrotask(() => child.emit('spawn'))
|
||||
return child
|
||||
}
|
||||
let reads = 0
|
||||
const sendSignal = vi.fn<typeof process.kill>(() => true)
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(),
|
||||
profile: profile(),
|
||||
identity: { launchId: 'launch-1', processId: 'process-1' },
|
||||
installationId: 'installation-1',
|
||||
registry,
|
||||
deadlineAt: '2030-01-01T00:00:00.000Z',
|
||||
maximumInputBytes: 1024,
|
||||
platform: 'linux',
|
||||
spawn,
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity: async () => {
|
||||
reads += 1
|
||||
return reads === 1
|
||||
? original
|
||||
: { ...original, startTimeTicks: 999n }
|
||||
},
|
||||
sendSignal
|
||||
})
|
||||
await expect(
|
||||
owner.stop({
|
||||
reason: 'user-cancelled',
|
||||
deadlineAt: '2030-01-01T00:00:00.000Z'
|
||||
})
|
||||
).rejects.toThrow(/identity changed/iu)
|
||||
expect(sendSignal).not.toHaveBeenCalled()
|
||||
expect(registry.get(owner.ownerId)).toBeUndefined()
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('models verified process-group TERM then KILL and requires an empty tree', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
let launched!: LinuxRuntimeProcessIdentity
|
||||
const spawn: DirectLinuxStdioSpawn = () => {
|
||||
launched = identity()
|
||||
queueMicrotask(() => child.emit('spawn'))
|
||||
return child
|
||||
}
|
||||
const signals: Array<[number, string | number | undefined]> = []
|
||||
let scans = 0
|
||||
let now = Date.now()
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(),
|
||||
profile: profile(),
|
||||
identity: { launchId: 'launch-1', processId: 'process-1' },
|
||||
installationId: 'installation-1',
|
||||
registry,
|
||||
deadlineAt: new Date(now + 30_000).toISOString(),
|
||||
maximumInputBytes: 1024,
|
||||
platform: 'linux',
|
||||
spawn,
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity: async () => launched,
|
||||
now: () => {
|
||||
now += 1_000
|
||||
return now
|
||||
},
|
||||
listPidNamespaceMembers: async () => {
|
||||
scans += 1
|
||||
return scans < 4 ? [launched] : []
|
||||
},
|
||||
sendSignal: ((pid, signal) => {
|
||||
signals.push([pid, signal])
|
||||
return true
|
||||
}) as typeof process.kill
|
||||
})
|
||||
await owner.stop({
|
||||
reason: 'user-cancelled',
|
||||
deadlineAt: new Date(now + 20_000).toISOString()
|
||||
})
|
||||
expect(signals).toEqual([
|
||||
[-launched.processGroupId, 'SIGTERM'],
|
||||
[-launched.processGroupId, 'SIGKILL']
|
||||
])
|
||||
expect(registry.get(owner.ownerId)).toBeUndefined()
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('startup reconciles only owners from the current installation', async () => {
|
||||
const registry = createRegistry()
|
||||
const reservation = {
|
||||
ownerId: 'owner-current',
|
||||
launchId: 'launch-current',
|
||||
processId: 'process-current',
|
||||
installationId: 'installation-current',
|
||||
ownerToken: 'a'.repeat(32)
|
||||
}
|
||||
registry.reserve(reservation)
|
||||
registry.reserve({
|
||||
...reservation,
|
||||
ownerId: 'owner-other',
|
||||
launchId: 'launch-other',
|
||||
processId: 'process-other',
|
||||
installationId: 'installation-other'
|
||||
})
|
||||
|
||||
await expect(
|
||||
reconcileOrphanedDirectLinuxStdioProcesses({
|
||||
installationId: 'installation-current',
|
||||
registry
|
||||
})
|
||||
).resolves.toEqual({
|
||||
inspected: 1,
|
||||
stopped: 1,
|
||||
conflicts: 0,
|
||||
unknown: 0
|
||||
})
|
||||
expect(registry.get('owner-current')).toBeUndefined()
|
||||
expect(registry.get('owner-other')?.state).toBe('reserved')
|
||||
registry.close()
|
||||
})
|
||||
})
|
||||
|
||||
function createRegistry(): RuntimeOwnerRegistry {
|
||||
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-direct-owner-'))
|
||||
paths.push(root)
|
||||
if (process.platform !== 'win32') chmodSync(root, 0o700)
|
||||
return new RuntimeOwnerRegistry(join(root, 'owners.sqlite'))
|
||||
}
|
||||
|
||||
function fakeChild(): DirectLinuxStdioChild & EventEmitter {
|
||||
const child = new EventEmitter() as DirectLinuxStdioChild & EventEmitter
|
||||
child.pid = 42
|
||||
child.stdin = new PassThrough()
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.exitCode = null
|
||||
child.signalCode = null
|
||||
child.kill = vi.fn(() => true)
|
||||
return child
|
||||
}
|
||||
|
||||
function profile() {
|
||||
return {
|
||||
executable: '/usr/bin/bwrap' as const,
|
||||
processExecutable: '/usr/bin/bwrap' as const,
|
||||
cwd: '/state/scratch',
|
||||
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' },
|
||||
workMode: 'ask' as const,
|
||||
args: [
|
||||
'--new-session',
|
||||
'--unshare-all',
|
||||
'--clearenv',
|
||||
'--proc',
|
||||
'/proc',
|
||||
'--ro-bind',
|
||||
'/workspace',
|
||||
'/workspace',
|
||||
'--',
|
||||
'/bundle/bin/opencode',
|
||||
'acp'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function identity(): LinuxRuntimeProcessIdentity {
|
||||
return {
|
||||
bootId: '11111111-1111-1111-1111-111111111111',
|
||||
pid: 42,
|
||||
startTimeTicks: 100n,
|
||||
processGroupId: 42,
|
||||
executablePath: '/usr/bin/bwrap'
|
||||
}
|
||||
}
|
||||
|
||||
function manifest(): RemoteRuntimeBundleManifest {
|
||||
return {
|
||||
formatVersion: 2,
|
||||
product: 'GoodBuddy',
|
||||
runtimeId: 'opencode',
|
||||
runtimeVersion: '1.18.9',
|
||||
provider: 'opencode',
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
signingKeyId: 'runtime-test',
|
||||
bundleDigest: `sha256:${'a'.repeat(64)}`,
|
||||
adapterDigest: `sha256:${'b'.repeat(64)}`,
|
||||
sourcePackage: {
|
||||
name: 'opencode-linux-x64-baseline',
|
||||
integrity:
|
||||
'sha512-x4KiJk9EF7ktM18Ru5Jue4kTntxMvlhWb7tHniQGGRvY2KeoK1iIkyAFd7ri5H/fSkM22hNv/Gg1Jk6/h9IlxQ=='
|
||||
},
|
||||
entrypoint: {
|
||||
identity: 'opencode-acp',
|
||||
path: 'bin/opencode',
|
||||
sha256: 'd'.repeat(64),
|
||||
argvPrefix: ['acp']
|
||||
},
|
||||
files: [
|
||||
{ path: 'bin/opencode', size: 64, sha256: 'd'.repeat(64), mode: '0755' },
|
||||
{ path: 'licenses/opencode.txt', size: 4, sha256: 'e'.repeat(64), mode: '0644' }
|
||||
],
|
||||
licenses: [
|
||||
{
|
||||
package: 'opencode-ai',
|
||||
version: '1.18.9',
|
||||
spdx: 'MIT',
|
||||
path: 'licenses/opencode.txt'
|
||||
}
|
||||
],
|
||||
allowedEnvironmentNames: [
|
||||
'HOME', 'LANG', 'LC_ALL', 'PATH', 'TMPDIR', 'XDG_CACHE_HOME',
|
||||
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME'
|
||||
],
|
||||
protocol: { major: 1, minor: 0 },
|
||||
acpCapabilitiesDigest: `sha256:${'f'.repeat(64)}`,
|
||||
limits: {
|
||||
maximumPromptRuntimeMilliseconds: 60_000,
|
||||
maximumPromptInputBytes: 4096,
|
||||
maximumPromptOutputBytes: 1024 * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { resolve } from 'node:path'
|
||||
import { runAgentCli } from './cli'
|
||||
|
||||
export async function main(argv = process.argv.slice(2)): Promise<number> {
|
||||
return await runAgentCli(argv)
|
||||
}
|
||||
|
||||
const invokedPath = process.argv[1]
|
||||
if (
|
||||
invokedPath !== undefined &&
|
||||
import.meta.url === pathToFileURL(resolve(invokedPath)).href
|
||||
) {
|
||||
process.exitCode = await main()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export class AgentUnsupportedError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code:
|
||||
| 'platform-incompatible'
|
||||
| 'supervisor-unavailable'
|
||||
| 'peer-identity-unavailable'
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AgentUnsupportedError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
EventJournal,
|
||||
EventJournalCapacityError,
|
||||
EventJournalSequenceError
|
||||
} from './event-journal'
|
||||
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories.splice(0).map((directory) =>
|
||||
rm(directory, { recursive: true, force: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
async function createJournal(
|
||||
overrides: ConstructorParameters<typeof EventJournal>[1] = {}
|
||||
): Promise<{ journal: EventJournal; path: string }> {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-event-journal-')
|
||||
)
|
||||
directories.push(directory)
|
||||
const path = join(directory, 'events.sqlite')
|
||||
const journal = new EventJournal(path, {
|
||||
defaultStreamLimits: {
|
||||
maximumBytes: 100,
|
||||
maximumEvents: 10,
|
||||
terminalReserveBytes: 20,
|
||||
terminalReserveEvents: 2,
|
||||
maximumDurationMs: 60_000
|
||||
},
|
||||
maximumControllerBytes: 500,
|
||||
maximumDaemonBytes: 1_000,
|
||||
terminalReserveBytes: 50,
|
||||
...overrides
|
||||
})
|
||||
return { journal, path }
|
||||
}
|
||||
|
||||
function event(sequence: string) {
|
||||
return {
|
||||
controllerId: 'controller-1',
|
||||
streamId: 'run-1',
|
||||
streamEpoch: '1',
|
||||
sequence
|
||||
}
|
||||
}
|
||||
|
||||
describe('EventJournal event streams', () => {
|
||||
it('persists before replay and survives reopening', async () => {
|
||||
const { journal, path } = await createJournal()
|
||||
journal.appendEvent({
|
||||
identity: event('1'),
|
||||
eventClass: 'data',
|
||||
payload: new Uint8Array([1, 2, 3])
|
||||
})
|
||||
journal.close()
|
||||
|
||||
const reopened = new EventJournal(path, {
|
||||
maximumControllerBytes: 500,
|
||||
maximumDaemonBytes: 1_000
|
||||
})
|
||||
expect(
|
||||
reopened.replayEvents({
|
||||
controllerId: 'controller-1',
|
||||
streamId: 'run-1',
|
||||
streamEpoch: '1',
|
||||
afterSequence: '0'
|
||||
})
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
identity: event('1'),
|
||||
payload: new Uint8Array([1, 2, 3])
|
||||
})
|
||||
])
|
||||
reopened.close()
|
||||
})
|
||||
|
||||
it('requires contiguous identity and idempotently accepts exact duplicates', async () => {
|
||||
const { journal } = await createJournal()
|
||||
const input = {
|
||||
identity: event('1'),
|
||||
eventClass: 'control' as const,
|
||||
payload: new Uint8Array([4])
|
||||
}
|
||||
expect(journal.appendEvent(input).created).toBe(true)
|
||||
expect(journal.appendEvent(input).created).toBe(false)
|
||||
expect(() =>
|
||||
journal.appendEvent({
|
||||
...input,
|
||||
payload: new Uint8Array([5])
|
||||
})
|
||||
).toThrow(EventJournalSequenceError)
|
||||
expect(() =>
|
||||
journal.appendEvent({
|
||||
...input,
|
||||
identity: event('3')
|
||||
})
|
||||
).toThrow(/contiguous/iu)
|
||||
journal.close()
|
||||
})
|
||||
|
||||
it('reserves bytes for truncation/cancellation terminal events', async () => {
|
||||
const { journal } = await createJournal({
|
||||
defaultStreamLimits: {
|
||||
maximumBytes: 10,
|
||||
maximumEvents: 10,
|
||||
terminalReserveBytes: 4,
|
||||
terminalReserveEvents: 2,
|
||||
maximumDurationMs: 60_000
|
||||
},
|
||||
maximumControllerBytes: 20,
|
||||
maximumDaemonBytes: 30,
|
||||
terminalReserveBytes: 5
|
||||
})
|
||||
journal.appendEvent({
|
||||
identity: event('1'),
|
||||
eventClass: 'data',
|
||||
payload: new Uint8Array(6)
|
||||
})
|
||||
try {
|
||||
journal.appendEvent({
|
||||
identity: event('2'),
|
||||
eventClass: 'data',
|
||||
payload: new Uint8Array(1)
|
||||
})
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({
|
||||
level: 'stream',
|
||||
terminalWriteAllowed: true
|
||||
})
|
||||
}
|
||||
expect(
|
||||
journal.appendEvent({
|
||||
identity: event('2'),
|
||||
eventClass: 'terminal',
|
||||
payload: new Uint8Array(4)
|
||||
}).created
|
||||
).toBe(true)
|
||||
expect(() =>
|
||||
journal.appendEvent({
|
||||
identity: event('3'),
|
||||
eventClass: 'terminal',
|
||||
payload: new Uint8Array(1)
|
||||
})
|
||||
).toThrow(EventJournalCapacityError)
|
||||
journal.close()
|
||||
})
|
||||
|
||||
it('ACK-prunes data immediately but retains terminal records for the floor', async () => {
|
||||
let now = 1_000
|
||||
const { journal } = await createJournal({
|
||||
now: () => now,
|
||||
minimumUnacknowledgedTerminalRetentionMs: 100
|
||||
})
|
||||
journal.appendEvent({
|
||||
identity: event('1'),
|
||||
eventClass: 'data',
|
||||
payload: new Uint8Array([1])
|
||||
})
|
||||
journal.appendEvent({
|
||||
identity: event('2'),
|
||||
eventClass: 'terminal',
|
||||
payload: new Uint8Array([2])
|
||||
})
|
||||
journal.acknowledgeEvents({
|
||||
controllerId: 'controller-1',
|
||||
streamId: 'run-1',
|
||||
streamEpoch: '1',
|
||||
sequence: '2'
|
||||
})
|
||||
expect(
|
||||
journal
|
||||
.replayEvents({
|
||||
controllerId: 'controller-1',
|
||||
streamId: 'run-1',
|
||||
streamEpoch: '1',
|
||||
afterSequence: '0'
|
||||
})
|
||||
.map((item) => item.identity.sequence)
|
||||
).toEqual(['2'])
|
||||
now += 101
|
||||
expect(journal.pruneAcknowledged()).toBe(1)
|
||||
expect(
|
||||
journal.replayEvents({
|
||||
controllerId: 'controller-1',
|
||||
streamId: 'run-1',
|
||||
streamEpoch: '1',
|
||||
afterSequence: '0'
|
||||
})
|
||||
).toEqual([])
|
||||
journal.close()
|
||||
})
|
||||
|
||||
it('rejects ACKs beyond durable sequence or moving backwards', async () => {
|
||||
const { journal } = await createJournal()
|
||||
journal.appendEvent({
|
||||
identity: event('1'),
|
||||
eventClass: 'data',
|
||||
payload: new Uint8Array([1])
|
||||
})
|
||||
expect(() =>
|
||||
journal.acknowledgeEvents({
|
||||
controllerId: 'controller-1',
|
||||
streamId: 'run-1',
|
||||
streamEpoch: '1',
|
||||
sequence: '2'
|
||||
})
|
||||
).toThrow(/exceeds/iu)
|
||||
journal.acknowledgeEvents({
|
||||
controllerId: 'controller-1',
|
||||
streamId: 'run-1',
|
||||
streamEpoch: '1',
|
||||
sequence: '1'
|
||||
})
|
||||
expect(() =>
|
||||
journal.acknowledgeEvents({
|
||||
controllerId: 'controller-1',
|
||||
streamId: 'run-1',
|
||||
streamEpoch: '1',
|
||||
sequence: '0'
|
||||
})
|
||||
).toThrow()
|
||||
journal.close()
|
||||
})
|
||||
|
||||
it('migrates legacy streams and reconstructs durable usage counters', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-event-journal-legacy-')
|
||||
)
|
||||
directories.push(directory)
|
||||
const path = join(directory, 'events.sqlite')
|
||||
const legacy = new DatabaseSync(path)
|
||||
legacy.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE event_streams (
|
||||
controller_id TEXT NOT NULL,
|
||||
stream_id TEXT NOT NULL,
|
||||
stream_epoch INTEGER NOT NULL,
|
||||
last_sequence INTEGER NOT NULL DEFAULT 0,
|
||||
ack_sequence INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
maximum_bytes INTEGER NOT NULL,
|
||||
maximum_events INTEGER NOT NULL,
|
||||
terminal_reserve_bytes INTEGER NOT NULL,
|
||||
terminal_reserve_events INTEGER NOT NULL,
|
||||
maximum_duration_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (controller_id, stream_id, stream_epoch)
|
||||
) STRICT;
|
||||
CREATE TABLE journal_events (
|
||||
controller_id TEXT NOT NULL,
|
||||
stream_id TEXT NOT NULL,
|
||||
stream_epoch INTEGER NOT NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
event_class TEXT NOT NULL,
|
||||
payload BLOB NOT NULL,
|
||||
byte_length INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (
|
||||
controller_id, stream_id, stream_epoch, sequence
|
||||
),
|
||||
FOREIGN KEY (controller_id, stream_id, stream_epoch)
|
||||
REFERENCES event_streams(
|
||||
controller_id, stream_id, stream_epoch
|
||||
) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
`)
|
||||
legacy
|
||||
.prepare(
|
||||
`INSERT INTO event_streams VALUES (
|
||||
'controller-1', 'run-1', 1, 1, 0, 1000,
|
||||
10, 10, 2, 2, 60000
|
||||
)`
|
||||
)
|
||||
.run()
|
||||
legacy
|
||||
.prepare(
|
||||
`INSERT INTO journal_events VALUES (
|
||||
'controller-1', 'run-1', 1, 1, 'data', ?, 6, 1000
|
||||
)`
|
||||
)
|
||||
.run(new Uint8Array(6))
|
||||
legacy.close()
|
||||
|
||||
const migrated = new EventJournal(path, {
|
||||
defaultStreamLimits: {
|
||||
maximumBytes: 10,
|
||||
maximumEvents: 10,
|
||||
terminalReserveBytes: 2,
|
||||
terminalReserveEvents: 2,
|
||||
maximumDurationMs: 60_000
|
||||
},
|
||||
maximumControllerBytes: 20,
|
||||
maximumDaemonBytes: 30,
|
||||
terminalReserveBytes: 2
|
||||
})
|
||||
expect(() =>
|
||||
migrated.appendEvent({
|
||||
identity: event('2'),
|
||||
eventClass: 'data',
|
||||
payload: new Uint8Array(3)
|
||||
})
|
||||
).toThrow(EventJournalCapacityError)
|
||||
migrated.close()
|
||||
})
|
||||
|
||||
it('repairs corrupted usage counters before enforcing quotas', async () => {
|
||||
const { journal, path } = await createJournal({
|
||||
defaultStreamLimits: {
|
||||
maximumBytes: 10,
|
||||
maximumEvents: 10,
|
||||
terminalReserveBytes: 2,
|
||||
terminalReserveEvents: 2,
|
||||
maximumDurationMs: 60_000
|
||||
}
|
||||
})
|
||||
journal.appendEvent({
|
||||
identity: event('1'),
|
||||
eventClass: 'data',
|
||||
payload: new Uint8Array(6)
|
||||
})
|
||||
journal.close()
|
||||
const corrupt = new DatabaseSync(path)
|
||||
corrupt.exec(`
|
||||
UPDATE event_streams
|
||||
SET stored_bytes = 0, stored_events = 0;
|
||||
UPDATE event_journal_usage SET total_bytes = 0;
|
||||
DELETE FROM event_controller_usage;
|
||||
`)
|
||||
corrupt.close()
|
||||
|
||||
const repaired = new EventJournal(path, {
|
||||
maximumControllerBytes: 500,
|
||||
maximumDaemonBytes: 1_000
|
||||
})
|
||||
expect(() =>
|
||||
repaired.appendEvent({
|
||||
identity: event('2'),
|
||||
eventClass: 'data',
|
||||
payload: new Uint8Array(3)
|
||||
})
|
||||
).toThrow(EventJournalCapacityError)
|
||||
repaired.close()
|
||||
})
|
||||
|
||||
it('accounts for event and ACP bytes together and releases pruned quota', async () => {
|
||||
const { journal } = await createJournal({
|
||||
maximumControllerBytes: 6,
|
||||
maximumDaemonBytes: 8,
|
||||
terminalReserveBytes: 0
|
||||
})
|
||||
journal.appendEvent({
|
||||
identity: event('1'),
|
||||
eventClass: 'data',
|
||||
payload: new Uint8Array(4)
|
||||
})
|
||||
expect(() =>
|
||||
journal.appendAcpFrame({
|
||||
controllerId: 'controller-1',
|
||||
bindingId: 'binding-controller-limit',
|
||||
channelEpoch: '1',
|
||||
direction: 'runtime-to-main',
|
||||
sequence: '1',
|
||||
payload: new Uint8Array(3)
|
||||
})
|
||||
).toThrow(
|
||||
expect.objectContaining({ level: 'controller' })
|
||||
)
|
||||
journal.appendAcpFrame({
|
||||
controllerId: 'controller-2',
|
||||
bindingId: 'binding-daemon-limit',
|
||||
channelEpoch: '1',
|
||||
direction: 'runtime-to-main',
|
||||
sequence: '1',
|
||||
payload: new Uint8Array(4)
|
||||
})
|
||||
expect(() =>
|
||||
journal.appendAcpFrame({
|
||||
controllerId: 'controller-2',
|
||||
bindingId: 'binding-daemon-limit',
|
||||
channelEpoch: '1',
|
||||
direction: 'runtime-to-main',
|
||||
sequence: '2',
|
||||
payload: new Uint8Array(1)
|
||||
})
|
||||
).toThrow(expect.objectContaining({ level: 'daemon' }))
|
||||
|
||||
journal.acknowledgeEvents({
|
||||
controllerId: 'controller-1',
|
||||
streamId: 'run-1',
|
||||
streamEpoch: '1',
|
||||
sequence: '1'
|
||||
})
|
||||
expect(
|
||||
journal.appendAcpFrame({
|
||||
controllerId: 'controller-2',
|
||||
bindingId: 'binding-daemon-limit',
|
||||
channelEpoch: '1',
|
||||
direction: 'runtime-to-main',
|
||||
sequence: '2',
|
||||
payload: new Uint8Array(1)
|
||||
}).created
|
||||
).toBe(true)
|
||||
journal.close()
|
||||
})
|
||||
|
||||
it('retires fully ACKed terminal stream metadata without permitting reuse', async () => {
|
||||
const { journal, path } = await createJournal({
|
||||
minimumUnacknowledgedTerminalRetentionMs: 0
|
||||
})
|
||||
journal.appendEvent({
|
||||
identity: event('1'),
|
||||
eventClass: 'terminal',
|
||||
payload: new Uint8Array([1])
|
||||
})
|
||||
journal.acknowledgeEvents({
|
||||
controllerId: 'controller-1',
|
||||
streamId: 'run-1',
|
||||
streamEpoch: '1',
|
||||
sequence: '1'
|
||||
})
|
||||
expect(() =>
|
||||
journal.acknowledgeEvents({
|
||||
controllerId: 'controller-1',
|
||||
streamId: 'run-1',
|
||||
streamEpoch: '1',
|
||||
sequence: '1'
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(() =>
|
||||
journal.appendEvent({
|
||||
identity: event('1'),
|
||||
eventClass: 'terminal',
|
||||
payload: new Uint8Array([1])
|
||||
})
|
||||
).toThrow(EventJournalSequenceError)
|
||||
journal.close()
|
||||
|
||||
const check = new DatabaseSync(path)
|
||||
expect(
|
||||
(
|
||||
check
|
||||
.prepare('SELECT COUNT(*) AS count FROM event_streams')
|
||||
.get() as { count: number }
|
||||
).count
|
||||
).toBe(0)
|
||||
expect(
|
||||
(
|
||||
check
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM retired_event_streams'
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
).toBe(1)
|
||||
check.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventJournal ACP directional journals', () => {
|
||||
it('enforces ACP frame caps before insert without advancing the journal cursor', async () => {
|
||||
const { journal } = await createJournal({
|
||||
maximumAcpChannelFrames: 1,
|
||||
maximumAcpControllerFrames: 2,
|
||||
maximumAcpDaemonFrames: 3
|
||||
})
|
||||
const append = (
|
||||
controllerId: string,
|
||||
bindingId: string,
|
||||
sequence = '1'
|
||||
) =>
|
||||
journal.appendAcpFrame({
|
||||
controllerId,
|
||||
bindingId,
|
||||
channelEpoch: '1',
|
||||
direction: 'runtime-to-main',
|
||||
sequence,
|
||||
payload: Uint8Array.of(1)
|
||||
})
|
||||
append('controller-1', 'binding-1')
|
||||
expect(() => append('controller-1', 'binding-1', '2')).toThrow(
|
||||
expect.objectContaining({ level: 'stream' })
|
||||
)
|
||||
expect(
|
||||
journal.getAcpCursor(
|
||||
'binding-1',
|
||||
'1',
|
||||
'runtime-to-main'
|
||||
)?.journaledSequence
|
||||
).toBe('1')
|
||||
append('controller-1', 'binding-2')
|
||||
expect(() => append('controller-1', 'binding-3')).toThrow(
|
||||
expect.objectContaining({ level: 'controller' })
|
||||
)
|
||||
append('controller-2', 'binding-4')
|
||||
expect(() => append('controller-2', 'binding-5')).toThrow(
|
||||
expect.objectContaining({ level: 'daemon' })
|
||||
)
|
||||
expect(
|
||||
journal.getAcpCursor(
|
||||
'binding-5',
|
||||
'1',
|
||||
'runtime-to-main'
|
||||
)
|
||||
).toBeUndefined()
|
||||
journal.close()
|
||||
})
|
||||
|
||||
it('transactionally prunes delivered outbound frames without retiring cursor history', async () => {
|
||||
const { journal } = await createJournal()
|
||||
journal.appendAcpFrame({
|
||||
controllerId: 'controller-1',
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
direction: 'main-to-runtime',
|
||||
sequence: '1',
|
||||
payload: new TextEncoder().encode('{"method":"session/prompt"}\n')
|
||||
})
|
||||
expect(
|
||||
journal.getAcpCursor(
|
||||
'binding-1',
|
||||
'9',
|
||||
'main-to-runtime'
|
||||
)
|
||||
).toEqual({
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
direction: 'main-to-runtime',
|
||||
journaledSequence: '1',
|
||||
deliveredSequence: '0',
|
||||
mainAckSequence: '0'
|
||||
})
|
||||
journal.markAcpDelivered({
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
sequence: '1'
|
||||
})
|
||||
expect(
|
||||
journal.getAcpCursor(
|
||||
'binding-1',
|
||||
'9',
|
||||
'main-to-runtime'
|
||||
)?.deliveredSequence
|
||||
).toBe('1')
|
||||
expect(
|
||||
journal.replayAcpFrames({
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
direction: 'main-to-runtime',
|
||||
afterSequence: '0'
|
||||
})
|
||||
).toHaveLength(0)
|
||||
expect(
|
||||
journal.pruneReconciledAcpOutbound({
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
throughSequence: '1'
|
||||
})
|
||||
).toBe(0)
|
||||
expect(
|
||||
journal.appendAcpFrame({
|
||||
controllerId: 'controller-1',
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '9',
|
||||
direction: 'main-to-runtime',
|
||||
sequence: '2',
|
||||
payload: new TextEncoder().encode('{"method":"session/load"}\n')
|
||||
}).created
|
||||
).toBe(true)
|
||||
journal.close()
|
||||
})
|
||||
|
||||
it('releases outbound frame capacity as each non-replayable input is delivered', async () => {
|
||||
const { journal } = await createJournal({
|
||||
maximumAcpChannelFrames: 1,
|
||||
maximumAcpControllerFrames: 1,
|
||||
maximumAcpDaemonFrames: 1
|
||||
})
|
||||
const identity = {
|
||||
controllerId: 'controller-1',
|
||||
bindingId: 'binding-input-capacity',
|
||||
channelEpoch: '7',
|
||||
direction: 'main-to-runtime' as const
|
||||
}
|
||||
journal.appendAcpFrame({
|
||||
...identity,
|
||||
sequence: '1',
|
||||
payload: Uint8Array.of(1)
|
||||
})
|
||||
journal.markAcpDelivered({
|
||||
bindingId: identity.bindingId,
|
||||
channelEpoch: identity.channelEpoch,
|
||||
sequence: '1'
|
||||
})
|
||||
expect(
|
||||
journal.appendAcpFrame({
|
||||
...identity,
|
||||
sequence: '2',
|
||||
payload: Uint8Array.of(2)
|
||||
}).created
|
||||
).toBe(true)
|
||||
expect(
|
||||
journal.getAcpCursor(
|
||||
identity.bindingId,
|
||||
identity.channelEpoch,
|
||||
identity.direction
|
||||
)
|
||||
).toMatchObject({
|
||||
journaledSequence: '2',
|
||||
deliveredSequence: '1'
|
||||
})
|
||||
journal.close()
|
||||
})
|
||||
|
||||
it('tracks inbound journaled and Main ACK cursors then prunes ACKed bytes', async () => {
|
||||
const { journal } = await createJournal()
|
||||
for (const sequence of ['1', '2']) {
|
||||
journal.appendAcpFrame({
|
||||
controllerId: 'controller-1',
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '1',
|
||||
direction: 'runtime-to-main',
|
||||
sequence,
|
||||
payload: new Uint8Array([Number(sequence)])
|
||||
})
|
||||
}
|
||||
journal.acknowledgeAcpFromMain({
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '1',
|
||||
sequence: '1'
|
||||
})
|
||||
expect(
|
||||
journal.getAcpCursor(
|
||||
'binding-1',
|
||||
'1',
|
||||
'runtime-to-main'
|
||||
)?.mainAckSequence
|
||||
).toBe('1')
|
||||
expect(
|
||||
journal.replayAcpFrames({
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '1',
|
||||
direction: 'runtime-to-main',
|
||||
afterSequence: '0'
|
||||
})
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
sequence: '2',
|
||||
payload: new Uint8Array([2])
|
||||
})
|
||||
])
|
||||
expect(() =>
|
||||
journal.acknowledgeAcpFromMain({
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '1',
|
||||
sequence: '3'
|
||||
})
|
||||
).toThrow(/exceeds/iu)
|
||||
journal.close()
|
||||
})
|
||||
|
||||
it('keeps an active inbound channel appendable after ACK reaches its current tail', async () => {
|
||||
const { journal } = await createJournal()
|
||||
const frame = {
|
||||
controllerId: 'controller-1',
|
||||
bindingId: 'binding-streaming',
|
||||
channelEpoch: '4',
|
||||
direction: 'runtime-to-main' as const
|
||||
}
|
||||
journal.appendAcpFrame({
|
||||
...frame,
|
||||
sequence: '1',
|
||||
payload: new Uint8Array([1])
|
||||
})
|
||||
journal.acknowledgeAcpFromMain({
|
||||
bindingId: frame.bindingId,
|
||||
channelEpoch: frame.channelEpoch,
|
||||
sequence: '1'
|
||||
})
|
||||
|
||||
expect(
|
||||
journal.getAcpCursor(
|
||||
frame.bindingId,
|
||||
frame.channelEpoch,
|
||||
frame.direction
|
||||
)
|
||||
).toEqual({
|
||||
bindingId: frame.bindingId,
|
||||
channelEpoch: frame.channelEpoch,
|
||||
direction: frame.direction,
|
||||
journaledSequence: '1',
|
||||
deliveredSequence: '0',
|
||||
mainAckSequence: '1'
|
||||
})
|
||||
expect(
|
||||
journal.appendAcpFrame({
|
||||
...frame,
|
||||
sequence: '2',
|
||||
payload: new Uint8Array([2])
|
||||
}).created
|
||||
).toBe(true)
|
||||
expect(
|
||||
journal.replayAcpFrames({
|
||||
bindingId: frame.bindingId,
|
||||
channelEpoch: frame.channelEpoch,
|
||||
direction: frame.direction,
|
||||
afterSequence: '1'
|
||||
})
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
sequence: '2',
|
||||
payload: new Uint8Array([2])
|
||||
})
|
||||
])
|
||||
journal.close()
|
||||
})
|
||||
|
||||
it('never reuses an ACP epoch/direction sequence with other bytes', async () => {
|
||||
const { journal } = await createJournal()
|
||||
const input = {
|
||||
controllerId: 'controller-1',
|
||||
bindingId: 'binding-1',
|
||||
channelEpoch: '2',
|
||||
direction: 'runtime-to-main' as const,
|
||||
sequence: '1',
|
||||
payload: new Uint8Array([1])
|
||||
}
|
||||
expect(journal.appendAcpFrame(input).created).toBe(true)
|
||||
expect(journal.appendAcpFrame(input).created).toBe(false)
|
||||
expect(() =>
|
||||
journal.appendAcpFrame({
|
||||
...input,
|
||||
payload: new Uint8Array([2])
|
||||
})
|
||||
).toThrow(EventJournalSequenceError)
|
||||
journal.close()
|
||||
})
|
||||
|
||||
it('retires a binding atomically, discards terminal frames, and preserves epoch safety', async () => {
|
||||
const { journal, path } = await createJournal()
|
||||
const input = {
|
||||
controllerId: 'controller-1',
|
||||
bindingId: 'binding-retired',
|
||||
channelEpoch: '3',
|
||||
direction: 'runtime-to-main' as const,
|
||||
sequence: '1',
|
||||
payload: new Uint8Array([1])
|
||||
}
|
||||
journal.appendAcpFrame(input)
|
||||
journal.appendAcpFrame({
|
||||
...input,
|
||||
direction: 'main-to-runtime',
|
||||
payload: new Uint8Array([2])
|
||||
})
|
||||
expect(journal.retireAcpBinding({
|
||||
controllerId: input.controllerId,
|
||||
bindingId: input.bindingId,
|
||||
channelEpoch: input.channelEpoch
|
||||
})).toBe(2)
|
||||
expect(
|
||||
journal.getAcpCursor(
|
||||
input.bindingId,
|
||||
input.channelEpoch,
|
||||
input.direction
|
||||
)
|
||||
).toBeUndefined()
|
||||
expect(() =>
|
||||
journal.appendAcpFrame({
|
||||
...input,
|
||||
sequence: '2',
|
||||
payload: new Uint8Array([2])
|
||||
})
|
||||
).toThrow(EventJournalSequenceError)
|
||||
expect(journal.retireAcpBinding({
|
||||
controllerId: input.controllerId,
|
||||
bindingId: input.bindingId,
|
||||
channelEpoch: input.channelEpoch
|
||||
})).toBe(0)
|
||||
expect(() => journal.appendAcpFrame(input)).toThrow(
|
||||
EventJournalSequenceError
|
||||
)
|
||||
expect(() =>
|
||||
journal.appendAcpFrame({
|
||||
...input,
|
||||
direction: 'main-to-runtime',
|
||||
sequence: '1'
|
||||
})
|
||||
).toThrow(EventJournalSequenceError)
|
||||
journal.close()
|
||||
|
||||
const check = new DatabaseSync(path)
|
||||
expect(
|
||||
(
|
||||
check
|
||||
.prepare('SELECT COUNT(*) AS count FROM acp_channels')
|
||||
.get() as { count: number }
|
||||
).count
|
||||
).toBe(0)
|
||||
expect(
|
||||
(
|
||||
check
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS count FROM retired_acp_channels'
|
||||
)
|
||||
.get() as { count: number }
|
||||
).count
|
||||
).toBe(2)
|
||||
expect(
|
||||
(
|
||||
check
|
||||
.prepare('SELECT COUNT(*) AS count FROM acp_frames')
|
||||
.get() as { count: number }
|
||||
).count
|
||||
).toBe(0)
|
||||
expect(
|
||||
(
|
||||
check
|
||||
.prepare(
|
||||
'SELECT total_bytes FROM event_journal_usage WHERE id = 1'
|
||||
)
|
||||
.get() as { total_bytes: number }
|
||||
).total_bytes
|
||||
).toBe(0)
|
||||
expect(
|
||||
(
|
||||
check
|
||||
.prepare(
|
||||
`SELECT total_bytes FROM event_controller_usage
|
||||
WHERE controller_id = 'controller-1'`
|
||||
)
|
||||
.get() as { total_bytes: number }
|
||||
).total_bytes
|
||||
).toBe(0)
|
||||
check.close()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
export * from './bundle-file-verification'
|
||||
export * from './channel-state'
|
||||
export * from './cli'
|
||||
export * from './controller-registry'
|
||||
export * from './daemon'
|
||||
export * from './detached-agent-lifecycle'
|
||||
export * from './direct-linux-stdio-process-owner'
|
||||
export * from './errors'
|
||||
export * from './entry'
|
||||
export * from './event-journal'
|
||||
export * from './installation-challenge'
|
||||
export * from './installation-registry'
|
||||
export * from './installed-bundle-verifier'
|
||||
export * from './managed-paths'
|
||||
export * from './linux-process-identity'
|
||||
export * from './model-bridge-broker'
|
||||
export * from './model-bridge-client'
|
||||
export * from './model-bridge-helper'
|
||||
export * from './opencode-runtime-profile'
|
||||
export * from './private-endpoint'
|
||||
export * from './protocol-server'
|
||||
export * from './runtime-bundle-registry'
|
||||
export * from './runtime-bundle-verifier'
|
||||
export * from './runtime-composition'
|
||||
export * from './runtime-acp-backend'
|
||||
export * from './runtime-owner-registry'
|
||||
export * from './runtime-process-identity'
|
||||
export * from './structured-command-runner'
|
||||
export * from './workspace-git-service'
|
||||
export * from './workspace-path-access'
|
||||
export * from './workspace-protocol-methods'
|
||||
export * from './workspace-registry'
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
createHmac,
|
||||
randomBytes,
|
||||
timingSafeEqual
|
||||
} from 'node:crypto'
|
||||
|
||||
const CHALLENGE_DOMAIN = 'goodbuddy-agent-installation-challenge-v1\0'
|
||||
|
||||
export type InstallationChallenge = {
|
||||
serverNonce: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export class InstallationChallengeVerifier {
|
||||
readonly #secret: Buffer
|
||||
readonly #now: () => number
|
||||
readonly #lifetimeMs: number
|
||||
readonly #pending = new Map<string, number>()
|
||||
|
||||
constructor(
|
||||
secret: Uint8Array,
|
||||
options: { now?: () => number; lifetimeMs?: number } = {}
|
||||
) {
|
||||
if (secret.byteLength < 32) {
|
||||
throw new RangeError('Installation challenge secret is too short')
|
||||
}
|
||||
this.#secret = Buffer.from(secret)
|
||||
this.#now = options.now ?? Date.now
|
||||
this.#lifetimeMs = options.lifetimeMs ?? 30_000
|
||||
if (!Number.isSafeInteger(this.#lifetimeMs) || this.#lifetimeMs < 1) {
|
||||
throw new RangeError('Invalid installation challenge lifetime')
|
||||
}
|
||||
}
|
||||
|
||||
issue(): InstallationChallenge {
|
||||
this.#purgeExpired()
|
||||
const serverNonce = randomBytes(32).toString('hex')
|
||||
const expiresAt = this.#now() + this.#lifetimeMs
|
||||
this.#pending.set(serverNonce, expiresAt)
|
||||
return { serverNonce, expiresAt }
|
||||
}
|
||||
|
||||
createResponse(input: {
|
||||
serverNonce: string
|
||||
clientNonce: string
|
||||
controllerId: string
|
||||
}): string {
|
||||
return calculateResponse(this.#secret, input)
|
||||
}
|
||||
|
||||
verify(input: {
|
||||
serverNonce: string
|
||||
clientNonce: string
|
||||
controllerId: string
|
||||
response: string
|
||||
}): boolean {
|
||||
const expiresAt = this.#pending.get(input.serverNonce)
|
||||
this.#pending.delete(input.serverNonce)
|
||||
if (expiresAt === undefined || expiresAt < this.#now()) {
|
||||
return false
|
||||
}
|
||||
const expected = Buffer.from(
|
||||
calculateResponse(this.#secret, input),
|
||||
'base64url'
|
||||
)
|
||||
let received: Buffer
|
||||
try {
|
||||
received = Buffer.from(input.response, 'base64url')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
expected.byteLength === received.byteLength &&
|
||||
timingSafeEqual(expected, received)
|
||||
)
|
||||
}
|
||||
|
||||
#purgeExpired(): void {
|
||||
const now = this.#now()
|
||||
for (const [nonce, expiresAt] of this.#pending) {
|
||||
if (expiresAt < now) {
|
||||
this.#pending.delete(nonce)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calculateResponse(
|
||||
secret: Uint8Array,
|
||||
input: {
|
||||
serverNonce: string
|
||||
clientNonce: string
|
||||
controllerId: string
|
||||
}
|
||||
): string {
|
||||
for (const value of [
|
||||
input.serverNonce,
|
||||
input.clientNonce,
|
||||
input.controllerId
|
||||
]) {
|
||||
if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(value)) {
|
||||
throw new Error('Invalid installation challenge field')
|
||||
}
|
||||
}
|
||||
return createHmac('sha256', secret)
|
||||
.update(CHALLENGE_DOMAIN)
|
||||
.update(input.serverNonce)
|
||||
.update('\0')
|
||||
.update(input.clientNonce)
|
||||
.update('\0')
|
||||
.update(input.controllerId)
|
||||
.digest('base64url')
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { InstallationRegistry } from './installation-registry'
|
||||
import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier'
|
||||
|
||||
const temporaryPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of temporaryPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('Agent installation registry', () => {
|
||||
it('stages and atomically promotes an exact signed installation', () => {
|
||||
const { registry, storagePath } = createRegistry()
|
||||
const first = verified('install-a', '0.11.0', 'a')
|
||||
const second = verified('install-b', '0.11.1', 'b')
|
||||
|
||||
registry.stageCandidate(first)
|
||||
expect(registry.snapshot()).toMatchObject({
|
||||
candidate: { installationId: 'install-a' }
|
||||
})
|
||||
registry.promoteCandidate('install-a')
|
||||
expect(registry.snapshot()).toMatchObject({
|
||||
current: { installationId: 'install-a' }
|
||||
})
|
||||
expect(registry.snapshot().candidate).toBeUndefined()
|
||||
|
||||
registry.stageCandidate(second)
|
||||
registry.promoteCandidate('install-b')
|
||||
expect(registry.snapshot()).toEqual({
|
||||
formatVersion: 1,
|
||||
current: expect.objectContaining({
|
||||
installationId: 'install-b',
|
||||
agentVersion: '0.11.1'
|
||||
})
|
||||
})
|
||||
expect(
|
||||
new InstallationRegistry({ storagePath }).snapshot()
|
||||
).toEqual(registry.snapshot())
|
||||
})
|
||||
|
||||
it('reuses the exact current identity without staging it again', () => {
|
||||
const { registry } = createRegistry()
|
||||
const current = verified('install-a', '0.11.1', 'a')
|
||||
registry.stageCandidate(current)
|
||||
registry.promoteCandidate('install-a')
|
||||
|
||||
expect(registry.stageCandidate(current)).toEqual(
|
||||
registry.snapshot().current
|
||||
)
|
||||
expect(registry.snapshot().candidate).toBeUndefined()
|
||||
expect(() =>
|
||||
registry.assertVerifiedRole(current, ['current'])
|
||||
).not.toThrow()
|
||||
expect(
|
||||
registry.assertRegisteredRole('install-a', ['current'])
|
||||
).toMatchObject({
|
||||
installationId: 'install-a',
|
||||
manifestSha256: 'a'.repeat(64)
|
||||
})
|
||||
expect(() =>
|
||||
registry.assertRegisteredRole('missing', [
|
||||
'current',
|
||||
'candidate'
|
||||
])
|
||||
).toThrow('authorized registry role')
|
||||
})
|
||||
|
||||
it('replaces a stale candidate with the requested signed identity', () => {
|
||||
const { registry } = createRegistry()
|
||||
registry.stageCandidate(verified('install-a', '0.11.0', 'a'))
|
||||
registry.stageCandidate(verified('install-b', '0.11.1', 'b'))
|
||||
|
||||
expect(registry.snapshot().candidate?.installationId).toBe(
|
||||
'install-b'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves legacy bytes until the registry is mutated', () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const storagePath = resolve(root, 'registry.json')
|
||||
const current = {
|
||||
installationId: 'install-a',
|
||||
productVersion: '0.11.0',
|
||||
agentVersion: '0.11.0',
|
||||
releaseSequence: 4,
|
||||
manifestSha256: 'a'.repeat(64),
|
||||
binaryDigest: `sha256:${'a'.repeat(64)}`,
|
||||
arch: 'x64',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
signingKeyId: 'test-key',
|
||||
previouslyVerified: true
|
||||
}
|
||||
const legacyBytes = `${JSON.stringify({
|
||||
formatVersion: 1,
|
||||
minimumTrustedReleaseSequence: 1,
|
||||
current,
|
||||
draining: []
|
||||
}, null, 2)}\n`
|
||||
writeFileSync(storagePath, legacyBytes, { mode: 0o600 })
|
||||
|
||||
const registry = new InstallationRegistry({ storagePath })
|
||||
expect(registry.snapshot()).toEqual({
|
||||
formatVersion: 1,
|
||||
current: {
|
||||
installationId: 'install-a',
|
||||
agentVersion: '0.11.0',
|
||||
manifestSha256: 'a'.repeat(64),
|
||||
arch: 'x64',
|
||||
protocol: { major: 1, minor: 0 }
|
||||
}
|
||||
})
|
||||
expect(readFileSync(storagePath, 'utf8')).toBe(legacyBytes)
|
||||
|
||||
registry.stageCandidate(
|
||||
verified('install-b', '0.11.1', 'b')
|
||||
)
|
||||
expect(readFileSync(storagePath, 'utf8')).not.toContain(
|
||||
'releaseSequence'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed on corrupt and concurrent state', () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const storagePath = resolve(root, 'registry.json')
|
||||
writeFileSync(
|
||||
storagePath,
|
||||
'{"formatVersion":1,"unknown":true}',
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
expect(() => new InstallationRegistry({ storagePath })).toThrow(
|
||||
'corrupt'
|
||||
)
|
||||
writeFileSync(storagePath, '{}', { mode: 0o644 })
|
||||
expect(() => new InstallationRegistry({ storagePath })).toThrow(
|
||||
'corrupt'
|
||||
)
|
||||
|
||||
rmSync(storagePath)
|
||||
const first = new InstallationRegistry({ storagePath })
|
||||
const concurrent = new InstallationRegistry({ storagePath })
|
||||
first.stageCandidate(verified('install-a', '0.11.0', 'a'))
|
||||
expect(() =>
|
||||
concurrent.stageCandidate(
|
||||
verified('install-b', '0.11.1', 'b')
|
||||
)
|
||||
).toThrow('changed concurrently')
|
||||
expect(readFileSync(storagePath, 'utf8')).toContain('install-a')
|
||||
})
|
||||
})
|
||||
|
||||
function createRegistry(): {
|
||||
registry: InstallationRegistry
|
||||
storagePath: string
|
||||
} {
|
||||
const root = privateTemporaryDirectory()
|
||||
const storagePath = resolve(root, 'registry.json')
|
||||
return {
|
||||
registry: new InstallationRegistry({ storagePath }),
|
||||
storagePath
|
||||
}
|
||||
}
|
||||
|
||||
function privateTemporaryDirectory(): string {
|
||||
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-registry-'))
|
||||
temporaryPaths.push(path)
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(path, 0o700)
|
||||
}
|
||||
return resolve(path)
|
||||
}
|
||||
|
||||
function verified(
|
||||
installationId: string,
|
||||
agentVersion: string,
|
||||
digestCharacter: string
|
||||
): VerifiedInstalledAgentBundle {
|
||||
const manifestSha256 = digestCharacter.repeat(64)
|
||||
return {
|
||||
installationId,
|
||||
installationDirectory: `/agent/${installationId}`,
|
||||
executablePath: `/agent/${installationId}/goodbuddy-agent`,
|
||||
manifestSha256,
|
||||
binaryDigest: `sha256:${manifestSha256}`,
|
||||
manifest: {
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
agentVersion,
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
protocol: { major: 1, minor: 0 },
|
||||
signingKeyId: 'test-key',
|
||||
entrypoint: {
|
||||
path: 'goodbuddy-agent',
|
||||
runtimePath: 'node',
|
||||
scriptPath: 'lib/agent.cjs'
|
||||
},
|
||||
files: [
|
||||
{
|
||||
path: 'goodbuddy-agent',
|
||||
size: 1,
|
||||
sha256: 'a'.repeat(64),
|
||||
mode: '0755'
|
||||
},
|
||||
{
|
||||
path: 'node',
|
||||
size: 1,
|
||||
sha256: 'b'.repeat(64),
|
||||
mode: '0755'
|
||||
},
|
||||
{
|
||||
path: 'lib/agent.cjs',
|
||||
size: 1,
|
||||
sha256: 'c'.repeat(64),
|
||||
mode: '0644'
|
||||
},
|
||||
{
|
||||
path: 'licenses/license.txt',
|
||||
size: 1,
|
||||
sha256: 'd'.repeat(64),
|
||||
mode: '0644'
|
||||
}
|
||||
],
|
||||
licenses: [
|
||||
{
|
||||
package: 'GoodBuddy',
|
||||
version: agentVersion,
|
||||
spdx: '0BSD',
|
||||
path: 'licenses/license.txt'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import {
|
||||
installationRegistryEntrySchema,
|
||||
installationRegistryIdSchema,
|
||||
installationRegistryStateSchema,
|
||||
parseInstallationRegistryState,
|
||||
type InstallationRegistryEntry,
|
||||
type InstallationRegistryState
|
||||
} from '../shared/remote-environment-registry-contracts'
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
assertPrivateRegularFile,
|
||||
writePrivateFileAtomic
|
||||
} from './managed-paths'
|
||||
import type { VerifiedInstalledAgentBundle } from './installed-bundle-verifier'
|
||||
|
||||
export {
|
||||
installationRegistryEntrySchema,
|
||||
installationRegistryIdSchema,
|
||||
installationRegistryStateSchema
|
||||
}
|
||||
export type {
|
||||
InstallationRegistryEntry,
|
||||
InstallationRegistryState
|
||||
}
|
||||
|
||||
export class InstallationRegistry {
|
||||
readonly #storagePath: string
|
||||
#state: InstallationRegistryState
|
||||
#needsCanonicalization = false
|
||||
|
||||
constructor(options: { storagePath: string }) {
|
||||
this.#storagePath = resolve(options.storagePath)
|
||||
ensurePrivateDirectory(dirname(this.#storagePath))
|
||||
this.#state = this.#load()
|
||||
}
|
||||
|
||||
snapshot(): InstallationRegistryState {
|
||||
return structuredClone(this.#state)
|
||||
}
|
||||
|
||||
stageCandidate(
|
||||
verified: VerifiedInstalledAgentBundle
|
||||
): InstallationRegistryEntry {
|
||||
const entry = entryFromVerified(verified)
|
||||
if (
|
||||
this.#state.current !== undefined &&
|
||||
entryEquals(this.#state.current, entry)
|
||||
) {
|
||||
this.#canonicalizeIfNeeded()
|
||||
return this.#state.current
|
||||
}
|
||||
if (
|
||||
this.#state.candidate !== undefined &&
|
||||
entryEquals(this.#state.candidate, entry)
|
||||
) {
|
||||
this.#canonicalizeIfNeeded()
|
||||
return this.#state.candidate
|
||||
}
|
||||
try {
|
||||
this.#commit({ ...this.#state, candidate: entry })
|
||||
} catch (error) {
|
||||
if (!isConcurrentRegistryError(error)) {
|
||||
throw error
|
||||
}
|
||||
this.#state = this.#load()
|
||||
const converged = [
|
||||
this.#state.current,
|
||||
this.#state.candidate
|
||||
].find(
|
||||
(candidate) =>
|
||||
candidate !== undefined && entryEquals(candidate, entry)
|
||||
)
|
||||
if (converged === undefined) {
|
||||
throw error
|
||||
}
|
||||
return converged
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
promoteCandidate(
|
||||
installationIdInput: string
|
||||
): InstallationRegistryState {
|
||||
const installationId = installationRegistryIdSchema.parse(
|
||||
installationIdInput
|
||||
)
|
||||
const candidate = this.#state.candidate
|
||||
if (
|
||||
candidate === undefined ||
|
||||
candidate.installationId !== installationId
|
||||
) {
|
||||
throw new Error('Agent candidate is not staged for promotion')
|
||||
}
|
||||
this.#commit({
|
||||
formatVersion: 1,
|
||||
current: candidate
|
||||
})
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
assertVerifiedRole(
|
||||
verified: VerifiedInstalledAgentBundle,
|
||||
roles: readonly ('current' | 'candidate')[]
|
||||
): InstallationRegistryEntry {
|
||||
const entry = entryFromVerified(verified)
|
||||
const registered = this.assertRegisteredRole(
|
||||
entry.installationId,
|
||||
roles
|
||||
)
|
||||
if (!entryEquals(registered, entry)) {
|
||||
throw new Error(
|
||||
'Verified Agent installation does not match its authorized registry role'
|
||||
)
|
||||
}
|
||||
return registered
|
||||
}
|
||||
|
||||
assertRegisteredRole(
|
||||
installationIdInput: string,
|
||||
roles: readonly ('current' | 'candidate')[]
|
||||
): InstallationRegistryEntry {
|
||||
const installationId = installationRegistryIdSchema.parse(
|
||||
installationIdInput
|
||||
)
|
||||
const registered = roles
|
||||
.map((role) => this.#state[role])
|
||||
.find(
|
||||
(candidate) =>
|
||||
candidate?.installationId === installationId
|
||||
)
|
||||
if (registered === undefined) {
|
||||
throw new Error(
|
||||
'Agent installation does not have an authorized registry role'
|
||||
)
|
||||
}
|
||||
return registered
|
||||
}
|
||||
|
||||
#load(): InstallationRegistryState {
|
||||
let value: unknown
|
||||
try {
|
||||
assertPrivateRegularFile(this.#storagePath)
|
||||
value = JSON.parse(readFileSync(this.#storagePath, 'utf8'))
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
const initial: InstallationRegistryState = {
|
||||
formatVersion: 1
|
||||
}
|
||||
writePrivateFileAtomic(
|
||||
this.#storagePath,
|
||||
`${JSON.stringify(initial, null, 2)}\n`
|
||||
)
|
||||
return initial
|
||||
}
|
||||
throw new Error('Agent installation registry is corrupt', {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
let parsed: InstallationRegistryState
|
||||
try {
|
||||
parsed = parseInstallationRegistryState(value)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'Agent installation registry is corrupt',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
this.#needsCanonicalization =
|
||||
JSON.stringify(parsed) !== JSON.stringify(value)
|
||||
return parsed
|
||||
}
|
||||
|
||||
#commit(stateInput: InstallationRegistryState): void {
|
||||
const state = installationRegistryStateSchema.parse(stateInput)
|
||||
let persisted: unknown
|
||||
try {
|
||||
assertPrivateRegularFile(this.#storagePath)
|
||||
persisted = JSON.parse(readFileSync(this.#storagePath, 'utf8'))
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'Agent installation registry changed or became corrupt before commit',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
let parsedPersisted: InstallationRegistryState
|
||||
try {
|
||||
parsedPersisted = parseInstallationRegistryState(persisted)
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Agent installation registry changed or became corrupt before commit'
|
||||
)
|
||||
}
|
||||
if (
|
||||
JSON.stringify(parsedPersisted) !== JSON.stringify(this.#state)
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent installation registry changed concurrently; refusing to overwrite it'
|
||||
)
|
||||
}
|
||||
writePrivateFileAtomic(
|
||||
this.#storagePath,
|
||||
`${JSON.stringify(state, null, 2)}\n`
|
||||
)
|
||||
this.#state = state
|
||||
this.#needsCanonicalization = false
|
||||
}
|
||||
|
||||
#canonicalizeIfNeeded(): void {
|
||||
if (this.#needsCanonicalization) {
|
||||
this.#commit(this.#state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function entryFromVerified(
|
||||
verified: VerifiedInstalledAgentBundle
|
||||
): InstallationRegistryEntry {
|
||||
return installationRegistryEntrySchema.parse({
|
||||
installationId: verified.installationId,
|
||||
agentVersion: verified.manifest.agentVersion,
|
||||
manifestSha256: verified.manifestSha256,
|
||||
arch: verified.manifest.arch,
|
||||
protocol: verified.manifest.protocol
|
||||
})
|
||||
}
|
||||
|
||||
function entryEquals(
|
||||
left: InstallationRegistryEntry,
|
||||
right: InstallationRegistryEntry
|
||||
): boolean {
|
||||
return (
|
||||
left.installationId === right.installationId &&
|
||||
left.agentVersion === right.agentVersion &&
|
||||
left.manifestSha256 === right.manifestSha256 &&
|
||||
left.arch === right.arch
|
||||
)
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && 'code' in error
|
||||
}
|
||||
|
||||
function isConcurrentRegistryError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.message.includes('changed concurrently')
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
createHash,
|
||||
generateKeyPairSync,
|
||||
sign,
|
||||
type KeyObject
|
||||
} from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
type AgentBundleManifest,
|
||||
type AgentReleaseKeyRegistry
|
||||
} from '../shared/agent-installation-contracts'
|
||||
import {
|
||||
canonicalInstalledAgentManifestBytes,
|
||||
installedAgentManifestSignaturePayload,
|
||||
loadRegisteredAgentBundle,
|
||||
verifyInstalledAgentBundle
|
||||
} from './installed-bundle-verifier'
|
||||
|
||||
let root: string
|
||||
let installationDirectory: string
|
||||
let privateKey: KeyObject
|
||||
let releaseKeyRegistry: AgentReleaseKeyRegistry
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'goodbuddy-installed-agent-'))
|
||||
installationDirectory = join(root, 'install-10')
|
||||
mkdirSync(installationDirectory, { mode: 0o700 })
|
||||
const generated = generateKeyPairSync('ed25519')
|
||||
privateKey = generated.privateKey
|
||||
releaseKeyRegistry = {
|
||||
formatVersion: 1,
|
||||
keys: [
|
||||
{
|
||||
keyId: 'installed-test-key',
|
||||
publicKeySpkiBase64: generated.publicKey
|
||||
.export({ format: 'der', type: 'spki' })
|
||||
.toString('base64'),
|
||||
environment: 'test'
|
||||
}
|
||||
],
|
||||
revocations: []
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('installed Agent bundle verifier', () => {
|
||||
it('binds the installation identity to a signed complete payload', async () => {
|
||||
writeBundle()
|
||||
|
||||
await expect(verifyBundle()).resolves.toMatchObject({
|
||||
installationId: 'install-10',
|
||||
binaryDigest: `sha256:${createHash('sha256')
|
||||
.update(readFileSync(join(installationDirectory, 'manifest.json')))
|
||||
.digest('hex')}`,
|
||||
manifest: {
|
||||
agentVersion: '0.11.0',
|
||||
protocol: { major: 1, minor: 0 }
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
verifyInstalledAgentBundle(installationDirectory, {
|
||||
installationId: 'different-id',
|
||||
architecture: 'x64',
|
||||
releaseKeyRegistry,
|
||||
verificationEnvironment: 'test'
|
||||
})
|
||||
).rejects.toThrow('does not match')
|
||||
})
|
||||
|
||||
it('rejects tampering, undeclared files, and symlinks', async () => {
|
||||
writeBundle()
|
||||
writeFileSync(
|
||||
join(installationDirectory, 'lib', 'agent.cjs'),
|
||||
'tampered'
|
||||
)
|
||||
await expect(verifyBundle()).rejects.toThrow(
|
||||
/size mismatch|hash mismatch/u
|
||||
)
|
||||
|
||||
resetInstallation()
|
||||
writeBundle()
|
||||
writeFileSync(join(installationDirectory, 'undeclared'), 'no')
|
||||
await expect(verifyBundle()).rejects.toThrow('undeclared')
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
resetInstallation()
|
||||
writeBundle()
|
||||
rmSync(join(installationDirectory, 'lib', 'agent.cjs'))
|
||||
symlinkSync(
|
||||
join(installationDirectory, 'goodbuddy-agent'),
|
||||
join(installationDirectory, 'lib', 'agent.cjs')
|
||||
)
|
||||
await expect(verifyBundle()).rejects.toThrow(/regular file|symlink/u)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects mode, architecture, and signature violations', async () => {
|
||||
if (process.platform !== 'win32') {
|
||||
writeBundle()
|
||||
chmodSync(join(installationDirectory, 'goodbuddy-agent'), 0o777)
|
||||
await expect(verifyBundle()).rejects.toThrow('mode mismatch')
|
||||
resetInstallation()
|
||||
}
|
||||
|
||||
writeBundle('arm64')
|
||||
await expect(verifyBundle()).rejects.toThrow(
|
||||
'current host'
|
||||
)
|
||||
|
||||
resetInstallation()
|
||||
writeBundle()
|
||||
writeFileSync(
|
||||
join(installationDirectory, 'manifest.sig'),
|
||||
`${Buffer.alloc(64).toString('base64')}\n`,
|
||||
{ mode: 0o644 }
|
||||
)
|
||||
await expect(verifyBundle()).rejects.toThrow(
|
||||
'signature verification failed'
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
it('loads Host-managed registry evidence without repeating signature or payload verification', async () => {
|
||||
writeBundle()
|
||||
const manifestBytes = readFileSync(
|
||||
join(installationDirectory, 'manifest.json')
|
||||
)
|
||||
const manifestSha256 = createHash('sha256')
|
||||
.update(manifestBytes)
|
||||
.digest('hex')
|
||||
writeFileSync(
|
||||
join(installationDirectory, 'manifest.sig'),
|
||||
'not-used-during-attach\n',
|
||||
{ mode: 0o644 }
|
||||
)
|
||||
|
||||
await expect(
|
||||
loadRegisteredAgentBundle(installationDirectory, {
|
||||
installationId: 'install-10',
|
||||
architecture: 'x64',
|
||||
registered: {
|
||||
installationId: 'install-10',
|
||||
agentVersion: '0.11.0',
|
||||
manifestSha256,
|
||||
arch: 'x64'
|
||||
},
|
||||
enforceFilesystemMode: process.platform !== 'win32'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
installationId: 'install-10',
|
||||
manifestSha256,
|
||||
binaryDigest: `sha256:${manifestSha256}`,
|
||||
manifest: {
|
||||
agentVersion: '0.11.0',
|
||||
protocol: { major: 1, minor: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
writeFileSync(
|
||||
join(installationDirectory, 'manifest.json'),
|
||||
Buffer.concat([manifestBytes, Buffer.from(' ')])
|
||||
)
|
||||
await expect(
|
||||
loadRegisteredAgentBundle(installationDirectory, {
|
||||
installationId: 'install-10',
|
||||
architecture: 'x64',
|
||||
registered: {
|
||||
installationId: 'install-10',
|
||||
agentVersion: '0.11.0',
|
||||
manifestSha256,
|
||||
arch: 'x64'
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('Host-managed registry')
|
||||
})
|
||||
})
|
||||
|
||||
function resetInstallation(): void {
|
||||
rmSync(installationDirectory, { recursive: true, force: true })
|
||||
mkdirSync(installationDirectory, { mode: 0o700 })
|
||||
}
|
||||
|
||||
function writeBundle(arch: 'x64' | 'arm64' = 'x64'): void {
|
||||
for (const directory of ['lib', 'licenses']) {
|
||||
mkdirSync(join(installationDirectory, directory), { mode: 0o700 })
|
||||
}
|
||||
const contents = new Map<string, Buffer>([
|
||||
['node', elf(arch)],
|
||||
['goodbuddy-agent', Buffer.from('#!/bin/sh\n')],
|
||||
['lib/agent.cjs', Buffer.from('module.exports = {}\n')],
|
||||
['licenses/GoodBuddy-0BSD.txt', Buffer.from('0BSD')]
|
||||
])
|
||||
for (const [path, bytes] of contents) {
|
||||
writeFileSync(
|
||||
join(installationDirectory, ...path.split('/')),
|
||||
bytes,
|
||||
{ mode: path === 'node' || path === 'goodbuddy-agent' ? 0o755 : 0o644 }
|
||||
)
|
||||
}
|
||||
const manifest: AgentBundleManifest = {
|
||||
formatVersion: 1,
|
||||
product: 'GoodBuddy',
|
||||
agentVersion: '0.11.0',
|
||||
platform: 'linux',
|
||||
arch,
|
||||
protocol: { major: 1, minor: 0 },
|
||||
signingKeyId: 'installed-test-key',
|
||||
entrypoint: {
|
||||
path: 'goodbuddy-agent',
|
||||
runtimePath: 'node',
|
||||
scriptPath: 'lib/agent.cjs'
|
||||
},
|
||||
files: [...contents].map(([path, bytes]) => ({
|
||||
path,
|
||||
size: bytes.byteLength,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
mode:
|
||||
path === 'node' || path === 'goodbuddy-agent'
|
||||
? '0755'
|
||||
: '0644'
|
||||
})),
|
||||
licenses: [
|
||||
{
|
||||
package: 'GoodBuddy',
|
||||
version: '0.11.0',
|
||||
spdx: '0BSD',
|
||||
path: 'licenses/GoodBuddy-0BSD.txt'
|
||||
}
|
||||
]
|
||||
}
|
||||
const bytes = canonicalInstalledAgentManifestBytes(manifest)
|
||||
writeFileSync(
|
||||
join(installationDirectory, 'manifest.json'),
|
||||
bytes,
|
||||
{ mode: 0o644 }
|
||||
)
|
||||
writeFileSync(
|
||||
join(installationDirectory, 'manifest.sig'),
|
||||
`${sign(
|
||||
null,
|
||||
installedAgentManifestSignaturePayload(bytes),
|
||||
privateKey
|
||||
).toString('base64')}\n`,
|
||||
{ mode: 0o644 }
|
||||
)
|
||||
}
|
||||
|
||||
function verifyBundle() {
|
||||
return verifyInstalledAgentBundle(installationDirectory, {
|
||||
installationId: 'install-10',
|
||||
architecture: 'x64',
|
||||
releaseKeyRegistry,
|
||||
verificationEnvironment: 'test',
|
||||
enforceFilesystemMode: process.platform !== 'win32'
|
||||
})
|
||||
}
|
||||
|
||||
function elf(architecture: 'x64' | 'arm64'): Buffer {
|
||||
const header = Buffer.alloc(64)
|
||||
header.set([0x7f, 0x45, 0x4c, 0x46])
|
||||
header[5] = 1
|
||||
header.writeUInt16LE(architecture === 'x64' ? 62 : 183, 18)
|
||||
return header
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
import {
|
||||
createHash,
|
||||
createPublicKey,
|
||||
verify
|
||||
} from 'node:crypto'
|
||||
import {
|
||||
lstat,
|
||||
readFile,
|
||||
readdir
|
||||
} from 'node:fs/promises'
|
||||
import { basename, join, relative, resolve, sep } from 'node:path'
|
||||
import {
|
||||
agentBundleManifestSchema,
|
||||
agentReleaseKeyRegistrySchema,
|
||||
type AgentArchitecture,
|
||||
type AgentBundleManifest,
|
||||
type AgentReleaseKey,
|
||||
type AgentReleaseKeyRegistry
|
||||
} from '../shared/agent-installation-contracts'
|
||||
import {
|
||||
installationRegistryEntrySchema,
|
||||
type InstallationRegistryEntry
|
||||
} from '../shared/remote-environment-registry-contracts'
|
||||
import { assertAbsoluteManagedPath } from './managed-paths'
|
||||
import {
|
||||
assertElfArchitecture,
|
||||
sha256File
|
||||
} from './bundle-file-verification'
|
||||
|
||||
const SIGNATURE_DOMAIN = Buffer.from(
|
||||
'GoodBuddy Agent Bundle Manifest Signature v1\0',
|
||||
'utf8'
|
||||
)
|
||||
const MAXIMUM_METADATA_BYTES = 1024 * 1024
|
||||
const MANIFEST_FILE_NAME = 'manifest.json'
|
||||
const SIGNATURE_FILE_NAME = 'manifest.sig'
|
||||
const installationIdPattern =
|
||||
/^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,126}[A-Za-z0-9])?$/u
|
||||
|
||||
export type InstalledBundleVerificationEnvironment =
|
||||
| 'production'
|
||||
| 'test'
|
||||
|
||||
export type VerifiedInstalledAgentBundle = {
|
||||
installationId: string
|
||||
installationDirectory: string
|
||||
executablePath: string
|
||||
manifest: AgentBundleManifest
|
||||
manifestSha256: string
|
||||
binaryDigest: string
|
||||
}
|
||||
|
||||
export type VerifyInstalledAgentBundleOptions = {
|
||||
installationId: string
|
||||
architecture: AgentArchitecture
|
||||
releaseKeyRegistry: AgentReleaseKeyRegistry
|
||||
verificationEnvironment?: InstalledBundleVerificationEnvironment
|
||||
enforceFilesystemMode?: boolean
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
uid?: number
|
||||
}
|
||||
|
||||
export type LoadRegisteredAgentBundleOptions = {
|
||||
installationId: string
|
||||
architecture: AgentArchitecture
|
||||
registered: InstallationRegistryEntry
|
||||
enforceFilesystemMode?: boolean
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
uid?: number
|
||||
}
|
||||
|
||||
export class RegisteredAgentBundleError extends Error {
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message, { cause })
|
||||
this.name = 'RegisteredAgentBundleError'
|
||||
}
|
||||
}
|
||||
|
||||
export async function readManagedAgentReleaseKeyRegistry(
|
||||
filePath: string,
|
||||
options: { uid?: number; filesystemPlatform?: NodeJS.Platform } = {}
|
||||
): Promise<AgentReleaseKeyRegistry> {
|
||||
const normalizedPath = assertAbsoluteManagedPath(resolve(filePath))
|
||||
const stat = await lstat(normalizedPath)
|
||||
assertOwnedRegularFile(stat, normalizedPath, options.uid)
|
||||
if (
|
||||
shouldEnforceMode(options) &&
|
||||
modeString(stat.mode) !== '0600'
|
||||
) {
|
||||
throw new Error(
|
||||
'Managed Agent release-key registry permissions must be 0600'
|
||||
)
|
||||
}
|
||||
if (stat.size > MAXIMUM_METADATA_BYTES) {
|
||||
throw new Error('Managed Agent release-key registry exceeds its size limit')
|
||||
}
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(
|
||||
(await readFile(normalizedPath)).toString('utf8')
|
||||
)
|
||||
} catch (error) {
|
||||
throw new Error('Managed Agent release-key registry is invalid JSON', {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
return parseReleaseKeyRegistry(value)
|
||||
}
|
||||
|
||||
export async function verifyInstalledAgentBundle(
|
||||
installationDirectoryInput: string,
|
||||
options: VerifyInstalledAgentBundleOptions
|
||||
): Promise<VerifiedInstalledAgentBundle> {
|
||||
const installationId = validateInstallationId(options.installationId)
|
||||
const installationDirectory = resolve(installationDirectoryInput)
|
||||
assertAbsoluteManagedPath(installationDirectory)
|
||||
if (basename(installationDirectory) !== installationId) {
|
||||
throw new Error(
|
||||
'Agent installation ID does not match its managed directory'
|
||||
)
|
||||
}
|
||||
const releaseKeyRegistry = parseReleaseKeyRegistry(
|
||||
options.releaseKeyRegistry
|
||||
)
|
||||
const verificationEnvironment =
|
||||
options.verificationEnvironment ?? 'production'
|
||||
assertRegistryAvailable(releaseKeyRegistry, verificationEnvironment)
|
||||
|
||||
const rootStat = await lstat(installationDirectory)
|
||||
assertOwnedDirectory(
|
||||
rootStat,
|
||||
'Agent installation directory',
|
||||
options.uid
|
||||
)
|
||||
if (
|
||||
shouldEnforceMode(options) &&
|
||||
modeString(rootStat.mode) !== '0700'
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent installation directory permissions must be 0700'
|
||||
)
|
||||
}
|
||||
|
||||
const manifestBytes = await readMetadataFile(
|
||||
join(installationDirectory, MANIFEST_FILE_NAME),
|
||||
'Agent manifest',
|
||||
options
|
||||
)
|
||||
const signatureBytes = await readSignature(
|
||||
join(installationDirectory, SIGNATURE_FILE_NAME),
|
||||
options
|
||||
)
|
||||
const manifest = verifyInstalledAgentManifestSignature(
|
||||
manifestBytes,
|
||||
signatureBytes,
|
||||
releaseKeyRegistry,
|
||||
verificationEnvironment
|
||||
)
|
||||
if (manifest.arch !== options.architecture) {
|
||||
throw new Error(
|
||||
'Agent manifest architecture does not match the current host'
|
||||
)
|
||||
}
|
||||
|
||||
const declaredPaths = new Set(
|
||||
manifest.files.map((file) => file.path)
|
||||
)
|
||||
for (const file of manifest.files) {
|
||||
const filePath = join(
|
||||
installationDirectory,
|
||||
...file.path.split('/')
|
||||
)
|
||||
const stat = await lstat(filePath)
|
||||
assertOwnedRegularFile(stat, file.path, options.uid)
|
||||
if (stat.size !== file.size) {
|
||||
throw new Error(`Agent payload size mismatch: ${file.path}`)
|
||||
}
|
||||
if ((await sha256File(filePath)) !== file.sha256) {
|
||||
throw new Error(`Agent payload hash mismatch: ${file.path}`)
|
||||
}
|
||||
if (
|
||||
shouldEnforceMode(options) &&
|
||||
modeString(stat.mode) !== file.mode
|
||||
) {
|
||||
throw new Error(`Agent payload mode mismatch: ${file.path}`)
|
||||
}
|
||||
}
|
||||
|
||||
const actualPaths = new Set(
|
||||
await listInstallationFiles(installationDirectory, options)
|
||||
)
|
||||
const expectedPaths = new Set([
|
||||
...declaredPaths,
|
||||
MANIFEST_FILE_NAME,
|
||||
SIGNATURE_FILE_NAME
|
||||
])
|
||||
if (
|
||||
actualPaths.size !== expectedPaths.size ||
|
||||
[...actualPaths].some((path) => !expectedPaths.has(path))
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent installation contains undeclared or missing files'
|
||||
)
|
||||
}
|
||||
|
||||
await assertElfArchitecture(
|
||||
join(installationDirectory, manifest.entrypoint.runtimePath),
|
||||
manifest.arch,
|
||||
'Agent Node runtime'
|
||||
)
|
||||
const manifestSha256 = sha256Bytes(manifestBytes)
|
||||
return {
|
||||
installationId,
|
||||
installationDirectory,
|
||||
executablePath: join(
|
||||
installationDirectory,
|
||||
manifest.entrypoint.path
|
||||
),
|
||||
manifest,
|
||||
manifestSha256,
|
||||
binaryDigest: `sha256:${manifestSha256}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs the identity previously verified and committed by Host
|
||||
* management. Normal attach/bootstrap uses this bounded metadata path instead
|
||||
* of rehashing the complete installation on every project switch.
|
||||
*/
|
||||
export async function loadRegisteredAgentBundle(
|
||||
installationDirectoryInput: string,
|
||||
options: LoadRegisteredAgentBundleOptions
|
||||
): Promise<VerifiedInstalledAgentBundle> {
|
||||
try {
|
||||
return await loadRegisteredAgentBundleUnchecked(
|
||||
installationDirectoryInput,
|
||||
options
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof RegisteredAgentBundleError) {
|
||||
throw error
|
||||
}
|
||||
throw new RegisteredAgentBundleError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Registered Agent installation is invalid',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRegisteredAgentBundleUnchecked(
|
||||
installationDirectoryInput: string,
|
||||
options: LoadRegisteredAgentBundleOptions
|
||||
): Promise<VerifiedInstalledAgentBundle> {
|
||||
const installationId = validateInstallationId(options.installationId)
|
||||
const installationDirectory = resolve(installationDirectoryInput)
|
||||
assertAbsoluteManagedPath(installationDirectory)
|
||||
if (basename(installationDirectory) !== installationId) {
|
||||
throw new Error(
|
||||
'Agent installation ID does not match its managed directory'
|
||||
)
|
||||
}
|
||||
const registered = installationRegistryEntrySchema.parse(
|
||||
options.registered
|
||||
)
|
||||
if (
|
||||
registered.installationId !== installationId ||
|
||||
registered.arch !== options.architecture
|
||||
) {
|
||||
throw new Error(
|
||||
'Registered Agent identity does not match the current installation'
|
||||
)
|
||||
}
|
||||
|
||||
const rootStat = await lstat(installationDirectory)
|
||||
assertOwnedDirectory(
|
||||
rootStat,
|
||||
'Agent installation directory',
|
||||
options.uid
|
||||
)
|
||||
if (
|
||||
shouldEnforceMode(options) &&
|
||||
modeString(rootStat.mode) !== '0700'
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent installation directory permissions must be 0700'
|
||||
)
|
||||
}
|
||||
|
||||
const manifestBytes = await readMetadataFile(
|
||||
join(installationDirectory, MANIFEST_FILE_NAME),
|
||||
'Agent manifest',
|
||||
options
|
||||
)
|
||||
if (sha256Bytes(manifestBytes) !== registered.manifestSha256) {
|
||||
throw new Error(
|
||||
'Agent manifest does not match the Host-managed registry'
|
||||
)
|
||||
}
|
||||
let untrusted: unknown
|
||||
try {
|
||||
untrusted = JSON.parse(manifestBytes.toString('utf8'))
|
||||
} catch (error) {
|
||||
throw new Error('Agent manifest JSON is invalid', { cause: error })
|
||||
}
|
||||
const manifest = agentBundleManifestSchema.parse(untrusted)
|
||||
if (
|
||||
!canonicalInstalledAgentManifestBytes(manifest).equals(
|
||||
manifestBytes
|
||||
) ||
|
||||
manifest.agentVersion !== registered.agentVersion ||
|
||||
manifest.arch !== registered.arch ||
|
||||
(
|
||||
registered.protocol !== undefined &&
|
||||
(
|
||||
manifest.protocol.major !==
|
||||
registered.protocol.major ||
|
||||
manifest.protocol.minor !==
|
||||
registered.protocol.minor
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent manifest does not match the Host-managed registry'
|
||||
)
|
||||
}
|
||||
|
||||
const executablePath = join(
|
||||
installationDirectory,
|
||||
manifest.entrypoint.path
|
||||
)
|
||||
await assertRegisteredEntrypoint(executablePath, '0755', options)
|
||||
await assertRegisteredEntrypoint(
|
||||
join(installationDirectory, manifest.entrypoint.runtimePath),
|
||||
'0755',
|
||||
options
|
||||
)
|
||||
await assertRegisteredEntrypoint(
|
||||
join(installationDirectory, manifest.entrypoint.scriptPath),
|
||||
'0644',
|
||||
options
|
||||
)
|
||||
return {
|
||||
installationId,
|
||||
installationDirectory,
|
||||
executablePath,
|
||||
manifest,
|
||||
manifestSha256: registered.manifestSha256,
|
||||
binaryDigest: `sha256:${registered.manifestSha256}`
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalInstalledAgentManifestBytes(
|
||||
manifest: AgentBundleManifest
|
||||
): Buffer {
|
||||
return Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export function installedAgentManifestSignaturePayload(
|
||||
manifestBytes: Uint8Array
|
||||
): Buffer {
|
||||
return Buffer.concat([
|
||||
SIGNATURE_DOMAIN,
|
||||
Buffer.from(manifestBytes)
|
||||
])
|
||||
}
|
||||
|
||||
export function verifyInstalledAgentManifestSignature(
|
||||
manifestBytes: Buffer,
|
||||
signatureBytes: Buffer,
|
||||
registryInput: AgentReleaseKeyRegistry,
|
||||
verificationEnvironment: InstalledBundleVerificationEnvironment =
|
||||
'production'
|
||||
): AgentBundleManifest {
|
||||
const registry = parseReleaseKeyRegistry(registryInput)
|
||||
assertRegistryAvailable(registry, verificationEnvironment)
|
||||
let untrusted: unknown
|
||||
try {
|
||||
untrusted = JSON.parse(manifestBytes.toString('utf8'))
|
||||
} catch (error) {
|
||||
throw new Error('Agent manifest JSON is invalid', { cause: error })
|
||||
}
|
||||
const parsed = agentBundleManifestSchema.safeParse(untrusted)
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Agent manifest contract is invalid: ${parsed.error.message}`
|
||||
)
|
||||
}
|
||||
const manifest = parsed.data
|
||||
if (
|
||||
!canonicalInstalledAgentManifestBytes(manifest).equals(manifestBytes)
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent manifest is not in canonical deterministic form'
|
||||
)
|
||||
}
|
||||
const key = trustedKeyForManifest(
|
||||
manifest,
|
||||
registry,
|
||||
verificationEnvironment
|
||||
)
|
||||
const publicKey = importPublicKey(key)
|
||||
if (
|
||||
signatureBytes.length !== 64 ||
|
||||
!verify(
|
||||
null,
|
||||
installedAgentManifestSignaturePayload(manifestBytes),
|
||||
publicKey,
|
||||
signatureBytes
|
||||
)
|
||||
) {
|
||||
throw new Error('Agent manifest signature verification failed')
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
function validateInstallationId(value: string): string {
|
||||
if (value.length > 128 || !installationIdPattern.test(value)) {
|
||||
throw new Error('Invalid Agent installation ID')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseReleaseKeyRegistry(
|
||||
value: unknown
|
||||
): AgentReleaseKeyRegistry {
|
||||
const parsed = agentReleaseKeyRegistrySchema.safeParse(value)
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Agent trusted key registry contract is invalid: ${parsed.error.message}`
|
||||
)
|
||||
}
|
||||
for (const key of parsed.data.keys) {
|
||||
importPublicKey(key)
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
function assertRegistryAvailable(
|
||||
registry: AgentReleaseKeyRegistry,
|
||||
environment: InstalledBundleVerificationEnvironment
|
||||
): void {
|
||||
if (
|
||||
environment === 'production' &&
|
||||
!registry.keys.some((key) => key.environment === 'production')
|
||||
) {
|
||||
throw new Error(
|
||||
'Agent production key registry is empty; installed Agent verification is unavailable'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function trustedKeyForManifest(
|
||||
manifest: AgentBundleManifest,
|
||||
registry: AgentReleaseKeyRegistry,
|
||||
environment: InstalledBundleVerificationEnvironment
|
||||
): AgentReleaseKey {
|
||||
const key = registry.keys.find(
|
||||
(candidate) => candidate.keyId === manifest.signingKeyId
|
||||
)
|
||||
if (key === undefined) {
|
||||
throw new Error(
|
||||
`Agent manifest uses an unknown signing key: ${manifest.signingKeyId}`
|
||||
)
|
||||
}
|
||||
if (environment === 'production' && key.environment !== 'production') {
|
||||
throw new Error(
|
||||
`Production Agent verification rejects non-production key: ${key.keyId}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
registry.revocations.some(
|
||||
(revocation) => revocation.keyId === key.keyId
|
||||
)
|
||||
) {
|
||||
throw new Error(`Agent signing key is revoked: ${key.keyId}`)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function importPublicKey(key: AgentReleaseKey) {
|
||||
let publicKey
|
||||
try {
|
||||
publicKey = createPublicKey({
|
||||
key: Buffer.from(key.publicKeySpkiBase64, 'base64'),
|
||||
format: 'der',
|
||||
type: 'spki'
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Agent trusted public key is invalid: ${key.keyId}`, {
|
||||
cause: error
|
||||
})
|
||||
}
|
||||
if (publicKey.asymmetricKeyType !== 'ed25519') {
|
||||
throw new Error(
|
||||
`Agent trusted public key is not Ed25519: ${key.keyId}`
|
||||
)
|
||||
}
|
||||
return publicKey
|
||||
}
|
||||
|
||||
async function assertRegisteredEntrypoint(
|
||||
filePath: string,
|
||||
expectedMode: string,
|
||||
options: Pick<
|
||||
LoadRegisteredAgentBundleOptions,
|
||||
'uid' | 'filesystemPlatform' | 'enforceFilesystemMode'
|
||||
>
|
||||
): Promise<void> {
|
||||
const stat = await lstat(filePath)
|
||||
assertOwnedRegularFile(stat, filePath, options.uid)
|
||||
if (
|
||||
shouldEnforceMode(options) &&
|
||||
modeString(stat.mode) !== expectedMode
|
||||
) {
|
||||
throw new Error(
|
||||
`Agent payload mode mismatch: ${basename(filePath)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readMetadataFile(
|
||||
filePath: string,
|
||||
label: string,
|
||||
options: Pick<
|
||||
VerifyInstalledAgentBundleOptions,
|
||||
'uid' | 'filesystemPlatform' | 'enforceFilesystemMode'
|
||||
>
|
||||
): Promise<Buffer> {
|
||||
const stat = await lstat(filePath)
|
||||
assertOwnedRegularFile(stat, label, options.uid)
|
||||
if (
|
||||
shouldEnforceMode(options) &&
|
||||
modeString(stat.mode) !== '0644'
|
||||
) {
|
||||
throw new Error(`Agent payload mode mismatch: ${basename(filePath)}`)
|
||||
}
|
||||
if (stat.size > MAXIMUM_METADATA_BYTES) {
|
||||
throw new Error(`${label} exceeds its size limit`)
|
||||
}
|
||||
return readFile(filePath)
|
||||
}
|
||||
|
||||
async function readSignature(
|
||||
filePath: string,
|
||||
options: Pick<
|
||||
VerifyInstalledAgentBundleOptions,
|
||||
'uid' | 'filesystemPlatform' | 'enforceFilesystemMode'
|
||||
>
|
||||
): Promise<Buffer> {
|
||||
const text = (await readMetadataFile(
|
||||
filePath,
|
||||
'Agent detached signature',
|
||||
options
|
||||
)).toString('utf8')
|
||||
if (!/^[A-Za-z0-9+/]{86}==\n$/u.test(text)) {
|
||||
throw new Error('Agent detached signature encoding is invalid')
|
||||
}
|
||||
return Buffer.from(text.trim(), 'base64')
|
||||
}
|
||||
|
||||
async function listInstallationFiles(
|
||||
installationDirectory: string,
|
||||
options: Pick<
|
||||
VerifyInstalledAgentBundleOptions,
|
||||
'uid' | 'filesystemPlatform' | 'enforceFilesystemMode'
|
||||
>
|
||||
): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
const pending = [installationDirectory]
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop()
|
||||
if (directory === undefined) {
|
||||
continue
|
||||
}
|
||||
const entries = await readdir(directory, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const absolutePath = join(directory, entry.name)
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Agent installation cannot contain a symlink: ${absolutePath}`
|
||||
)
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
const stat = await lstat(absolutePath)
|
||||
assertOwnedDirectory(stat, absolutePath, options.uid)
|
||||
if (
|
||||
shouldEnforceMode(options) &&
|
||||
modeString(stat.mode) !== '0700'
|
||||
) {
|
||||
throw new Error(
|
||||
`Agent installation directory permissions must be 0700: ${absolutePath}`
|
||||
)
|
||||
}
|
||||
pending.push(absolutePath)
|
||||
} else if (entry.isFile()) {
|
||||
files.push(
|
||||
relative(installationDirectory, absolutePath)
|
||||
.split(sep)
|
||||
.join('/')
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Agent installation contains an unsupported file: ${absolutePath}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function assertOwnedDirectory(
|
||||
stat: Awaited<ReturnType<typeof lstat>>,
|
||||
label: string,
|
||||
uid = process.getuid?.()
|
||||
): void {
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
||||
throw new Error(`${label} is not a real directory`)
|
||||
}
|
||||
if (uid !== undefined && stat.uid !== uid) {
|
||||
throw new Error(`${label} is not owned by the current user`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertOwnedRegularFile(
|
||||
stat: Awaited<ReturnType<typeof lstat>>,
|
||||
label: string,
|
||||
uid = process.getuid?.()
|
||||
): void {
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
throw new Error(`${label} is not a regular file`)
|
||||
}
|
||||
if (uid !== undefined && stat.uid !== uid) {
|
||||
throw new Error(`${label} is not owned by the current user`)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldEnforceMode(
|
||||
options: {
|
||||
enforceFilesystemMode?: boolean
|
||||
filesystemPlatform?: NodeJS.Platform
|
||||
}
|
||||
): boolean {
|
||||
return (
|
||||
options.enforceFilesystemMode !== false &&
|
||||
(options.filesystemPlatform ?? process.platform) !== 'win32'
|
||||
)
|
||||
}
|
||||
|
||||
function modeString(mode: number): string {
|
||||
return (mode & 0o777).toString(8).padStart(4, '0')
|
||||
}
|
||||
|
||||
function sha256Bytes(contents: Uint8Array): string {
|
||||
return createHash('sha256').update(contents).digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
fstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
openSync,
|
||||
rmSync,
|
||||
symlinkSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createConnection, createServer, type Socket } from 'node:net'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { AGENT_PROTOCOL_VERSION } from '../shared/agent-protocol/contracts'
|
||||
import {
|
||||
decodeAgentFrame,
|
||||
encodeAgentFrame
|
||||
} from '../shared/agent-protocol/frame'
|
||||
import {
|
||||
ControllerRegistry,
|
||||
ControllerRegistryError
|
||||
} from './controller-registry'
|
||||
import { EventJournal } from './event-journal'
|
||||
import { InstallationChallengeVerifier } from './installation-challenge'
|
||||
import {
|
||||
ensurePrivateDirectory,
|
||||
ManagedPathError
|
||||
} from './managed-paths'
|
||||
import { AgentProtocolServer } from './protocol-server'
|
||||
|
||||
const temporaryPaths: string[] = []
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of temporaryPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('managed daemon lifecycle', () => {
|
||||
it('creates private directories and rejects symlink-managed paths', () => {
|
||||
const root = temporaryDirectory()
|
||||
const privateDirectory = join(root, 'private')
|
||||
ensurePrivateDirectory(privateDirectory)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(chmodMode(privateDirectory)).toBe(0o700)
|
||||
}
|
||||
const target = join(root, 'target')
|
||||
mkdirSync(target)
|
||||
const link = join(root, 'link')
|
||||
symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
expect(() => ensurePrivateDirectory(resolve(link))).toThrow(ManagedPathError)
|
||||
})
|
||||
|
||||
it('uses one-shot installation HMAC challenges', () => {
|
||||
let now = 10
|
||||
const verifier = new InstallationChallengeVerifier(Buffer.alloc(32, 7), {
|
||||
now: () => now,
|
||||
lifetimeMs: 100
|
||||
})
|
||||
const challenge = verifier.issue()
|
||||
expect(challenge.serverNonce).toMatch(/^[a-f0-9]{64}$/u)
|
||||
const response = verifier.createResponse({
|
||||
serverNonce: challenge.serverNonce,
|
||||
clientNonce: 'client-1',
|
||||
controllerId: 'controller-1'
|
||||
})
|
||||
expect(
|
||||
verifier.verify({
|
||||
serverNonce: challenge.serverNonce,
|
||||
clientNonce: 'client-1',
|
||||
controllerId: 'controller-1',
|
||||
response
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
verifier.verify({
|
||||
serverNonce: challenge.serverNonce,
|
||||
clientNonce: 'client-1',
|
||||
controllerId: 'controller-1',
|
||||
response
|
||||
})
|
||||
).toBe(false)
|
||||
const expired = verifier.issue()
|
||||
now = 111
|
||||
expect(
|
||||
verifier.verify({
|
||||
serverNonce: expired.serverNonce,
|
||||
clientNonce: 'client-1',
|
||||
controllerId: 'controller-1',
|
||||
response: verifier.createResponse({
|
||||
serverNonce: expired.serverNonce,
|
||||
clientNonce: 'client-1',
|
||||
controllerId: 'controller-1'
|
||||
})
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('persists generations and enforces controller ownership', () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const registryPath = resolve(root, 'controllers.json')
|
||||
const first = new ControllerRegistry({ storagePath: registryPath })
|
||||
const attached = first.attach('controller-a')
|
||||
first.claim(
|
||||
attached.controllerId,
|
||||
attached.generation,
|
||||
'process',
|
||||
'process-a'
|
||||
)
|
||||
const second = new ControllerRegistry({ storagePath: registryPath })
|
||||
const resumed = second.attach('controller-a')
|
||||
expect(resumed.generation).toBe(attached.generation + 1)
|
||||
expect(() =>
|
||||
second.assertCurrent('controller-a', attached.generation)
|
||||
).toThrow(ControllerRegistryError)
|
||||
expect(() =>
|
||||
second.assertOwner(
|
||||
'controller-b',
|
||||
resumed.generation,
|
||||
'process',
|
||||
'process-a'
|
||||
)
|
||||
).toThrow(ControllerRegistryError)
|
||||
expect(() =>
|
||||
second.assertOwner(
|
||||
'controller-a',
|
||||
resumed.generation,
|
||||
'process',
|
||||
'process-a'
|
||||
)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('frames attach control requests with stale-generation rejection', async () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const events = new EventJournal(resolve(root, 'events.sqlite'))
|
||||
const controllers = new ControllerRegistry()
|
||||
const controller = controllers.attach('controller-a')
|
||||
const protocol = new AgentProtocolServer({
|
||||
controllers,
|
||||
events,
|
||||
status: () => ({
|
||||
state: 'ready',
|
||||
installationId: 'install-1',
|
||||
binaryDigest: digest,
|
||||
daemonBootId: 'boot-1',
|
||||
agentVersion: '1.0.0',
|
||||
protocol: AGENT_PROTOCOL_VERSION,
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity: 'uid:1000',
|
||||
draining: false
|
||||
})
|
||||
})
|
||||
const server = createServer((socket) => protocol.accept(socket, controller))
|
||||
await listen(server)
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') {
|
||||
throw new Error('Test server did not open a TCP endpoint')
|
||||
}
|
||||
const client = createConnection(address.port, '127.0.0.1')
|
||||
try {
|
||||
await onceConnected(client)
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'agent/status',
|
||||
params: {}
|
||||
})
|
||||
)
|
||||
client.write(
|
||||
encodeAgentFrame({
|
||||
header: {
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
connectionId: controller.connectionId,
|
||||
generation: controller.generation,
|
||||
channelId: 'control',
|
||||
channelEpoch: '1',
|
||||
direction: 'main-to-agent',
|
||||
sequence: '1',
|
||||
kind: 'control',
|
||||
payloadLength: payload.byteLength
|
||||
},
|
||||
payload
|
||||
})
|
||||
)
|
||||
const replies = await readFrames(client, 2)
|
||||
expect(
|
||||
JSON.parse(Buffer.from(replies[0]!.payload).toString('utf8'))
|
||||
).toMatchObject({
|
||||
id: 1,
|
||||
result: {
|
||||
state: 'ready',
|
||||
installationId: 'install-1',
|
||||
binaryDigest: digest
|
||||
}
|
||||
})
|
||||
|
||||
controllers.attach('controller-a')
|
||||
client.write(
|
||||
encodeAgentFrame({
|
||||
header: {
|
||||
protocolMajor: AGENT_PROTOCOL_VERSION.major,
|
||||
protocolMinor: AGENT_PROTOCOL_VERSION.minor,
|
||||
connectionId: controller.connectionId,
|
||||
generation: controller.generation,
|
||||
channelId: 'control',
|
||||
channelEpoch: '1',
|
||||
direction: 'main-to-agent',
|
||||
sequence: '2',
|
||||
kind: 'control',
|
||||
payloadLength: payload.byteLength
|
||||
},
|
||||
payload
|
||||
})
|
||||
)
|
||||
await onceClosed(client)
|
||||
} finally {
|
||||
client.destroy()
|
||||
await new Promise<void>((resolveClose) =>
|
||||
server.close(() => resolveClose())
|
||||
)
|
||||
events.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function temporaryDirectory(): string {
|
||||
const path = mkdtempSync(join(tmpdir(), 'goodbuddy-agent-'))
|
||||
temporaryPaths.push(path)
|
||||
return path
|
||||
}
|
||||
|
||||
function privateTemporaryDirectory(): string {
|
||||
const path = temporaryDirectory()
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(path, 0o700)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
function chmodMode(path: string): number {
|
||||
const descriptor = openSync(path, 'r')
|
||||
try {
|
||||
return fstatSync(descriptor).mode & 0o777
|
||||
} finally {
|
||||
closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
async function listen(server: ReturnType<typeof createServer>): Promise<void> {
|
||||
await new Promise<void>((resolveListen, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolveListen)
|
||||
})
|
||||
}
|
||||
|
||||
async function onceConnected(socket: Socket): Promise<void> {
|
||||
await new Promise<void>((resolveConnect, reject) => {
|
||||
socket.once('connect', resolveConnect)
|
||||
socket.once('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async function onceClosed(socket: Socket): Promise<void> {
|
||||
await new Promise<void>((resolveClose) => socket.once('close', resolveClose))
|
||||
}
|
||||
|
||||
async function readFrames(
|
||||
socket: Socket,
|
||||
count: number
|
||||
): Promise<ReturnType<typeof decodeAgentFrame>[]> {
|
||||
return await new Promise((resolveFrames, reject) => {
|
||||
let buffer = Buffer.alloc(0)
|
||||
const frames: ReturnType<typeof decodeAgentFrame>[] = []
|
||||
socket.on('data', (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk])
|
||||
while (buffer.byteLength >= 40) {
|
||||
const length =
|
||||
40 +
|
||||
buffer.readUInt16BE(32) +
|
||||
buffer.readUInt16BE(34) +
|
||||
buffer.readUInt32BE(36)
|
||||
if (buffer.byteLength < length) {
|
||||
break
|
||||
}
|
||||
frames.push(decodeAgentFrame(buffer.subarray(0, length)))
|
||||
buffer = buffer.subarray(length)
|
||||
}
|
||||
if (frames.length >= count) {
|
||||
resolveFrames(frames)
|
||||
}
|
||||
})
|
||||
socket.once('error', reject)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LinuxProcessInspector,
|
||||
type LinuxProcFileSystem,
|
||||
type LinuxProcessIdentity
|
||||
} from './linux-process-identity'
|
||||
|
||||
describe('Linux process identity', () => {
|
||||
it('reads only PID, start time, and canonical executable', () => {
|
||||
const executablePath = resolve('fixtures', 'runtime', 'node')
|
||||
const inspector = new LinuxProcessInspector({
|
||||
procRoot: resolve('fixtures', 'proc'),
|
||||
fileSystem: procFileSystem({
|
||||
executablePath,
|
||||
stat: procStat('98765', 'worker ) name')
|
||||
})
|
||||
})
|
||||
|
||||
expect(inspector.inspect(4242)).toEqual({
|
||||
pid: 4242,
|
||||
starttime: '98765',
|
||||
executablePath
|
||||
})
|
||||
})
|
||||
|
||||
it('returns absent only for a missing process and rejects malformed proc data', () => {
|
||||
expect(
|
||||
new LinuxProcessInspector({
|
||||
procRoot: resolve('fixtures', 'proc'),
|
||||
fileSystem: procFileSystem({
|
||||
executablePath: resolve('fixtures', 'node'),
|
||||
missingPid: true
|
||||
})
|
||||
}).inspect(4242)
|
||||
).toBeUndefined()
|
||||
|
||||
expect(() =>
|
||||
new LinuxProcessInspector({
|
||||
procRoot: resolve('fixtures', 'proc'),
|
||||
fileSystem: procFileSystem({
|
||||
executablePath: resolve('fixtures', 'node'),
|
||||
stat: '42 malformed'
|
||||
})
|
||||
}).inspect(4242)
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['start time', { starttime: '98766' }],
|
||||
['executable', { executablePath: resolve('fixtures', 'other-node') }]
|
||||
])('refuses an identity with mismatched %s', (_label, change) => {
|
||||
const { inspector, identity } = fixtureInspector()
|
||||
expect(inspector.matches({ ...identity, ...change })).toBe(false)
|
||||
})
|
||||
|
||||
it('signals only an exact process identity and handles ESRCH', () => {
|
||||
const { inspector, identity, kill } = fixtureInspector()
|
||||
expect(inspector.signal(identity, 'SIGTERM')).toBe(true)
|
||||
expect(kill).toHaveBeenCalledWith(identity.pid, 'SIGTERM')
|
||||
kill.mockClear()
|
||||
|
||||
expect(
|
||||
inspector.signal({ ...identity, starttime: '98766' }, 'SIGKILL')
|
||||
).toBe(false)
|
||||
expect(kill).not.toHaveBeenCalled()
|
||||
|
||||
const gone = new LinuxProcessInspector({
|
||||
procRoot: resolve('fixtures', 'proc'),
|
||||
fileSystem: procFileSystem({
|
||||
executablePath: identity.executablePath
|
||||
}),
|
||||
kill: () => {
|
||||
throw nodeError('ESRCH')
|
||||
}
|
||||
})
|
||||
expect(gone.signal(identity, 'SIGTERM')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function fixtureInspector(): {
|
||||
inspector: LinuxProcessInspector
|
||||
identity: LinuxProcessIdentity
|
||||
kill: ReturnType<typeof vi.fn<(pid: number, signal: NodeJS.Signals | 0) => void>>
|
||||
} {
|
||||
const executablePath = resolve('fixtures', 'runtime', 'node')
|
||||
const kill = vi.fn<(pid: number, signal: NodeJS.Signals | 0) => void>()
|
||||
return {
|
||||
inspector: new LinuxProcessInspector({
|
||||
procRoot: resolve('fixtures', 'proc'),
|
||||
fileSystem: procFileSystem({ executablePath }),
|
||||
kill
|
||||
}),
|
||||
identity: {
|
||||
pid: 4242,
|
||||
starttime: '98765',
|
||||
executablePath
|
||||
},
|
||||
kill
|
||||
}
|
||||
}
|
||||
|
||||
function procFileSystem(options: {
|
||||
executablePath: string
|
||||
stat?: string
|
||||
missingPid?: boolean
|
||||
}): LinuxProcFileSystem {
|
||||
return {
|
||||
readText(path) {
|
||||
if (!path.endsWith('4242\\stat') && !path.endsWith('4242/stat')) {
|
||||
throw new Error(`Unexpected proc text read: ${path}`)
|
||||
}
|
||||
if (options.missingPid) {
|
||||
throw nodeError('ENOENT')
|
||||
}
|
||||
return options.stat ?? procStat('98765')
|
||||
},
|
||||
readLink(path) {
|
||||
if (!path.endsWith('4242\\exe') && !path.endsWith('4242/exe')) {
|
||||
throw new Error(`Unexpected proc link read: ${path}`)
|
||||
}
|
||||
return options.executablePath
|
||||
},
|
||||
realpath(path) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function procStat(starttime: string, name = 'agent'): string {
|
||||
const fields = ['S']
|
||||
while (fields.length < 19) {
|
||||
fields.push('0')
|
||||
}
|
||||
fields.push(starttime, '0')
|
||||
return `4242 (${name}) ${fields.join(' ')}\n`
|
||||
}
|
||||
|
||||
function nodeError(code: string): NodeJS.ErrnoException {
|
||||
return Object.assign(new Error(code), { code })
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
lstatSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync
|
||||
} from 'node:fs'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { assertAbsoluteManagedPath } from './managed-paths'
|
||||
|
||||
const linuxProcessIdentitySchema = z
|
||||
.object({
|
||||
pid: z.number().int().positive().safe(),
|
||||
starttime: z.string().regex(/^(?:0|[1-9][0-9]*)$/u),
|
||||
executablePath: z.string().min(1)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type LinuxProcessIdentity = z.infer<
|
||||
typeof linuxProcessIdentitySchema
|
||||
>
|
||||
|
||||
export type LinuxProcessInspectorOptions = {
|
||||
procRoot?: string
|
||||
kill?: (pid: number, signal: NodeJS.Signals | 0) => void
|
||||
fileSystem?: LinuxProcFileSystem
|
||||
}
|
||||
|
||||
export interface LinuxProcFileSystem {
|
||||
readText(path: string): string
|
||||
readLink(path: string): string
|
||||
realpath(path: string): string
|
||||
}
|
||||
|
||||
export class LinuxProcessInspector {
|
||||
readonly #procRoot: string
|
||||
readonly #kill: (pid: number, signal: NodeJS.Signals | 0) => void
|
||||
readonly #fileSystem: LinuxProcFileSystem
|
||||
|
||||
constructor(options: LinuxProcessInspectorOptions = {}) {
|
||||
this.#procRoot = resolve(options.procRoot ?? '/proc')
|
||||
this.#kill = options.kill ?? process.kill
|
||||
this.#fileSystem = options.fileSystem ?? nodeProcFileSystem
|
||||
}
|
||||
|
||||
inspect(pidInput: number): LinuxProcessIdentity | undefined {
|
||||
const pid = positivePid(pidInput)
|
||||
try {
|
||||
const starttime = parseStarttime(
|
||||
this.#fileSystem.readText(
|
||||
join(this.#procRoot, String(pid), 'stat')
|
||||
)
|
||||
)
|
||||
const executableLink = this.#fileSystem.readLink(
|
||||
join(this.#procRoot, String(pid), 'exe')
|
||||
)
|
||||
if (
|
||||
executableLink.includes('\0') ||
|
||||
!isAbsolute(executableLink) ||
|
||||
resolve(executableLink) !== executableLink
|
||||
) {
|
||||
throw new Error('Linux process executable link is malformed')
|
||||
}
|
||||
return linuxProcessIdentitySchema.parse({
|
||||
pid,
|
||||
starttime,
|
||||
executablePath: assertAbsoluteManagedPath(
|
||||
this.#fileSystem.realpath(executableLink)
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
if (isMissingProcessError(error)) {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
matches(expectedInput: LinuxProcessIdentity): boolean {
|
||||
const expected = linuxProcessIdentitySchema.parse(expectedInput)
|
||||
const actual = this.inspect(expected.pid)
|
||||
return (
|
||||
actual !== undefined &&
|
||||
actual.starttime === expected.starttime &&
|
||||
actual.executablePath === expected.executablePath
|
||||
)
|
||||
}
|
||||
|
||||
signal(
|
||||
expectedInput: LinuxProcessIdentity,
|
||||
signal: NodeJS.Signals
|
||||
): boolean {
|
||||
const expected = linuxProcessIdentitySchema.parse(expectedInput)
|
||||
if (!this.matches(expected)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
this.#kill(expected.pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function currentLinuxProcessIdentity(
|
||||
options: LinuxProcessInspectorOptions = {}
|
||||
): LinuxProcessIdentity {
|
||||
const identity = new LinuxProcessInspector(options).inspect(process.pid)
|
||||
if (identity === undefined) {
|
||||
throw new Error('Current Linux process identity is unavailable')
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
export function canonicalExecutablePath(path: string): string {
|
||||
const normalized = assertAbsoluteManagedPath(resolve(path))
|
||||
const stat = lstatSync(normalized)
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
throw new Error('Agent executable must be a regular file')
|
||||
}
|
||||
return assertAbsoluteManagedPath(realpathSync(normalized))
|
||||
}
|
||||
|
||||
function parseStarttime(contents: string): string {
|
||||
const close = contents.lastIndexOf(')')
|
||||
if (close < 2 || contents[close + 1] !== ' ') {
|
||||
throw new Error('Linux process stat is malformed')
|
||||
}
|
||||
const starttime = contents
|
||||
.slice(close + 2)
|
||||
.trim()
|
||||
.split(/\s+/u)[19]
|
||||
if (
|
||||
starttime === undefined ||
|
||||
!/^(?:0|[1-9][0-9]*)$/u.test(starttime)
|
||||
) {
|
||||
throw new Error('Linux process stat identity is malformed')
|
||||
}
|
||||
return starttime
|
||||
}
|
||||
|
||||
const nodeProcFileSystem: LinuxProcFileSystem = {
|
||||
readText: (path) => readFileSync(path, 'utf8'),
|
||||
readLink: (path) => readlinkSync(path),
|
||||
realpath: (path) => realpathSync(path)
|
||||
}
|
||||
|
||||
function positivePid(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error('Linux process ID is invalid')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function isMissingProcessError(error: unknown): boolean {
|
||||
return (
|
||||
isNodeError(error) &&
|
||||
(error.code === 'ENOENT' || error.code === 'ESRCH')
|
||||
)
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && 'code' in error
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertAbsoluteManagedPath,
|
||||
derivePrivateTemporaryRoot,
|
||||
ensurePrivateDirectory,
|
||||
ensurePrivateDirectoryTree,
|
||||
ensurePrivateTemporaryRoot,
|
||||
readPrivateFile,
|
||||
writePrivateFileAtomic
|
||||
} from './managed-paths'
|
||||
|
||||
const temporaryPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of temporaryPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('deterministic private temporary root', () => {
|
||||
it('binds a 0700 root marker to the UID and canonical home', () => {
|
||||
const temporaryDirectory = privateTemporaryDirectory()
|
||||
const identity = derivePrivateTemporaryRoot({
|
||||
platform: 'linux',
|
||||
uid: 1234,
|
||||
homeDirectory: resolve(temporaryDirectory, 'home'),
|
||||
temporaryDirectory
|
||||
})
|
||||
expect(identity.rootPath).toContain('goodbuddy-1234-')
|
||||
expect(ensurePrivateTemporaryRoot(identity)).toBe(identity.rootPath)
|
||||
const marker = readFileSync(
|
||||
resolve(identity.rootPath, '.goodbuddy-private-root-v1'),
|
||||
'utf8'
|
||||
)
|
||||
expect(JSON.parse(marker)).toEqual({
|
||||
formatVersion: 1,
|
||||
uid: 1234,
|
||||
canonicalHomeDirectory: resolve(temporaryDirectory, 'home')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an existing marker for another home', () => {
|
||||
const temporaryDirectory = privateTemporaryDirectory()
|
||||
const identity = derivePrivateTemporaryRoot({
|
||||
platform: 'linux',
|
||||
uid: 1234,
|
||||
homeDirectory: resolve(temporaryDirectory, 'home'),
|
||||
temporaryDirectory
|
||||
})
|
||||
ensurePrivateTemporaryRoot(identity)
|
||||
writeFileSync(
|
||||
resolve(identity.rootPath, '.goodbuddy-private-root-v1'),
|
||||
'{"formatVersion":1,"uid":1234,"canonicalHomeDirectory":"/foreign"}\n',
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
expect(() => ensurePrivateTemporaryRoot(identity)).toThrow(
|
||||
'does not match this user'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a corrupt or permissive marker without replacing it', () => {
|
||||
const temporaryDirectory = privateTemporaryDirectory()
|
||||
const identity = derivePrivateTemporaryRoot({
|
||||
platform: 'linux',
|
||||
uid: 1234,
|
||||
homeDirectory: resolve(temporaryDirectory, 'home'),
|
||||
temporaryDirectory
|
||||
})
|
||||
ensurePrivateTemporaryRoot(identity)
|
||||
const markerPath = resolve(
|
||||
identity.rootPath,
|
||||
'.goodbuddy-private-root-v1'
|
||||
)
|
||||
writeFileSync(markerPath, '{broken-json\n')
|
||||
expect(() => ensurePrivateTemporaryRoot(identity)).toThrow(
|
||||
'does not match this user'
|
||||
)
|
||||
expect(readFileSync(markerPath, 'utf8')).toBe('{broken-json\n')
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
writeFileSync(
|
||||
markerPath,
|
||||
`${JSON.stringify({
|
||||
formatVersion: 1,
|
||||
uid: identity.uid,
|
||||
canonicalHomeDirectory: identity.canonicalHomeDirectory
|
||||
}, null, 2)}\n`
|
||||
)
|
||||
chmodSync(markerPath, 0o644)
|
||||
expect(() => ensurePrivateTemporaryRoot(identity)).toThrow(
|
||||
'permissions must be 0600'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('managed path containment', () => {
|
||||
it('requires normalized absolute paths and rejects anchor escapes', () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
expect(() => assertAbsoluteManagedPath('relative')).toThrow(
|
||||
'normalized absolute'
|
||||
)
|
||||
expect(() =>
|
||||
assertAbsoluteManagedPath(`${resolve(root, 'entry')}\0tail`)
|
||||
).toThrow('cannot contain NUL')
|
||||
expect(() =>
|
||||
ensurePrivateDirectoryTree(resolve(root, '..', 'escape'), root)
|
||||
).toThrow('escapes its configured anchor')
|
||||
})
|
||||
|
||||
it('rejects files, permissive directories, and symlink traversal', () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const filePath = resolve(root, 'file')
|
||||
writeFileSync(filePath, 'not a directory')
|
||||
expect(() => ensurePrivateDirectory(filePath)).toThrow(
|
||||
'not a real directory'
|
||||
)
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
const permissive = resolve(root, 'permissive')
|
||||
mkdirSync(permissive, { mode: 0o755 })
|
||||
chmodSync(permissive, 0o755)
|
||||
expect(() => ensurePrivateDirectory(permissive)).toThrow(
|
||||
'permissions must be 0700'
|
||||
)
|
||||
}
|
||||
|
||||
const outside = privateTemporaryDirectory()
|
||||
const junction = resolve(root, 'junction')
|
||||
symlinkSync(outside, junction, 'junction')
|
||||
expect(() =>
|
||||
assertAbsoluteManagedPath(resolve(junction, 'managed.json'))
|
||||
).toThrow('cannot traverse a symlink')
|
||||
})
|
||||
|
||||
it('atomically writes only private regular targets and reads bounded bytes', () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const target = resolve(root, 'state.json')
|
||||
writePrivateFileAtomic(target, '{"state":"ready"}\n')
|
||||
expect(readPrivateFile(target, 64).toString('utf8')).toBe(
|
||||
'{"state":"ready"}\n'
|
||||
)
|
||||
expect(() => readPrivateFile(target, 4)).toThrow('size limit')
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(target, 0o644)
|
||||
expect(() => writePrivateFileAtomic(target, 'replacement')).toThrow(
|
||||
'permissions must be 0600'
|
||||
)
|
||||
expect(readFileSync(target, 'utf8')).toBe('{"state":"ready"}\n')
|
||||
}
|
||||
})
|
||||
|
||||
it('never writes through a symlinked component outside its root', () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const outside = privateTemporaryDirectory()
|
||||
const sentinel = resolve(outside, 'sentinel.txt')
|
||||
writeFileSync(sentinel, 'user-owned')
|
||||
const junction = resolve(root, 'junction')
|
||||
symlinkSync(outside, junction, 'junction')
|
||||
|
||||
expect(() =>
|
||||
writePrivateFileAtomic(resolve(junction, 'sentinel.txt'), 'changed')
|
||||
).toThrow('cannot traverse a symlink')
|
||||
expect(readFileSync(sentinel, 'utf8')).toBe('user-owned')
|
||||
expect(existsSync(resolve(root, 'sentinel.txt'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function privateTemporaryDirectory(): string {
|
||||
const path = mkdtempSync(resolve(tmpdir(), 'goodbuddy-paths-'))
|
||||
temporaryPaths.push(path)
|
||||
if (process.platform !== 'win32') {
|
||||
chmodSync(path, 0o700)
|
||||
}
|
||||
return resolve(path)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
constants,
|
||||
fchmodSync,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import type { Stats } from 'node:fs'
|
||||
import {
|
||||
dirname,
|
||||
isAbsolute,
|
||||
parse,
|
||||
relative,
|
||||
resolve,
|
||||
sep
|
||||
} from 'node:path'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
export class ManagedPathError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'ManagedPathError'
|
||||
}
|
||||
}
|
||||
|
||||
function assertOwner(stat: Stats, label: string): void {
|
||||
const uid = process.getuid?.()
|
||||
if (uid !== undefined && stat.uid !== uid) {
|
||||
throw new ManagedPathError(`${label} is not owned by the current user`)
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAbsoluteManagedPath(path: string): string {
|
||||
if (path.includes('\0')) {
|
||||
throw new ManagedPathError('Managed paths cannot contain NUL')
|
||||
}
|
||||
if (!isAbsolute(path) || resolve(path) !== path) {
|
||||
throw new ManagedPathError('Managed paths must be normalized absolute paths')
|
||||
}
|
||||
assertNoSymlinkComponents(path)
|
||||
return path
|
||||
}
|
||||
|
||||
export function ensurePrivateDirectory(
|
||||
path: string,
|
||||
options: { create?: boolean } = {}
|
||||
): void {
|
||||
assertAbsoluteManagedPath(path)
|
||||
let created = false
|
||||
try {
|
||||
const stat = lstatSync(path)
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
||||
throw new ManagedPathError('Managed directory is not a real directory')
|
||||
}
|
||||
assertOwner(stat, 'Managed directory')
|
||||
if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) {
|
||||
throw new ManagedPathError('Managed directory permissions must be 0700')
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ManagedPathError ||
|
||||
!isNodeError(error) ||
|
||||
error.code !== 'ENOENT' ||
|
||||
options.create === false
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
mkdirSync(path, { mode: 0o700 })
|
||||
created = true
|
||||
}
|
||||
if (created && process.platform !== 'win32') {
|
||||
chmodSync(path, 0o700)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoSymlinkComponents(path: string): void {
|
||||
const root = parse(path).root
|
||||
const components = relative(root, path).split(sep).filter(Boolean)
|
||||
let current = root
|
||||
for (const component of components) {
|
||||
current = resolve(current, component)
|
||||
try {
|
||||
if (lstatSync(current).isSymbolicLink()) {
|
||||
throw new ManagedPathError('Managed paths cannot traverse a symlink')
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ManagedPathError ||
|
||||
!isNodeError(error) ||
|
||||
error.code !== 'ENOENT'
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ensurePrivateDirectoryTree(path: string, anchor: string): void {
|
||||
const normalizedPath = assertAbsoluteManagedPath(path)
|
||||
const normalizedAnchor = assertAbsoluteManagedPath(anchor)
|
||||
if (
|
||||
normalizedPath !== normalizedAnchor &&
|
||||
!normalizedPath.startsWith(`${normalizedAnchor}${process.platform === 'win32' ? '\\' : '/'}`)
|
||||
) {
|
||||
throw new ManagedPathError('Managed path escapes its configured anchor')
|
||||
}
|
||||
const relative = normalizedPath.slice(normalizedAnchor.length)
|
||||
ensurePrivateDirectory(normalizedAnchor)
|
||||
let current = normalizedAnchor
|
||||
for (const segment of relative.split(/[\\/]/u).filter(Boolean)) {
|
||||
current = resolve(current, segment)
|
||||
ensurePrivateDirectory(current)
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPrivateRegularFile(path: string): void {
|
||||
assertAbsoluteManagedPath(path)
|
||||
const stat = lstatSync(path)
|
||||
assertPrivateRegularFileStat(stat)
|
||||
}
|
||||
|
||||
function assertPrivateRegularFileStat(stat: Stats): void {
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
throw new ManagedPathError('Managed file is not a regular file')
|
||||
}
|
||||
assertOwner(stat, 'Managed file')
|
||||
if (process.platform !== 'win32' && (stat.mode & 0o177) !== 0) {
|
||||
throw new ManagedPathError('Managed file permissions must be 0600')
|
||||
}
|
||||
}
|
||||
|
||||
export function readOrCreatePrivateSecret(path: string): Buffer {
|
||||
assertAbsoluteManagedPath(path)
|
||||
ensurePrivateDirectory(dirname(path), { create: false })
|
||||
const noFollow = constants.O_NOFOLLOW ?? 0
|
||||
let descriptor: number
|
||||
try {
|
||||
descriptor = openSync(
|
||||
path,
|
||||
constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | noFollow,
|
||||
0o600
|
||||
)
|
||||
const secret = randomBytes(32)
|
||||
try {
|
||||
writeFileSync(descriptor, secret)
|
||||
if (process.platform !== 'win32') {
|
||||
fchmodSync(descriptor, 0o600)
|
||||
}
|
||||
} finally {
|
||||
closeSync(descriptor)
|
||||
}
|
||||
return secret
|
||||
} catch (error) {
|
||||
if (!isNodeError(error) || error.code !== 'EEXIST') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
assertPrivateRegularFile(path)
|
||||
descriptor = openSync(path, constants.O_RDONLY | noFollow)
|
||||
try {
|
||||
const stat = fstatSync(descriptor)
|
||||
assertPrivateRegularFileStat(stat)
|
||||
if (stat.size !== 32) {
|
||||
throw new ManagedPathError('Installation identity must contain 32 bytes')
|
||||
}
|
||||
const output = Buffer.alloc(32)
|
||||
if (readSync(descriptor, output, 0, output.length, 0) !== output.length) {
|
||||
throw new ManagedPathError('Installation identity could not be read')
|
||||
}
|
||||
return output
|
||||
} finally {
|
||||
closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
export function readPrivateSecret(path: string): Buffer {
|
||||
const output = readPrivateFile(path, 32)
|
||||
if (output.byteLength !== 32) {
|
||||
throw new ManagedPathError('Installation identity must contain 32 bytes')
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export function readPrivateFile(path: string, maximumBytes: number): Buffer {
|
||||
assertAbsoluteManagedPath(path)
|
||||
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) {
|
||||
throw new ManagedPathError('Managed file size limit is invalid')
|
||||
}
|
||||
const noFollow = constants.O_NOFOLLOW ?? 0
|
||||
const descriptor = openSync(path, constants.O_RDONLY | noFollow)
|
||||
try {
|
||||
const stat = fstatSync(descriptor)
|
||||
assertPrivateRegularFileStat(stat)
|
||||
if (stat.size > maximumBytes) {
|
||||
throw new ManagedPathError('Managed file exceeds its size limit')
|
||||
}
|
||||
const output = Buffer.alloc(stat.size)
|
||||
if (
|
||||
stat.size > 0 &&
|
||||
readSync(descriptor, output, 0, output.length, 0) !== output.length
|
||||
) {
|
||||
throw new ManagedPathError('Managed file could not be read')
|
||||
}
|
||||
return output
|
||||
} finally {
|
||||
closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
export function writePrivateFileAtomic(path: string, contents: string): void {
|
||||
assertAbsoluteManagedPath(path)
|
||||
ensurePrivateDirectory(dirname(path), { create: false })
|
||||
assertPrivateTargetIfPresent(path)
|
||||
const temporary = `${path}.tmp-${randomBytes(12).toString('hex')}`
|
||||
const noFollow = constants.O_NOFOLLOW ?? 0
|
||||
const descriptor = openSync(
|
||||
temporary,
|
||||
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow,
|
||||
0o600
|
||||
)
|
||||
let descriptorOpen = true
|
||||
let renamed = false
|
||||
let operationError: unknown
|
||||
try {
|
||||
writeFileSync(descriptor, contents, { encoding: 'utf8' })
|
||||
if (process.platform !== 'win32') {
|
||||
fchmodSync(descriptor, 0o600)
|
||||
}
|
||||
closeSync(descriptor)
|
||||
descriptorOpen = false
|
||||
assertPrivateTargetIfPresent(path)
|
||||
renameSync(temporary, path)
|
||||
renamed = true
|
||||
assertPrivateRegularFile(path)
|
||||
} catch (error) {
|
||||
operationError = error
|
||||
}
|
||||
if (descriptorOpen) {
|
||||
try {
|
||||
closeSync(descriptor)
|
||||
} catch (error) {
|
||||
operationError ??= error
|
||||
}
|
||||
}
|
||||
if (!renamed) {
|
||||
try {
|
||||
unlinkSync(temporary)
|
||||
} catch (error) {
|
||||
if (!isNodeError(error) || error.code !== 'ENOENT') {
|
||||
operationError ??= error
|
||||
}
|
||||
}
|
||||
}
|
||||
if (operationError !== undefined) {
|
||||
throw operationError
|
||||
}
|
||||
}
|
||||
|
||||
export function unlinkOwnedPrivateFile(path: string): void {
|
||||
assertPrivateRegularFile(path)
|
||||
unlinkSync(path)
|
||||
}
|
||||
|
||||
const TEMP_ROOT_MARKER = '.goodbuddy-private-root-v1'
|
||||
|
||||
export type PrivateTemporaryRootIdentity = {
|
||||
rootPath: string
|
||||
uid: number
|
||||
canonicalHomeDirectory: string
|
||||
}
|
||||
|
||||
export function derivePrivateTemporaryRoot(options: {
|
||||
uid?: number
|
||||
homeDirectory?: string
|
||||
temporaryDirectory?: string
|
||||
platform?: NodeJS.Platform
|
||||
} = {}): PrivateTemporaryRootIdentity {
|
||||
const platform = options.platform ?? process.platform
|
||||
const uid = options.uid ?? process.getuid?.()
|
||||
if (
|
||||
platform !== 'linux' ||
|
||||
uid === undefined ||
|
||||
!Number.isSafeInteger(uid) ||
|
||||
uid < 0
|
||||
) {
|
||||
throw new ManagedPathError(
|
||||
'Private Agent endpoint root requires a current Linux UID'
|
||||
)
|
||||
}
|
||||
const canonicalHomeDirectory = resolve(
|
||||
options.homeDirectory ?? homedir()
|
||||
)
|
||||
const temporaryDirectory = resolve(
|
||||
options.temporaryDirectory ?? '/tmp'
|
||||
)
|
||||
assertAbsoluteManagedPath(canonicalHomeDirectory)
|
||||
assertAbsoluteManagedPath(temporaryDirectory)
|
||||
const homeHash = createHash('sha256')
|
||||
.update(`${uid}\0${canonicalHomeDirectory}`, 'utf8')
|
||||
.digest('hex')
|
||||
.slice(0, 20)
|
||||
return {
|
||||
rootPath: resolve(
|
||||
temporaryDirectory,
|
||||
`goodbuddy-${uid}-${homeHash}`
|
||||
),
|
||||
uid,
|
||||
canonicalHomeDirectory
|
||||
}
|
||||
}
|
||||
|
||||
export function ensurePrivateTemporaryRoot(
|
||||
identityInput: PrivateTemporaryRootIdentity
|
||||
): string {
|
||||
const identity = {
|
||||
rootPath: assertAbsoluteManagedPath(identityInput.rootPath),
|
||||
uid: identityInput.uid,
|
||||
canonicalHomeDirectory: assertAbsoluteManagedPath(
|
||||
identityInput.canonicalHomeDirectory
|
||||
)
|
||||
}
|
||||
if (!Number.isSafeInteger(identity.uid) || identity.uid < 0) {
|
||||
throw new ManagedPathError('Private Agent endpoint root UID is invalid')
|
||||
}
|
||||
ensurePrivateDirectory(identity.rootPath)
|
||||
const markerPath = resolve(identity.rootPath, TEMP_ROOT_MARKER)
|
||||
const expected = `${JSON.stringify({
|
||||
formatVersion: 1,
|
||||
uid: identity.uid,
|
||||
canonicalHomeDirectory: identity.canonicalHomeDirectory
|
||||
}, null, 2)}\n`
|
||||
try {
|
||||
assertPrivateRegularFile(markerPath)
|
||||
if (readPrivateText(markerPath) !== expected) {
|
||||
throw new ManagedPathError(
|
||||
'Private Agent endpoint root marker does not match this user'
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isNodeError(error) || error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
writePrivateFileAtomic(markerPath, expected)
|
||||
}
|
||||
return identity.rootPath
|
||||
}
|
||||
|
||||
function readPrivateText(path: string): string {
|
||||
return readPrivateFile(path, 16 * 1024).toString('utf8')
|
||||
}
|
||||
|
||||
function assertPrivateTargetIfPresent(path: string): void {
|
||||
try {
|
||||
assertPrivateRegularFile(path)
|
||||
} catch (error) {
|
||||
if (!isNodeError(error) || error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && 'code' in error
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
type Stats,
|
||||
unlinkSync
|
||||
} from 'node:fs'
|
||||
import {
|
||||
createConnection,
|
||||
createServer,
|
||||
type Server,
|
||||
type Socket
|
||||
} from 'node:net'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { TextDecoder } from 'node:util'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
REMOTE_MODEL_GATEWAY_LIMITS,
|
||||
remoteModelGatewayRequestSchema,
|
||||
remoteModelGatewayResponseSchema,
|
||||
type RemoteModelGatewayRequest,
|
||||
type RemoteModelGatewayResponse
|
||||
} from '../shared/remote-model-gateway-contracts'
|
||||
import {
|
||||
assertAbsoluteManagedPath,
|
||||
ensurePrivateDirectory
|
||||
} from './managed-paths'
|
||||
import { AgentUnsupportedError } from './errors'
|
||||
import {
|
||||
boundedInteger,
|
||||
closeServer,
|
||||
isNodeError,
|
||||
raceWithAbort,
|
||||
settleWithin
|
||||
} from './async-utils'
|
||||
|
||||
export const MODEL_BRIDGE_BROKER_SOCKET_NAME =
|
||||
'model-bridge.sock'
|
||||
|
||||
const BROKER_PROTOCOL_VERSION = 1
|
||||
const DEFAULT_MAXIMUM_CONNECTIONS = 8
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 150_000
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
|
||||
const CLOSE_TIMEOUT_MS = 2_000
|
||||
const MAXIMUM_UNIX_SOCKET_PATH_BYTES = 107
|
||||
const MAXIMUM_FRAME_BYTES =
|
||||
Math.ceil(
|
||||
REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes / 3
|
||||
) *
|
||||
4 +
|
||||
64 * 1_024
|
||||
|
||||
const requestIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u)
|
||||
|
||||
const brokerRequestPacketSchema = z
|
||||
.object({
|
||||
version: z.literal(BROKER_PROTOCOL_VERSION),
|
||||
type: z.literal('model-request'),
|
||||
requestId: requestIdSchema,
|
||||
request: remoteModelGatewayRequestSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
const brokerSuccessPacketSchema = z
|
||||
.object({
|
||||
version: z.literal(BROKER_PROTOCOL_VERSION),
|
||||
type: z.literal('model-response'),
|
||||
requestId: requestIdSchema,
|
||||
ok: z.literal(true),
|
||||
response: remoteModelGatewayResponseSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
const brokerFailureCodeSchema = z.enum([
|
||||
'request-invalid',
|
||||
'request-timeout',
|
||||
'request-cancelled',
|
||||
'dispatch-failed'
|
||||
])
|
||||
|
||||
const brokerFailurePacketSchema = z
|
||||
.object({
|
||||
version: z.literal(BROKER_PROTOCOL_VERSION),
|
||||
type: z.literal('model-response'),
|
||||
requestId: requestIdSchema,
|
||||
ok: z.literal(false),
|
||||
error: z
|
||||
.object({
|
||||
code: brokerFailureCodeSchema
|
||||
})
|
||||
.strict()
|
||||
})
|
||||
.strict()
|
||||
|
||||
const brokerResponsePacketSchema = z.discriminatedUnion('ok', [
|
||||
brokerSuccessPacketSchema,
|
||||
brokerFailurePacketSchema
|
||||
])
|
||||
|
||||
const brokerDeliveryPacketSchema = z
|
||||
.object({
|
||||
version: z.literal(BROKER_PROTOCOL_VERSION),
|
||||
type: z.literal('response-delivered'),
|
||||
requestId: requestIdSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
const brokerDeliveryAcceptedPacketSchema = z
|
||||
.object({
|
||||
version: z.literal(BROKER_PROTOCOL_VERSION),
|
||||
type: z.literal('delivery-accepted'),
|
||||
requestId: requestIdSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ModelBridgeDispatchContext = {
|
||||
requestId: string
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
export type ModelBridgeExchangeResult = {
|
||||
response: RemoteModelGatewayResponse
|
||||
acknowledgeDelivery(): Promise<void>
|
||||
failDelivery(reason?: unknown): void | Promise<void>
|
||||
}
|
||||
|
||||
export type ModelBridgeBrokerDispatch = (
|
||||
request: RemoteModelGatewayRequest,
|
||||
context: ModelBridgeDispatchContext
|
||||
) => Promise<ModelBridgeExchangeResult | RemoteModelGatewayResponse>
|
||||
|
||||
export type ModelBridgeExchange = (
|
||||
request: RemoteModelGatewayRequest,
|
||||
context: ModelBridgeDispatchContext
|
||||
) => Promise<ModelBridgeExchangeResult | RemoteModelGatewayResponse>
|
||||
|
||||
export class ModelBridgeBrokerError extends Error {
|
||||
readonly code:
|
||||
| 'request-invalid'
|
||||
| 'request-timeout'
|
||||
| 'request-cancelled'
|
||||
| 'dispatch-failed'
|
||||
| 'transport-failed'
|
||||
|
||||
constructor(code: ModelBridgeBrokerError['code']) {
|
||||
super(modelBridgeBrokerErrorMessage(code))
|
||||
this.name = 'ModelBridgeBrokerError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export class ModelBridgeBrokerServer {
|
||||
readonly socketPath: string
|
||||
readonly #dispatch: ModelBridgeBrokerDispatch
|
||||
readonly #maximumConnections: number
|
||||
readonly #requestTimeoutMs: number
|
||||
readonly #connections = new Set<Socket>()
|
||||
#server?: Server
|
||||
#socketIdentity?: FileIdentity
|
||||
#closing = false
|
||||
#closePromise?: Promise<void>
|
||||
|
||||
constructor(options: {
|
||||
scratchDirectory: string
|
||||
dispatch: ModelBridgeBrokerDispatch
|
||||
maximumConnections?: number
|
||||
requestTimeoutMs?: number
|
||||
}) {
|
||||
const scratchDirectory = assertAbsoluteManagedPath(
|
||||
options.scratchDirectory
|
||||
)
|
||||
this.socketPath = assertAbsoluteManagedPath(
|
||||
join(scratchDirectory, MODEL_BRIDGE_BROKER_SOCKET_NAME)
|
||||
)
|
||||
if (
|
||||
process.platform !== 'win32' &&
|
||||
Buffer.byteLength(this.socketPath, 'utf8') >
|
||||
MAXIMUM_UNIX_SOCKET_PATH_BYTES
|
||||
) {
|
||||
throw new Error('Model bridge endpoint path is too long')
|
||||
}
|
||||
this.#dispatch = options.dispatch
|
||||
this.#maximumConnections = boundedInteger(
|
||||
options.maximumConnections ?? DEFAULT_MAXIMUM_CONNECTIONS,
|
||||
1,
|
||||
64,
|
||||
'Model bridge connection limit'
|
||||
)
|
||||
this.#requestTimeoutMs = boundedInteger(
|
||||
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
1,
|
||||
300_000,
|
||||
'Model bridge request timeout'
|
||||
)
|
||||
}
|
||||
|
||||
async listen(): Promise<void> {
|
||||
if (process.platform === 'win32') {
|
||||
throw new AgentUnsupportedError(
|
||||
'Unix model bridge brokers are unavailable on Windows',
|
||||
'platform-incompatible'
|
||||
)
|
||||
}
|
||||
if (this.#server !== undefined || this.#closing) {
|
||||
throw new Error('Model bridge broker is already active')
|
||||
}
|
||||
ensurePrivateDirectory(dirname(this.socketPath), {
|
||||
create: false
|
||||
})
|
||||
removeOwnedStaleSocket(this.socketPath)
|
||||
|
||||
const server = createServer(
|
||||
{ pauseOnConnect: true },
|
||||
(socket) => {
|
||||
if (
|
||||
this.#closing ||
|
||||
this.#connections.size >= this.#maximumConnections
|
||||
) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
this.#connections.add(socket)
|
||||
socket.once('close', () => {
|
||||
this.#connections.delete(socket)
|
||||
})
|
||||
void this.#serveOne(socket)
|
||||
}
|
||||
)
|
||||
this.#server = server
|
||||
try {
|
||||
await listenOnUnixSocket(server, this.socketPath)
|
||||
const initialStat = lstatSync(this.socketPath)
|
||||
this.#socketIdentity = fileIdentity(initialStat)
|
||||
assertOwnedSocket(initialStat, 'Model bridge endpoint')
|
||||
chmodSync(this.socketPath, 0o600)
|
||||
const securedStat = lstatSync(this.socketPath)
|
||||
assertSameFile(securedStat, this.#socketIdentity)
|
||||
assertOwnedSocket(securedStat, 'Model bridge endpoint')
|
||||
if ((securedStat.mode & 0o777) !== 0o600) {
|
||||
throw new Error('Model bridge endpoint mode verification failed')
|
||||
}
|
||||
} catch (error) {
|
||||
await this.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.#closePromise !== undefined) {
|
||||
await this.#closePromise
|
||||
return
|
||||
}
|
||||
const closePromise = this.#close()
|
||||
this.#closePromise = closePromise
|
||||
try {
|
||||
await closePromise
|
||||
} finally {
|
||||
if (this.#closePromise === closePromise) {
|
||||
this.#closePromise = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #close(): Promise<void> {
|
||||
const server = this.#server
|
||||
this.#server = undefined
|
||||
this.#closing = true
|
||||
try {
|
||||
for (const connection of this.#connections) {
|
||||
connection.destroy()
|
||||
}
|
||||
await settleWithin(
|
||||
server === undefined
|
||||
? Promise.resolve()
|
||||
: closeServer(server),
|
||||
CLOSE_TIMEOUT_MS
|
||||
)
|
||||
} finally {
|
||||
this.#connections.clear()
|
||||
removeSocketIfIdentityMatches(
|
||||
this.socketPath,
|
||||
this.#socketIdentity
|
||||
)
|
||||
this.#socketIdentity = undefined
|
||||
this.#closing = false
|
||||
}
|
||||
}
|
||||
|
||||
async #serveOne(socket: Socket): Promise<void> {
|
||||
const cancellation = new AbortController()
|
||||
const onClose = (): void => {
|
||||
cancellation.abort(
|
||||
new ModelBridgeBrokerError('request-cancelled')
|
||||
)
|
||||
}
|
||||
socket.once('close', onClose)
|
||||
const timeout = setTimeout(() => {
|
||||
cancellation.abort(
|
||||
new ModelBridgeBrokerError('request-timeout')
|
||||
)
|
||||
}, this.#requestTimeoutMs)
|
||||
timeout.unref?.()
|
||||
|
||||
let requestId = `invalid-${randomUUID()}`
|
||||
let dispatched: ModelBridgeExchangeResult | undefined
|
||||
let delivered = false
|
||||
try {
|
||||
const packet = brokerRequestPacketSchema.parse(
|
||||
await readPacket(socket, cancellation.signal)
|
||||
)
|
||||
requestId = packet.requestId
|
||||
dispatched = normalizeExchangeResult(await raceWithAbort(
|
||||
this.#dispatch(packet.request, {
|
||||
requestId,
|
||||
signal: cancellation.signal
|
||||
}),
|
||||
cancellation.signal
|
||||
))
|
||||
const response = remoteModelGatewayResponseSchema.parse(
|
||||
dispatched.response
|
||||
)
|
||||
cancellation.signal.throwIfAborted()
|
||||
await writePacket(socket, {
|
||||
version: BROKER_PROTOCOL_VERSION,
|
||||
type: 'model-response',
|
||||
requestId,
|
||||
ok: true,
|
||||
response
|
||||
})
|
||||
const delivery = brokerDeliveryPacketSchema.parse(
|
||||
await readPacket(socket, cancellation.signal)
|
||||
)
|
||||
if (delivery.requestId !== requestId) {
|
||||
throw new ModelBridgeBrokerError('transport-failed')
|
||||
}
|
||||
await raceWithAbort(
|
||||
Promise.resolve(dispatched.acknowledgeDelivery()),
|
||||
cancellation.signal
|
||||
)
|
||||
delivered = true
|
||||
await writePacket(socket, {
|
||||
version: BROKER_PROTOCOL_VERSION,
|
||||
type: 'delivery-accepted',
|
||||
requestId
|
||||
})
|
||||
} catch (error) {
|
||||
if (dispatched !== undefined && !delivered) {
|
||||
await Promise.resolve(
|
||||
dispatched.failDelivery(error)
|
||||
).catch(() => undefined)
|
||||
} else if (!socket.destroyed) {
|
||||
await writeFailure(
|
||||
socket,
|
||||
requestId,
|
||||
failureCode(error, cancellation.signal)
|
||||
).catch(() => undefined)
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
socket.off('close', onClose)
|
||||
if (!socket.destroyed) {
|
||||
socket.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createUnixModelBridgeExchange(options: {
|
||||
socketPath: string
|
||||
connectTimeoutMs?: number
|
||||
requestTimeoutMs?: number
|
||||
}): ModelBridgeExchange {
|
||||
const socketPath = assertAbsoluteManagedPath(options.socketPath)
|
||||
const connectTimeoutMs = boundedInteger(
|
||||
options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
1,
|
||||
60_000,
|
||||
'Model bridge connect timeout'
|
||||
)
|
||||
const requestTimeoutMs = boundedInteger(
|
||||
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
1,
|
||||
300_000,
|
||||
'Model bridge request timeout'
|
||||
)
|
||||
|
||||
return async (request, context) => {
|
||||
const parsedRequest = remoteModelGatewayRequestSchema.parse(request)
|
||||
const requestId = requestIdSchema.parse(context.requestId)
|
||||
context.signal.throwIfAborted()
|
||||
const socket = createConnection(socketPath)
|
||||
const timeoutController = new AbortController()
|
||||
const timeout = setTimeout(() => {
|
||||
timeoutController.abort(
|
||||
new ModelBridgeBrokerError('request-timeout')
|
||||
)
|
||||
socket.destroy()
|
||||
}, requestTimeoutMs)
|
||||
timeout.unref?.()
|
||||
const onAbort = (): void => {
|
||||
socket.destroy()
|
||||
}
|
||||
context.signal.addEventListener('abort', onAbort, { once: true })
|
||||
let responseHandedOff = false
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timeout)
|
||||
context.signal.removeEventListener('abort', onAbort)
|
||||
socket.destroy()
|
||||
}
|
||||
try {
|
||||
await waitForConnection(
|
||||
socket,
|
||||
Math.min(connectTimeoutMs, requestTimeoutMs),
|
||||
context.signal
|
||||
)
|
||||
await writePacket(socket, {
|
||||
version: BROKER_PROTOCOL_VERSION,
|
||||
type: 'model-request',
|
||||
requestId,
|
||||
request: parsedRequest
|
||||
})
|
||||
const packet = brokerResponsePacketSchema.parse(
|
||||
await readPacket(
|
||||
socket,
|
||||
AbortSignal.any([
|
||||
context.signal,
|
||||
timeoutController.signal
|
||||
])
|
||||
)
|
||||
)
|
||||
if (packet.requestId !== requestId) {
|
||||
throw new ModelBridgeBrokerError('transport-failed')
|
||||
}
|
||||
if (!packet.ok) {
|
||||
throw new ModelBridgeBrokerError(packet.error.code)
|
||||
}
|
||||
responseHandedOff = true
|
||||
let deliveryFinalized = false
|
||||
return {
|
||||
response: packet.response,
|
||||
acknowledgeDelivery: async () => {
|
||||
if (deliveryFinalized) {
|
||||
throw new ModelBridgeBrokerError('transport-failed')
|
||||
}
|
||||
deliveryFinalized = true
|
||||
await writePacket(socket, {
|
||||
version: BROKER_PROTOCOL_VERSION,
|
||||
type: 'response-delivered',
|
||||
requestId
|
||||
})
|
||||
const accepted = brokerDeliveryAcceptedPacketSchema.parse(
|
||||
await readPacket(
|
||||
socket,
|
||||
AbortSignal.any([
|
||||
context.signal,
|
||||
timeoutController.signal
|
||||
])
|
||||
)
|
||||
)
|
||||
if (accepted.requestId !== requestId) {
|
||||
throw new ModelBridgeBrokerError('transport-failed')
|
||||
}
|
||||
cleanup()
|
||||
},
|
||||
failDelivery: () => {
|
||||
deliveryFinalized = true
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (context.signal.aborted) {
|
||||
throw new ModelBridgeBrokerError('request-cancelled')
|
||||
}
|
||||
if (timeoutController.signal.aborted) {
|
||||
throw new ModelBridgeBrokerError('request-timeout')
|
||||
}
|
||||
if (error instanceof ModelBridgeBrokerError) {
|
||||
throw error
|
||||
}
|
||||
throw new ModelBridgeBrokerError('transport-failed')
|
||||
} finally {
|
||||
if (!responseHandedOff) {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type BrokerFailureCode = z.infer<
|
||||
typeof brokerFailureCodeSchema
|
||||
>
|
||||
|
||||
type FileIdentity = {
|
||||
dev: bigint
|
||||
ino: bigint
|
||||
}
|
||||
|
||||
function failureCode(
|
||||
error: unknown,
|
||||
signal: AbortSignal
|
||||
): BrokerFailureCode {
|
||||
const reason = signal.reason
|
||||
if (
|
||||
signal.aborted &&
|
||||
reason instanceof ModelBridgeBrokerError &&
|
||||
reason.code === 'request-timeout'
|
||||
) {
|
||||
return 'request-timeout'
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return 'request-cancelled'
|
||||
}
|
||||
if (error instanceof z.ZodError || error instanceof SyntaxError) {
|
||||
return 'request-invalid'
|
||||
}
|
||||
return 'dispatch-failed'
|
||||
}
|
||||
|
||||
async function writeFailure(
|
||||
socket: Socket,
|
||||
requestId: string,
|
||||
code: BrokerFailureCode
|
||||
): Promise<void> {
|
||||
await writePacket(socket, {
|
||||
version: BROKER_PROTOCOL_VERSION,
|
||||
type: 'model-response',
|
||||
requestId,
|
||||
ok: false,
|
||||
error: { code }
|
||||
})
|
||||
}
|
||||
|
||||
async function readPacket(
|
||||
socket: Socket,
|
||||
signal: AbortSignal
|
||||
): Promise<unknown> {
|
||||
const lengthBytes = await readExactly(socket, 4, signal)
|
||||
const length = lengthBytes.readUInt32BE(0)
|
||||
if (length < 2 || length > MAXIMUM_FRAME_BYTES) {
|
||||
throw new ModelBridgeBrokerError('request-invalid')
|
||||
}
|
||||
const contents = await readExactly(socket, length, signal)
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
contents
|
||||
)
|
||||
return JSON.parse(text) as unknown
|
||||
}
|
||||
|
||||
async function writePacket(
|
||||
socket: Socket,
|
||||
value: unknown
|
||||
): Promise<void> {
|
||||
const contents = Buffer.from(JSON.stringify(value), 'utf8')
|
||||
if (
|
||||
contents.byteLength < 2 ||
|
||||
contents.byteLength > MAXIMUM_FRAME_BYTES
|
||||
) {
|
||||
throw new ModelBridgeBrokerError('request-invalid')
|
||||
}
|
||||
const packet = Buffer.allocUnsafe(contents.byteLength + 4)
|
||||
packet.writeUInt32BE(contents.byteLength, 0)
|
||||
contents.copy(packet, 4)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.write(packet, (error) => {
|
||||
if (error === undefined || error === null) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function readExactly(
|
||||
socket: Socket,
|
||||
byteLength: number,
|
||||
signal: AbortSignal
|
||||
): Promise<Buffer> {
|
||||
const chunks: Buffer[] = []
|
||||
let remaining = byteLength
|
||||
while (remaining > 0) {
|
||||
signal.throwIfAborted()
|
||||
const readableBytes = Math.min(
|
||||
remaining,
|
||||
socket.readableLength
|
||||
)
|
||||
if (readableBytes > 0) {
|
||||
const chunk = socket.read(readableBytes) as Buffer | null
|
||||
if (chunk === null || chunk.byteLength !== readableBytes) {
|
||||
throw new ModelBridgeBrokerError('transport-failed')
|
||||
}
|
||||
chunks.push(chunk)
|
||||
remaining -= chunk.byteLength
|
||||
continue
|
||||
}
|
||||
if (socket.readableEnded || socket.destroyed) {
|
||||
throw new ModelBridgeBrokerError('transport-failed')
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onReadable = (): void => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const onClose = (): void => {
|
||||
cleanup()
|
||||
reject(new ModelBridgeBrokerError('transport-failed'))
|
||||
}
|
||||
const onError = (): void => {
|
||||
cleanup()
|
||||
reject(new ModelBridgeBrokerError('transport-failed'))
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
cleanup()
|
||||
reject(signal.reason)
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
socket.off('readable', onReadable)
|
||||
socket.off('close', onClose)
|
||||
socket.off('end', onClose)
|
||||
socket.off('error', onError)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
socket.once('readable', onReadable)
|
||||
socket.once('close', onClose)
|
||||
socket.once('end', onClose)
|
||||
socket.once('error', onError)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
if (socket.readableLength > 0) {
|
||||
onReadable()
|
||||
return
|
||||
}
|
||||
if (socket.readableEnded || socket.destroyed) {
|
||||
onClose()
|
||||
}
|
||||
})
|
||||
}
|
||||
return Buffer.concat(chunks, byteLength)
|
||||
}
|
||||
|
||||
async function waitForConnection(
|
||||
socket: Socket,
|
||||
timeoutMs: number,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onConnect = (): void => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const onError = (): void => {
|
||||
cleanup()
|
||||
reject(new ModelBridgeBrokerError('transport-failed'))
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
cleanup()
|
||||
reject(new ModelBridgeBrokerError('request-cancelled'))
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
socket.off('connect', onConnect)
|
||||
socket.off('error', onError)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new ModelBridgeBrokerError('request-timeout'))
|
||||
}, timeoutMs)
|
||||
timeout.unref?.()
|
||||
socket.once('connect', onConnect)
|
||||
socket.once('error', onError)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
function listenOnUnixSocket(
|
||||
server: Server,
|
||||
socketPath: string
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error): void => {
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
const onListening = (): void => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
server.off('error', onError)
|
||||
server.off('listening', onListening)
|
||||
}
|
||||
server.once('error', onError)
|
||||
server.once('listening', onListening)
|
||||
server.listen(socketPath)
|
||||
})
|
||||
}
|
||||
|
||||
function removeOwnedStaleSocket(path: string): void {
|
||||
let stat: Stats
|
||||
try {
|
||||
stat = lstatSync(path)
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
assertOwnedSocket(stat, 'Existing model bridge endpoint')
|
||||
if ((stat.mode & 0o777) !== 0o600) {
|
||||
throw new Error(
|
||||
'Refusing to replace an insecure model bridge endpoint'
|
||||
)
|
||||
}
|
||||
unlinkSync(path)
|
||||
}
|
||||
|
||||
function removeSocketIfIdentityMatches(
|
||||
path: string,
|
||||
identity: FileIdentity | undefined
|
||||
): void {
|
||||
if (identity === undefined) {
|
||||
return
|
||||
}
|
||||
let stat: Stats
|
||||
try {
|
||||
stat = lstatSync(path)
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (
|
||||
stat.isSocket() &&
|
||||
isOwnedByCurrentUser(stat) &&
|
||||
sameFile(stat, identity)
|
||||
) {
|
||||
unlinkSync(path)
|
||||
}
|
||||
}
|
||||
|
||||
function assertOwnedSocket(stat: Stats, label: string): void {
|
||||
if (
|
||||
stat.isSymbolicLink() ||
|
||||
!stat.isSocket() ||
|
||||
!isOwnedByCurrentUser(stat)
|
||||
) {
|
||||
throw new Error(`${label} is not an owned Unix socket`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertSameFile(
|
||||
stat: Stats,
|
||||
identity: FileIdentity
|
||||
): void {
|
||||
if (!sameFile(stat, identity)) {
|
||||
throw new Error('Model bridge endpoint changed during setup')
|
||||
}
|
||||
}
|
||||
|
||||
function sameFile(stat: Stats, identity: FileIdentity): boolean {
|
||||
return (
|
||||
BigInt(stat.dev) === identity.dev &&
|
||||
BigInt(stat.ino) === identity.ino
|
||||
)
|
||||
}
|
||||
|
||||
function fileIdentity(stat: Stats): FileIdentity {
|
||||
return {
|
||||
dev: BigInt(stat.dev),
|
||||
ino: BigInt(stat.ino)
|
||||
}
|
||||
}
|
||||
|
||||
function isOwnedByCurrentUser(stat: Stats): boolean {
|
||||
const uid = process.getuid?.()
|
||||
return uid === undefined || stat.uid === uid
|
||||
}
|
||||
|
||||
|
||||
function modelBridgeBrokerErrorMessage(
|
||||
code: ModelBridgeBrokerError['code']
|
||||
): string {
|
||||
switch (code) {
|
||||
case 'request-invalid':
|
||||
return 'Model bridge request was rejected'
|
||||
case 'request-timeout':
|
||||
return 'Model bridge request timed out'
|
||||
case 'request-cancelled':
|
||||
return 'Model bridge request was cancelled'
|
||||
case 'dispatch-failed':
|
||||
return 'Model bridge dispatch failed'
|
||||
case 'transport-failed':
|
||||
return 'Model bridge transport failed'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExchangeResult(
|
||||
value: ModelBridgeExchangeResult | RemoteModelGatewayResponse
|
||||
): ModelBridgeExchangeResult {
|
||||
if (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'response' in value &&
|
||||
typeof value.acknowledgeDelivery === 'function' &&
|
||||
typeof value.failDelivery === 'function'
|
||||
) {
|
||||
return value
|
||||
}
|
||||
return {
|
||||
response: remoteModelGatewayResponseSchema.parse(value),
|
||||
acknowledgeDelivery: async () => undefined,
|
||||
failDelivery: () => undefined
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user