Compare commits

...
21 Commits
Author SHA1 Message Date
mesalogo 38f6a23306 chore: prepare GoodBuddy 0.11.6 release notes
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Add the approved Simplified Chinese and English release notes for sidebar brand customization, independent Anthropic output limits, and restored composer and brand guidance.
2026-08-27 12:56:51 +08:00
mesalogo 12463ad593 feat: add sidebar brand customization
The primary sidebar brand was fixed to the built-in name, subtitle, and logo. Appearance settings now let users preview and save a custom brand name, localized Chinese and English subtitles, and a validated local PNG, JPEG, or WebP logo, with default-logo and full-reset actions.

Customization remains device-local and limited to the top-left brand area; installer, update, Agent, and protocol identities remain GoodBuddy. The desktop package version is now 0.11.6.

Release note: 新增“外观 → 品牌定制”,可修改左上角品牌名称、中英文副标题和本地 Logo,并支持实时预览、恢复默认及本机持久化。
2026-08-27 12:42:26 +08:00
mesalogo 618e505b8b fix: restore the workspace brand subtitle
The primary sidebar showed only the GoodBuddy name after its supporting brand line was removed. It now displays the localized “智能工作台” / “Intelligent Workspace” subtitle beneath the product name again.

Release note: 恢复左上角 GoodBuddy 标题下方的“智能工作台”副标题,使品牌区域的信息层级更加完整。
2026-08-27 10:30:15 +08:00
mesalogo 0a3bf6094a fix: restore composer guidance in input hint
The composer placeholder had been reduced to only the message prompt, hiding send, newline, and paste guidance unless users hovered the field. The full localized keyboard guidance now appears directly in the empty text area again for both chat and image generation.

Release note: 恢复聊天输入框中的发送、换行和粘贴快捷键提示,空输入框会再次直接显示完整操作说明。
2026-08-27 10:14:39 +08:00
mesalogo 957e2ecde7 fix: align homepage feature checklist
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
Wrapped feature rows were centered independently, which shifted the lower-right item away from the column above it. The Chinese homepage now uses a shared two-column grid and collapses to one aligned column on narrow screens.

Release note: 官网首页特性列表现在按列对齐,桌面与移动端布局更整齐。
2026-08-27 08:04:36 +08:00
mesalogo 9316fed3e3 fix: streamline homepage introduction
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
The homepage hero paragraph and page description now focus on GoodBuddy's desktop assistant and Agent Runtime capabilities. Download options and architecture support remain unchanged.

Release note: 官网首页产品介绍现更聚焦桌面助手与 Agent Runtime 能力。
2026-08-27 07:54:04 +08:00
mesalogo d6fea0c6c2 fix: simplify LoongArch download copy
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
The Linux download card now presents only the preview version, DEB format, size, and checksum. Preview index validation and fail-closed download behavior are unchanged.

Release note: 官网龙芯下载信息现仅展示版本、包格式、大小与校验值。
2026-08-27 07:45:03 +08:00
mesalogo d9a554b4b1 feat: add LoongArch preview download to the website
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Deploy website to GitHub Pages / Deploy static website (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
The Chinese Linux download card now includes a LoongArch processor option and fixes its package type to DEB. It reads a separate strictly validated OSS preview index, while the formal six-target release index and English GitHub downloads remain unchanged.

The page keeps three symmetric platform cards, disables only the LoongArch selection when its index is unavailable, and labels the package as unverified on real LoongArch hardware.

Release note: 官网 Linux 下载新增龙芯 LoongArch 处理器选项,选择后提供独立 DEB 预览包;正式下载与更新索引保持不变。
2026-08-26 23:23:27 +08:00
mesalogo b20c10f820 fix: separate Anthropic output and context limits
Direct Anthropic connections reused the configured context window as max_tokens, which could impose an unintended output value or send a provider-invalid request. Anthropic connections now expose an independent per-response maximum output setting, defaulting to 32K for the protocol-required field, while OpenAI direct requests remain without an added output token cap and direct tool rounds remain unmetered.

Release note: Anthropic 直连模型现在可单独配置单次最大输出,不再复用上下文窗口;OpenAI 直连请求和工具调用轮次不增加额外配额。
2026-08-26 22:53:49 +08:00
mesalogo 49700bc7cf fix: make LoongArch preview checksum portable
Generate SHA256SUMS from inside the timestamped artifact directory so it records only the DEB filename and remains verifiable after copying the preview output elsewhere.
2026-08-26 17:12:20 +08:00
mesalogo fcdcc8e081 fix: finalize 0.11.5 compatibility paths
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Legacy key-only environment deployments retain their historical model endpoint, name, and protocol while generic model variables continue to take precedence. Agent inventory scanning now overlaps the signed catalog check instead of waiting for the network first.

The separate LoongArch preview builder derives node-pty from the candidate lockfile and verifies the installed version before cross-compiling it, preventing stale preview-only pins.
2026-08-26 17:01:49 +08:00
mesalogo de64e9768a fix: make Agent updates reliable and visible
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Valid Agent packages could be rejected when packaged trusted-key JSON used CRLF, and Settings could not show whether a newer compatible Agent existed. Registry formatting is now parsed independently of cryptographic trust, and Settings reads only the signed catalog to report local and latest versions.

New installations use a credential-free local Ollama-compatible profile. The v20 migration changes only untouched historical defaults, while explicit profiles and legacy environment aliases remain compatible. Magic Notes count badges no longer shift navigation rows.

Release note: 修复 Agent 下载兼容性并在设置中显示更新状态;新安装默认连接本地模型,保留显式用户配置;魔法笔记待办数字不再改变侧栏菜单高度。
2026-08-26 15:43:41 +08:00
mesalogo f9137f0463 fix: restore cross-platform release packaging
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
The unpublished 0.11.3 candidate could not validate the DeepSeek Harness node-pty prebuilds on every native target, and catalog signing could misread large compound Agent payloads. Release packaging now verifies the rc.8 native layout and writes deterministic stored .gbagent archives with bounded central-directory, mode, CRC, SHA-256, and signature checks.

The 0.11.4 recovery also pins reviewed dependency lifecycle scripts, packages the omitted ONNX Runtime license, and carries the approved unpublished notes forward without changing the immutable failed tags or adding a LoongArch package.

Release note: 修复跨平台桌面包和大文件 Agent 包的发行校验失败,并将未发布的 0.11.3 内容顺延至 0.11.4。
2026-08-26 01:15:34 +08:00
mesalogo 798ac814a9 fix: update bundled DeepSeek Harness to rc.8
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
GoodBuddy Agent Release / Authorize Agent release (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux arm64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Build Linux x64 compound package (push) Canceled after 0s
GoodBuddy Agent Release / Sign cumulative Agent catalog (push) Canceled after 0s
GoodBuddy Agent Release / Publish Agent to GitHub and OSS (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
The desktop runtime still pinned the rc.6 Harness family. It now uses the coherent rc.8 package set, adapts the revised image-attachment limits and error contract, and derives release checks from the lockfile. The managed SSH Agent package remains unchanged.

Release note: 内置 DeepSeek Harness 已升级至 0.1.0-rc.8,并适配新版图片附件限制;托管 SSH Agent 包不受影响。
2026-08-25 23:26:43 +08:00
mesalogo 6913e9ce16 fix: stabilize extension startup deadline validation
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
The total-deadline test relied on a timer firing at an exact millisecond boundary, so a shared runner could legally begin the second extension before observing expiration. Use the existing finite synchronous-work fixture to exceed the total budget deterministically while preserving the same production behavior assertion.
2026-08-25 22:30:52 +08:00
mesalogo 9aca3b2627 docs: trim GoodBuddy 0.11.3 release notes
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Remove the Agent package verification and installation bullet from both language versions while keeping the remaining approved 0.11.3 notes aligned.
2026-08-25 22:19:53 +08:00
mesalogo 0cb7007429 fix: unify GoodBuddy release signing identity
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Use one generic GoodBuddy Ed25519 identity for the Agent bundle, bundled Runtime, outer package, and signed catalog while preserving each layer's existing signature domain.

Rename the production Environment contract to GOODBUDDY_SIGNING_*, register the matching production public key, and keep desktop and branch CI workflows isolated from the private key.
2026-08-25 22:02:47 +08:00
mesalogo bcd347e092 fix: align Unix endpoint tests with platform behavior
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Node and libuv remove a bound Unix socket path when the server closes. The removed test required protecting a same-user replacement inside the Agent-owned 0700 directory, which would need disproportionate custom socket publication machinery without crossing a real trust boundary.

Keep the practical live-endpoint and shutdown lifecycle coverage instead.
2026-08-25 21:31:47 +08:00
mesalogo 9c50de521e fix: stabilize Linux release validation
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Ubuntu validation exposed a missing Unix socket test import, a platform-dependent cancellation-message assertion, and a maximum-frame test that could exceed the default timeout under shared-runner load. The tests now exercise the intended behavior consistently without changing product code.
2026-08-25 21:21:58 +08:00
mesalogo da00a850ec fix: report verified Agent CI archives
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
The native Linux CI builder completed both deterministic package assemblies but read an obsolete nested return field while producing its result JSON. It now reports the archive name from the actual assembleAgentPackage result and guards that shape in workflow tests.
2026-08-25 21:13:46 +08:00
mesalogo fcedaf578e fix: recover managed SSH Agent package release
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build Linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build Linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Linux .gbagent extraction inherited permissive directory modes, so compound packages failed secure Runtime verification on POSIX runners. Package roots and nested bundle directories now use private modes.

The production workflow now uses one GoodBuddy Agent signing identity for the Agent, bundled Runtime, outer package, and catalog. The unpublished 0.11.2 candidate is carried forward as desktop and Agent 0.11.3 without a LoongArch preview.

Release note: 修复 Linux 上 Agent 包解压后无法通过权限验证的问题,并将复合包各层统一为一组 GoodBuddy Agent 发布密钥。
2026-08-25 20:53:28 +08:00
103 changed files with 4247 additions and 1249 deletions
+6 -10
View File
@@ -189,25 +189,21 @@ jobs:
}
NODE
- name: Preflight production signing identities
- name: Preflight production GoodBuddy signing identity
shell: bash
env:
GOODBUDDY_AGENT_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_AGENT_SIGNING_KEY_ID }}
GOODBUDDY_REMOTE_RUNTIME_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_REMOTE_RUNTIME_SIGNING_KEY_ID }}
GOODBUDDY_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_SIGNING_KEY_ID }}
run: |
set -euo pipefail
node build/agent-bundle.cjs preflight
node build/remote-runtime-bundle.cjs preflight
- name: Build production compound Agent package
shell: bash
env:
AGENT_ARCH: ${{ matrix.arch }}
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
GOODBUDDY_AGENT_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_AGENT_SIGNING_KEY_ID }}
GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY: ${{ secrets.GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY }}
GOODBUDDY_REMOTE_RUNTIME_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_REMOTE_RUNTIME_SIGNING_KEY_ID }}
GOODBUDDY_REMOTE_RUNTIME_SIGNING_PRIVATE_KEY: ${{ secrets.GOODBUDDY_REMOTE_RUNTIME_SIGNING_PRIVATE_KEY }}
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: |
@@ -348,8 +344,8 @@ jobs:
env:
AGENT_VERSION: ${{ needs.authorize.outputs.version }}
PREVIOUS_ARGUMENTS: ${{ steps.previous.outputs.arguments }}
GOODBUDDY_AGENT_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_AGENT_SIGNING_KEY_ID }}
GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY: ${{ secrets.GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY }}
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")"
+5 -5
View File
@@ -227,12 +227,12 @@ Release note: 修复左上角项目设置与消息通道项目设置不一致的
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, production Agent and
Runtime signing-key preflights, a signed cumulative catalog, a non-Latest
GitHub Agent Release, and synchronized immutable Beijing OSS objects plus the
final signed latest-catalog pointer.
`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 Agent signing
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
+16 -9
View File
@@ -139,13 +139,20 @@ npm run dist:linux:arm64
组件,缺少远端包也不能阻塞普通桌面打包或发布。
“设置 > 平台功能 > 远程项目(技术预览)”列出用户数据目录中已经下载或导入的
Linux x64/arm64 复合 Agent 包,并分别显示 `已下载并验证 / 未下载 / 校验失败`。
在线“下载/检查并更新”必须由用户显式触发,来源跟随“关于与更新”的 `github` 或
`mirror` 选择;打开远程项目不会自动下载。用户也可导入或导出 `.gbagent` 离线包。
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,不使用 MD5。
“SSH 主机 > 远程运行环境”的“更新版本”只使用已验证的本地复合包。对应 Host 架构
尚未下载时会提示先到设置下载或导入,不会联网,也不会影响本地项目、普通桌面功能或
其他架构的 Agent 包。
@@ -197,14 +204,14 @@ Linux x64/arm64 复合包。每个 job 从 `agent-runtime-lock.json` 解析官
发布路径:只接受指向受保护 `main` 历史的 annotated
`agent-v${agent-runtime-lock.agentVersion}` 标签,在 `agent-signing` Environment 中
原生构建两种架构,生成并签名累计目录,然后同步到非 Latest 的 GitHub Agent Release
与北京 OSS。该 Environment 保护以下两组变量与 Secret:
与北京 OSS。该 Environment 只保护一组 GoodBuddy 通用发布变量与 Secret:
- `GOODBUDDY_AGENT_SIGNING_KEY_ID` /
`GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY`
- `GOODBUDDY_REMOTE_RUNTIME_SIGNING_KEY_ID` /
`GOODBUDDY_REMOTE_RUNTIME_SIGNING_PRIVATE_KEY`
- `GOODBUDDY_SIGNING_KEY_ID` /
`GOODBUDDY_SIGNING_PRIVATE_KEY`
私钥只注入对应签名步骤;预检只读取 key ID 并确认公钥已在
该 GoodBuddy 身份统一签署内部 Agent、固定 Runtime manifest、外层 `.gbagent`
描述符和累计目录,各层通过既有签名域区分用途,不再要求内部 Runtime 单独配置生产
身份。私钥只注入签名步骤;预检只读取 key ID 并确认公钥已在
`resources/agent-release-keys.json` 注册为 production 且未撤销。
累计目录使用 `agent-catalog.json` 与 `agent-catalog.sig`。每个条目绑定 Agent 版本、
+2 -2
View File
@@ -23,14 +23,14 @@
- [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。缺少某架构包只使该架构托管 SSH 不可用。持久化验证继续精确绑定 Agent installation 与签名 Runtime digest;托管 SSH 会话只显示支持的 OpenCode 选择。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 已转为正式 Agent 0.11.2。
- [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 不可用。持久化验证继续精确绑定 Agent installation 与签名 Runtime digest;托管 SSH 会话只显示支持的 OpenCode 选择。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 已转为正式 Agent 0.11.5。
- [x] **SSH Host 运行环境手动更新**:远程运行环境卡片只读取 Host 已安装 Agent/Runtime 与当前本地复合包所需版本,不会联网。用户显式触发后,Main 按 Agent、Runtime 顺序使用对应 Host 架构的已验证本地 `.gbagent` 强制重新校验并安装,显示阶段进度并允许在最终收尾前取消;尚未下载时提示先到平台功能设置下载或导入。更新期间保留旧版本卡片,失败或取消后刷新实际状态并允许重试;成功后定向清理该 Host 缓存并使引用项目重新验证,不删除 Host 配置、凭据、项目设置或 Workspace 文件。
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权。
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
- [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 原生会话持久化仍不开放。
+1 -1
View File
@@ -20,7 +20,7 @@ GoodBuddy是一个开箱即用的 AI 生产平台,安全、跨平台、本地
- **安全执行**:`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、对话、任务、活动、成果、记忆、魔法笔记和智能心跳;可从稳定的本地会话复制当前聊天内容并创建互不影响的会话分支。
- **远程通道**:支持微信 ClawBot、企业微信和钉钉,每个发送者使用独立远程会话。
+9 -1
View File
@@ -298,6 +298,13 @@
失败或中断使用危险色,等待审批使用警告色,暂停或取消使用禁用色;非完成态旁边同时
显示本地化状态文字,已完成文案保留给辅助技术而不在紧凑元数据中重复显示,不能只靠
颜色或动效表达状态。
- 左上角品牌区允许用户在“外观”设置中定制品牌名称、中英文副标题和单个 Logo。定制只
影响该品牌区,不替换安装包名称、更新协议、Agent 标识或应用正文中的 GoodBuddy 产品
身份。副标题按当前界面语言显示,留空时隐藏且不保留空行。
- 品牌设置必须提供同布局预览、显式保存、默认 Logo 和整组恢复默认操作。自定义 Logo 仅
接受用户本机选择的 PNG、JPEG 或 WebP 内联数据,宽高不超过 `4096 × 4096`、总像素不
超过 1600 万且大小不超过 `512 KB`;不得读取远程 Logo URL。名称或副标题溢出品牌区时
单行截断,不能撑宽或挤压侧栏。
- 账户与设置入口固定在侧栏底部。已有稳定设置入口时,不在顶栏重复提供同一入口。
### 6.9 助手工作栏
@@ -619,6 +626,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
- 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。
- 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。
@@ -639,7 +647,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。
- “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。
- 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64/arm64 Agent 包清单分成独立卡片;清单延迟到首次打开该页签时由 Main 完整校验,明确区分“已下载并验证”“未下载”和“校验失败”,提供手动下载/检查并更新、离线导入/导出与就地刷新,并且不混入 Host 已安装状态或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口,可恢复错误留在当前卡片。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64/arm64 Agent 包清单分成独立卡片;清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图,失败时保留草稿并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.11.2",
"agentVersion": "0.11.5",
"protocol": {
"major": 2,
"minor": 0
+16 -10
View File
@@ -47,9 +47,9 @@ const manifestFileName = 'manifest.json'
const signatureFileName = 'manifest.sig'
const koffiPackageName = 'koffi'
const signingPrivateKeyEnvironment =
'GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY'
'GOODBUDDY_SIGNING_PRIVATE_KEY'
const signingKeyIdEnvironment =
'GOODBUDDY_AGENT_SIGNING_KEY_ID'
'GOODBUDDY_SIGNING_KEY_ID'
const semanticVersionPattern =
/^(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
@@ -372,7 +372,7 @@ function productionSigningKey(environment = process.env) {
const privateKey = environment[signingPrivateKeyEnvironment]
if (!keyId || !privateKey) {
throw new Error(
`${signingKeyIdEnvironment} and ${signingPrivateKeyEnvironment} are required for production Agent signing`
`${signingKeyIdEnvironment} and ${signingPrivateKeyEnvironment} are required for production GoodBuddy signing`
)
}
return { keyId, privateKey }
@@ -385,11 +385,11 @@ function preflightProductionSigningKey(options = {}) {
const registry =
options.registry ?? readTrustedKeyRegistry(projectRoot)
return preflightRegisteredProductionKey({
component: 'Agent',
component: 'GoodBuddy',
keyId,
registry,
missingKeyIdMessage:
`${signingKeyIdEnvironment} is not configured; provision the production Agent signing key ID before building a release`
`${signingKeyIdEnvironment} is not configured; provision the production GoodBuddy signing key ID before building a release`
})
}
@@ -427,7 +427,7 @@ function trustedKeyForManifest(
(candidate) => candidate.keyId === key.keyId
)
if (revocation) {
throw new Error(`Agent signing key is revoked: ${key.keyId}`)
throw new Error(`GoodBuddy signing key is revoked: ${key.keyId}`)
}
return key
}
@@ -791,7 +791,9 @@ function buildAgentBundle(options) {
options.registry ?? readTrustedKeyRegistry(projectRoot)
const signingEnvironment = options.environment ?? process.env
const signingIdentity =
options.testSigningIdentity ?? productionSigningKey(signingEnvironment)
options.testSigningIdentity ??
options.signingIdentity ??
productionSigningKey(signingEnvironment)
const signingRecord = options.testSigningIdentity
? registry.keys.find(
(key) => key.keyId === signingIdentity.keyId
@@ -812,10 +814,13 @@ function buildAgentBundle(options) {
publicKeySpkiBase64(signingIdentity.privateKey) !==
signingRecord.publicKeySpkiBase64
) {
throw new Error('Agent signing private key does not match the trusted key')
throw new Error(
'GoodBuddy signing private key does not match the trusted key'
)
}
if (
!options.testSigningIdentity &&
!options.signingIdentity &&
signingEnvironment === process.env
) {
delete process.env[signingPrivateKeyEnvironment]
@@ -1102,7 +1107,7 @@ function parseArguments(argv) {
}
if (command === 'preflight') {
if (Object.keys(options).length > 0) {
throw new Error('Agent signing preflight does not accept arguments')
throw new Error('GoodBuddy signing preflight does not accept arguments')
}
return { command }
}
@@ -1115,7 +1120,7 @@ function main(argv = process.argv.slice(2)) {
if (options.command === 'preflight') {
const signingRecord = preflightProductionSigningKey()
console.log(
`Production Agent signing registry preflight passed: ${signingRecord.keyId}`
`Production GoodBuddy signing registry preflight passed: ${signingRecord.keyId}`
)
return
}
@@ -1166,6 +1171,7 @@ module.exports = {
createAgentArchive,
createManifest,
detectElfArchitecture,
expectedManifestMode,
importAgentArchive,
lockedRuntimeInput,
preflightProductionSigningKey,
+216 -113
View File
@@ -6,18 +6,14 @@ const {
} = require('node:crypto')
const {
closeSync,
fstatSync,
openSync,
readFileSync,
readSync,
statSync,
writeFileSync
} = require('node:fs')
const { basename, resolve } = require('node:path')
const {
Unzip,
UnzipInflate,
UnzipPassThrough
} = require('fflate')
const { crc32 } = require('node:zlib')
const {
productionSigningKey,
publicKeySpkiBase64,
@@ -29,6 +25,11 @@ const {
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'
@@ -41,6 +42,11 @@ 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
@@ -183,14 +189,6 @@ function assertPackageDescriptor(descriptor) {
function readPackageMetadata(archivePath, registry) {
const archive = resolve(archivePath)
const status = statSync(archive)
if (
!status.isFile() ||
status.size <= 0 ||
status.size > maximumPackageBytes
) {
throw new Error('Agent package archive is not a bounded file')
}
const streamed = streamPackageMetadata(archive)
const descriptorBytes = streamed.descriptor
const signatureBytes = streamed.signature
@@ -225,7 +223,7 @@ function readPackageMetadata(archivePath, registry) {
return {
archive: expectedArchive,
descriptor,
size: status.size,
size: streamed.size,
sha256: streamed.sha256
}
}
@@ -282,127 +280,232 @@ function verifyPackageFileInventory(descriptor, actualFiles) {
}
}
function streamPackageMetadata(archivePath) {
const handle = openSync(archivePath, 'r')
const hash = createHash('sha256')
const buffer = Buffer.allocUnsafe(64 * 1024)
const values = new Map()
const files = new Map()
function centralDirectoryEntries(handle, archiveSize) {
const parsed = readZipCentralDirectory(handle, archiveSize, {
label: 'Agent package',
minimumEntries: 1,
maximumEntries: maximumPackageEntries,
maximumCentralDirectoryBytes
})
const entries = []
const seen = new Set()
let fatalError
let entries = 0
let expandedBytes = 0
const unzip = new Unzip((file) => {
entries += 1
if (entries > 50_002) {
fatalError ??= new Error(
'Agent package contains too many entries'
)
file.terminate()
return
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(file.name) || seen.has(file.name)) {
fatalError ??= new Error(
if (
!safePackagePath(entry.name) ||
seen.has(entry.name)
) {
throw new Error(
'Agent package contains an unsafe or duplicate path'
)
file.terminate()
return
}
seen.add(file.name)
const wanted =
file.name === 'agent-package.json' ||
file.name === 'agent-package.sig'
if (wanted && values.has(file.name)) {
fatalError ??= new Error(
'Agent package contains duplicate metadata'
seen.add(entry.name)
expandedBytes += entry.size
if (expandedBytes > maximumExpandedPackageBytes) {
throw new Error(
'Agent package expanded payload exceeds its limit'
)
file.terminate()
return
}
const chunks = []
let size = 0
const fileHash = createHash('sha256')
file.ondata = (error, data, final) => {
if (error) {
fatalError ??= error
return
}
size += data.byteLength
expandedBytes += data.byteLength
if (
size > 384 * 1024 * 1024 ||
expandedBytes > 1024 * 1024 * 1024
) {
fatalError ??= new Error(
'Agent package expanded payload exceeds its limit'
)
file.terminate()
return
}
fileHash.update(data)
if (wanted) {
if (size > 1024 * 1024) {
fatalError ??= new Error(
'Agent package metadata exceeds its limit'
)
file.terminate()
return
}
chunks.push(Buffer.from(data))
}
if (final) {
files.set(file.name, {
size,
sha256: fileHash.digest('hex')
})
if (wanted) {
values.set(file.name, Buffer.concat(chunks, size))
}
}
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')
}
try {
file.start()
} catch (error) {
fatalError ??= error
file.terminate()
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')
}
})
unzip.register(UnzipPassThrough)
unzip.register(UnzipInflate)
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 {
while (true) {
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,
buffer.length,
null
Math.min(buffer.length, initialStatus.size - position),
position
)
if (bytesRead === 0) {
unzip.push(new Uint8Array(), true)
break
if (bytesRead <= 0) {
throw new Error('Agent package archive is truncated')
}
const chunk = Uint8Array.from(
buffer.subarray(0, bytesRead)
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'
)
hash.update(chunk)
unzip.push(chunk, false)
if (fatalError) {
throw fatalError
}
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)
}
if (fatalError) {
throw fatalError
}
return {
descriptor: values.get('agent-package.json'),
signature: values.get('agent-package.sig'),
files,
sha256: hash.digest('hex')
}
}
function catalogEntry(metadata) {
+1 -1
View File
@@ -186,7 +186,7 @@ async function buildCiAgentBundle(options) {
remoteRuntimeVersion:
runtimeLock.runtimes.opencode.version,
architecture: options.arch,
archive: second.package.archive,
archive: second.archive,
archiveSha256: archiveDigest
}
} finally {
+146 -62
View File
@@ -19,9 +19,10 @@ const {
} = require('node:fs')
const { basename, dirname, join, relative, resolve, sep } = require('node:path')
const { tmpdir } = require('node:os')
const { Zip, ZipPassThrough } = require('fflate')
const { crc32 } = require('node:zlib')
const {
buildAgentBundle,
expectedManifestMode: expectedAgentManifestMode,
preflightProductionSigningKey,
productionSigningKey,
publicKeySpkiBase64,
@@ -36,6 +37,11 @@ const {
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'
@@ -44,7 +50,10 @@ const signatureDomain = Buffer.from(
'GoodBuddy Agent Package Descriptor Signature v1\0',
'utf8'
)
const archiveEpoch = new Date('1980-01-01T00:00:00.000Z')
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
@@ -89,10 +98,11 @@ function listFiles(directory) {
)
}
function sha256File(filePath) {
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(
@@ -105,12 +115,17 @@ function sha256File(filePath) {
if (bytesRead === 0) {
break
}
hash.update(buffer.subarray(0, bytesRead))
const chunk = buffer.subarray(0, bytesRead)
hash.update(chunk)
checksum = crc32(chunk, checksum)
}
} finally {
closeSync(handle)
}
return hash.digest('hex')
return {
crc32: checksum >>> 0,
sha256: hash.digest('hex')
}
}
function copyTree(source, destination) {
@@ -122,6 +137,13 @@ function copyTree(source, destination) {
}
}
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'))
@@ -157,20 +179,18 @@ function stagePackagePayload(options) {
}
function payloadFiles(staging) {
return listFiles(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: sha256File(filePath),
mode:
path === 'agent/goodbuddy-agent' ||
path === 'agent/node' ||
/\/bin\/opencode$/u.test(path)
? '0755'
: '0644'
sha256: digests.sha256,
mode: packageFileMode(path)
}
})
.filter(
@@ -178,18 +198,44 @@ function payloadFiles(staging) {
file.path !== packageDescriptorName &&
file.path !== packageSignatureName
)
return { crc32ByPath, files }
}
function createPackageArchive(staging, output) {
function createPackageArchive(
staging,
output,
knownCrc32 = new Map()
) {
mkdirSync(dirname(output), { recursive: true })
const outputHandle = openSync(output, 'w')
let archiveError
let completed = false
const archive = new Zip((error, data, final) => {
if (error) {
archiveError ??= error
return
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(
@@ -199,69 +245,101 @@ function createPackageArchive(staging, output) {
data.byteLength - offset
)
if (written <= 0) {
archiveError ??= new Error(
'Agent package archive write made no progress'
)
return
throw new Error('Agent package archive write made no progress')
}
offset += written
}
completed ||= final
})
archiveHash.update(data)
outputOffset += data.byteLength
}
const buffer = Buffer.allocUnsafe(1024 * 1024)
try {
for (const filePath of listFiles(staging)) {
const name = relative(staging, filePath).split(sep).join('/')
const input = new ZipPassThrough(name)
input.mtime = archiveEpoch
archive.add(input)
const inputHandle = openSync(filePath, 'r')
const fileSize = statSync(filePath).size
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
if (fileSize === 0) {
input.push(new Uint8Array(), true)
}
while (position < fileSize) {
while (position < entry.size) {
const bytesRead = readSync(
inputHandle,
buffer,
0,
buffer.length,
Math.min(buffer.length, entry.size - position),
null
)
if (bytesRead <= 0) {
throw new Error(
`Agent package source read made no progress: ${name}`
`Agent package source read made no progress: ${entry.name}`
)
}
position += bytesRead
input.push(
Uint8Array.from(buffer.subarray(0, bytesRead)),
position === fileSize
)
writeBytes(buffer.subarray(0, bytesRead))
}
} finally {
closeSync(inputHandle)
}
if (archiveError) {
throw archiveError
}
}
archive.end()
if (archiveError) {
throw archiveError
}
if (!completed) {
throw new Error('Agent package archive did not finish')
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)
} catch (error) {
archive.terminate()
throw error
} finally {
closeSync(outputHandle)
}
return {
size: outputOffset,
sha256: archiveHash.digest('hex')
}
}
function assembleAgentPackage(options) {
@@ -345,6 +423,7 @@ function assembleAgentPackage(options) {
agentBundle: resolve(options.agentBundle),
runtimeBundle: runtime.bundleDirectory
})
const payload = payloadFiles(staging)
const initial = {
format: 'goodbuddy-agent-package',
formatVersion: 1,
@@ -364,7 +443,7 @@ function assembleAgentPackage(options) {
protocol: runtime.manifest.protocol
},
contentDigest: `sha256:${'0'.repeat(64)}`,
files: payloadFiles(staging)
files: payload.files
}
const descriptor = {
...initial,
@@ -382,12 +461,16 @@ function assembleAgentPackage(options) {
`${signature.toString('base64')}\n`,
'utf8'
)
createPackageArchive(staging, options.output)
const archive = createPackageArchive(
staging,
options.output,
payload.crc32ByPath
)
return {
descriptor,
archive: basename(options.output),
size: statSync(options.output).size,
sha256: sha256File(options.output)
size: archive.size,
sha256: archive.sha256
}
} finally {
rmSync(staging, { recursive: true, force: true })
@@ -400,6 +483,7 @@ function buildAgentPackage(options) {
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}-`)
@@ -417,7 +501,7 @@ function buildAgentPackage(options) {
...(options.agentLock ? { lock: options.agentLock } : {}),
...(options.testSigningIdentity
? { testSigningIdentity: options.testSigningIdentity }
: {})
: { signingIdentity })
})
const runtime = buildRuntimeBundle({
projectRoot,
@@ -428,7 +512,7 @@ function buildAgentPackage(options) {
...(options.runtimeLock ? { lock: options.runtimeLock } : {}),
...(options.testSigningIdentity
? { testSigningIdentity: options.testSigningIdentity }
: {})
: { signingIdentity })
})
return assembleAgentPackage({
...options,
+53 -97
View File
@@ -5,6 +5,7 @@ const {
createWriteStream,
existsSync,
closeSync,
fstatSync,
mkdirSync,
mkdtempSync,
openSync,
@@ -38,6 +39,9 @@ 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(
@@ -59,11 +63,17 @@ 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',
@@ -516,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}`
}
}
@@ -994,7 +1005,7 @@ function verifyHarnessPackage(
'DeepSeek Harness Canvas'
)
if (options.platform === 'darwin') {
if (options.platform === 'macos') {
const helper = join(
unpackedRoot,
'node_modules',
@@ -1268,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) {
@@ -1692,6 +1647,7 @@ module.exports = {
replaceOutput,
stageTargetRuntimeDependencies,
targetRuntimePackageNames,
targetHarnessPaths,
verifyHarnessPackage,
verifyArchiveIntegrity,
verifyUnpackedOutput,
+1 -1
View File
@@ -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: [],
+1 -1
View File
@@ -68,7 +68,7 @@ The preview:
- uses a separate application name and user-data directory;
- includes the direct model path and Continue runtime;
- cross-builds node-pty against Electron 42.3.0;
- 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.
+9 -3
View File
@@ -9,7 +9,6 @@ output_root="${3:-/preview-output}"
electron_version="42.3.0"
electron_sha256="92b0ca0c9c18ed90166918a4ac1970266c4fa967aee9277031b3b250b905526e"
koffi_version="3.1.4"
node_pty_version="1.1.0"
node_gyp_version="11.4.2"
preview_iteration="1"
application_directory="GoodBuddy-LoongArch-Preview"
@@ -68,6 +67,10 @@ 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"
@@ -171,7 +174,7 @@ tar \
koffi_binding="${koffi_target}/linux_loong64/koffi.node"
assert_loongarch_elf "${koffi_binding}"
echo "== cross-build node-pty for Electron ${electron_version} =="
echo "== cross-build node-pty ${node_pty_version} for Electron ${electron_version} =="
node_gyp_root="${work_root}/node-gyp"
npm install \
--ignore-scripts \
@@ -297,7 +300,10 @@ assert_loongarch_elf \
assert_loongarch_elf \
"${verify_root}/opt/${application_directory}/resources/app/${koffi_binding#${app_root}/}"
sha256sum "${artifact}" > "${artifact_root}/SHA256SUMS"
(
cd "${artifact_root}"
sha256sum "${artifact_name}" > SHA256SUMS
)
cat > "${artifact_root}/preview-manifest.json" <<EOF
{
"formatVersion": 1,
+7 -6
View File
@@ -44,9 +44,9 @@ const supportedArchitectures = Object.freeze(['x64', 'arm64'])
const manifestFileName = 'manifest.json'
const signatureFileName = 'manifest.sig'
const signingPrivateKeyEnvironment =
'GOODBUDDY_REMOTE_RUNTIME_SIGNING_PRIVATE_KEY'
'GOODBUDDY_SIGNING_PRIVATE_KEY'
const signingKeyIdEnvironment =
'GOODBUDDY_REMOTE_RUNTIME_SIGNING_KEY_ID'
'GOODBUDDY_SIGNING_KEY_ID'
const signatureDomain = Buffer.from(
'GoodBuddy Remote Runtime Bundle Manifest Signature v1\0',
'utf8'
@@ -265,7 +265,7 @@ function productionSigningIdentity(environment = process.env) {
const privateKey = environment[signingPrivateKeyEnvironment]
if (!keyId || !privateKey) {
throw new Error(
`${signingKeyIdEnvironment} and ${signingPrivateKeyEnvironment} are required for production Runtime signing`
`${signingKeyIdEnvironment} and ${signingPrivateKeyEnvironment} are required to sign the bundled Runtime`
)
}
return { keyId, privateKey }
@@ -318,11 +318,11 @@ function preflightProductionSigningKey(options = {}) {
const registry =
options.registry ?? readTrustedKeyRegistry(projectRoot)
return preflightRegisteredProductionKey({
component: 'Runtime',
component: 'GoodBuddy',
keyId,
registry,
missingKeyIdMessage:
`${signingKeyIdEnvironment} is not configured; provision the production Runtime signing key ID before building a release`
`${signingKeyIdEnvironment} is not configured; provision the production GoodBuddy signing key ID before building a release`
})
}
@@ -768,6 +768,7 @@ function buildRuntimeBundle(options) {
options.registry ?? readTrustedKeyRegistry(projectRoot)
const signingIdentity =
options.testSigningIdentity ??
options.signingIdentity ??
productionSigningIdentity(options.environment ?? process.env)
const verificationEnvironment =
options.testSigningIdentity ? 'test' : 'production'
@@ -1050,7 +1051,7 @@ function main(argv = process.argv.slice(2)) {
if (options.command === 'preflight') {
const record = preflightProductionSigningKey()
console.log(
`Production Runtime signing preflight passed: ${record.keyId}`
`Production GoodBuddy signing preflight passed for bundled Runtime: ${record.keyId}`
)
return
}
+1 -1
View File
@@ -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'
},
+141
View File
@@ -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,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 入口。
@@ -2,7 +2,7 @@
## 状态
本文记录当前代码实现,不定义额外的信任框架。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 后,当前源码 lock 已转为正式 Agent `0.11.2`,尚未复用旧版本号。远程 OpenCode 功能仍处于发布前验证阶段;公开签名 key registry 已供应,当前发布门槛是通过独立 Agent workflow 正式发布并公开验证 Linux x64/arm64 复合包和签名累计目录。
本文记录当前代码实现,不定义额外的信任框架。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 后,当前源码 lock 已转为正式 Agent `0.11.5`。失败的 `agent-v0.11.3` 保持不可变且未发布。远程 OpenCode 功能仍处于发布前验证阶段;公开签名 key registry 已供应,当前发布门槛是通过独立 Agent workflow 正式发布并公开验证 Linux x64/arm64 复合包和签名累计目录。
## 产品语义
@@ -65,10 +65,10 @@ Detached GoodBuddy Agent
- SSH Host 设置通过固定探针和有界 SFTP 显示 Host 已登记的 Agent/OpenCode 版本,并与当前 GoodBuddy 所需版本比较;打开设置或“刷新版本”只读取状态,不安装或切换远端组件。
- “平台功能 > 远程项目”中的本地 Agent 包清单与 Host 状态分离。Main 对用户数据目录中
Linux x64/arm64 `.gbagent` 分别执行外层可信签名、桌面/协议兼容性、内部 Agent/Runtime
签名、架构与完整 payload 校验;Renderer 只得到版本、架构、远端 Runtime 版本、协议和
`verified | not-downloaded | invalid`,不得到缓存路径、key ID 或 digest。在线下载只由
用户点击触发,并使用“关于与更新”选择的 GitHub 或北京 OSS 来源;离线导入/导出通过
Main 管理的文件对话框完成。
签名、架构与完整 payload 校验;首次打开和手动刷新还会读取“关于与更新”所选来源的
小型签名目录。Renderer 只得到本地/在线版本、是否有更新、架构、远端 Runtime 版本、
协议和本地状态,不得到缓存路径、key ID 或 digest。目录检查不下载 `.gbagent`,在线
包下载只由用户点击带目标版本的操作触发;离线导入/导出通过 Main 管理的文件对话框完成。
- 当任一组件缺失或不是当前版本时,用户可显式选择“更新版本”。Main 使用同一个取消信号依次强制执行 Agent、Runtime 安装器,并把 Agent、Runtime、Finalizing 阶段只发送给发起更新的 Renderer。更新启动仍受技术预览开关和可信 Sender 校验约束;取消不受开关约束,确保操作开始后即使关闭功能也能停止。
- 更新成功后才定向清理该 Host 的 Agent 连接和 Runtime 选择缓存,并让当前引用项目重新激活;失败或取消保留 Host 配置、凭据、项目、Workspace 和可继续验证的旧组件,Renderer 刷新实际版本并允许重试。同一 Renderer 同时只更新一个 Host,窗口销毁或 Main 退出会取消活动更新并执行有界收尾。
- 显式 stop、升级、身份冲突或进程退出时清理 GoodBuddy 自己的 socket、状态和子进程;不得删除或覆盖无关 Host 文件。
@@ -170,7 +170,7 @@ Execute 不经过 Ask 的 bubblewrap profile:
- 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0`
和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布
的双架构验收。当前源码 lock 是正式版本 `0.11.2`,需要由新的复合包发布流程另行验证。
的双架构验收。当前源码 lock 是正式版本 `0.11.5`,需要由新的复合包发布流程另行验证。
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
@@ -216,7 +216,9 @@ Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转
`.remote-runtime-resources` 或任何可安装 Linux 远端 payload。
- `build/agent-package.cjs` 在对应原生 Linux 架构组装确定性 `.gbagent`,外层签名覆盖
描述符和每个内部文件身份;内部 Agent 与 Runtime 仍分别使用既有签名 manifest 和 lock
校验。`build/agent-catalog.cjs` 为双架构包生成签名累计目录,拒绝同一版本/架构改变字节。
校验,但所有 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 必须
@@ -225,6 +227,10 @@ Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转
- 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 验证。
## 发布前验证
+3 -2
View File
@@ -42,7 +42,7 @@ Debian `loong64` 桌面,不代表 GoodBuddy 已将龙芯纳入正式发布支
| React 界面和主进程业务代码 | 已打包 | 生产 bundle 构建、测试、类型检查和 lint 已通过 |
| 直接模型调用路径 | 已打包 | JavaScript 代码与生产依赖已包含;待龙芯真机端到端验证 |
| Continue Runtime | 已打包 | CLI JavaScript 入口及 npm Runtime 已包含;待真机执行验证 |
| 本地终端能力 | 已打包 | node-pty 1.1.0 已针对 Electron 42.3.0 交叉编译并验证为 LoongArch ELF;待真机 PTY 验证 |
| 本地终端能力 | 已打包 | 输入锁文件固定的 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 |
@@ -198,7 +198,8 @@ SHA256SUMS
1. 校验 Electron zip 的固定 SHA-256。
2. 删除 npm 错误解析出的宿主架构可选原生包。
3. 按 lockfile integrity 校验并安装 Koffi LoongArch glibc binding。
4. 使用上游 node-gyp 11.4.2 和 Electron 42.3.0 headers 交叉编译 node-pty。
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。
@@ -28,8 +28,8 @@ Linux x64 和 Linux arm64。
导航及重定向仍经过 DNS 解析和目标地址固定。
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
空闲回收和应用退出清理。
- 直连模型工具循环已提高到适合浏览器任务的有界上限,并包含重复调用和
无进展保护。
- 直连模型工具循环不设置工具次数或模型轮次配额;取消、请求超时、工具结果大小和
累计输出边界继续防止失控执行。
### MCP
+316 -313
View File
@@ -1,39 +1,39 @@
{
"name": "goodbuddy",
"version": "0.11.2",
"version": "0.11.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.11.2",
"version": "0.11.6",
"license": "0BSD",
"dependencies": {
"@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",
@@ -1485,96 +1485,96 @@
"license": "MIT"
},
"node_modules/@deepseek-ai/dsh-agent": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent/-/dsh-agent-0.1.0-rc.6.tgz",
"integrity": "sha512-vtqq2pWTrzn0dKfj5kREZRpP82AwtGjGx9V1lYnKvF+Uc/a8zyWbSvjDE7V1d3YQAQJzs2cWO31hURWDekDXIA==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent/-/dsh-agent-0.1.0-rc.8.tgz",
"integrity": "sha512-fOl46ZvzoYHalrlyfOJAHNhzyKeVDTUnZZKNSJEr9GY/98WJTjcV21agVEofTXldkJ7a/ws2HpJkVNJVFMtslg==",
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8",
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-agent-loop": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-loop/-/dsh-agent-loop-0.1.0-rc.6.tgz",
"integrity": "sha512-yShuKIMW360H14L4y13j3gz3Ix1s/3lwEEpfJW4hnFAE09h9Z4yJA7UfTQmQdzMRMnuj4uWFPw0e0BbapnkFuw==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-loop/-/dsh-agent-loop-0.1.0-rc.8.tgz",
"integrity": "sha512-6Vg5oLy3UZ6lJKMPxldqqBylKSMOpbV/FRrqzDqn3hGdksYBzt9K2hUJm/rE2RyguYKUKFPm2hExs0nn4HKmWA==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.8",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-attachment": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment/-/dsh-attachment-0.1.0-rc.6.tgz",
"integrity": "sha512-3P6N17NQ8jqSQGzeCs+svCIqArU8oq0YmgEAo+axN9aVuUDferWU4DLRSX59UGpmyldX4LQn81toA+c+DqMcHg==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment/-/dsh-attachment-0.1.0-rc.8.tgz",
"integrity": "sha512-cCrg4WWiav7pGtbdU8dJTpAG28cPnkWVB2YPOrlBCgBbhQziGcG9Dv67Ti6L3PI4OQKC55gYBFinQghFO/7FGg==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6"
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-bash-local": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-bash-local/-/dsh-bash-local-0.1.0-rc.6.tgz",
"integrity": "sha512-YAc5W9uR7sN1Rrobp6fQlCaOdHqCWatTktV68UsoDuOJs5eM3nWEUUUVN8jdZCIDZhktT5hqbhe3+DzOX3Xg1w==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-bash-local/-/dsh-bash-local-0.1.0-rc.8.tgz",
"integrity": "sha512-vMf3dMRrvVvwWCLJxobYONSebPtWqy2ZZKpRZ6o5fBrZbM88irbvL6F/6r/mu4ADTIDigUtpoxlRpOEQKbSuTg==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-shell": "^0.1.0-rc.6",
"@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
"@deepseek-ai/dsh-shell": "^0.1.0-rc.8",
"@deepseek-ai/dsh-subprocess": "^0.1.0-rc.8",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-brand": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.1.0-rc.6.tgz",
"integrity": "sha512-E8j9Nby24qP4rfrdcfc7bpt1CHpGT3tYmycOJJkEOH4ptIdT1m2ro9nmnSd5CWYukTr64A77vjm2WGqHRI92UA==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.1.0-rc.8.tgz",
"integrity": "sha512-402aUAfHxIZJrArBV4gDf0I/A/69A/By26FX6HTIOyFmCnB4wUBKh8jnglz1CorKWdZy8AKldAhh8HQhQEbGOQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-code-runtime": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-code-runtime/-/dsh-code-runtime-0.1.0-rc.6.tgz",
"integrity": "sha512-aw8D4IOeMo11A3uxQeE4LFoW3bvaQnVkGFqqtS+lsINDARrOCJHXLUgecoKpys+Lc5erZZ4UQDnymJmL4OCcKA==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-code-runtime/-/dsh-code-runtime-0.1.0-rc.8.tgz",
"integrity": "sha512-6ZKyEE7dHH1UkKe5b0lg18Byww/7ocZyrNMVjAoUWEWp5i96Brf9Eavu9q3zepBMDMZJErCfV0pyt7KDhBJSig==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-commands": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-commands/-/dsh-commands-0.1.0-rc.6.tgz",
"integrity": "sha512-dq1GZmGTPXEGG01ksZ6Jj6DxAkzAbg+nepvfnM/P74d6EUcsFsEy7CfS18n0VJ6/2Wp0xOWsQyyuFElqbynO7w==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-commands/-/dsh-commands-0.1.0-rc.8.tgz",
"integrity": "sha512-/i99EDLrUyd4bUVn4d7XS46vg09ZE0ervbE/aQaVUWLjDYKrarb7hPx9slADAg5/cUlJsN6L6CP544h9qlUo4g==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -1582,58 +1582,60 @@
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6"
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.8",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-compaction": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-compaction/-/dsh-compaction-0.1.0-rc.6.tgz",
"integrity": "sha512-Uu8qgrHom13gdwwxAnqmNuWM8MqafkRlhjQMMJYmaMVVxjEUewSJ6lwtMbUEBR5ViLsfv4pTm5TnhxNCjedBUw==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-compaction/-/dsh-compaction-0.1.0-rc.8.tgz",
"integrity": "sha512-sWNqZqejzpKsnEMPsNVA/WYK4S59XlYJtnWw8wCPushzQdUt5zhf+g7LVx0cStSozEfwurxRvaZdOmNaa4WhNQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6",
"@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6"
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
"@deepseek-ai/dsh-commands": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-credentials": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-credentials/-/dsh-credentials-0.1.0-rc.6.tgz",
"integrity": "sha512-zyYRs3A9gxfZjZfONzJdMhM0Gzbslpha+FGYkLDRAozgPj0N7mZdE+Qflx2V4WNrCnsSjuvOW0HFBxWtSRUTUw==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-credentials/-/dsh-credentials-0.1.0-rc.8.tgz",
"integrity": "sha512-VopOhtUEa/fSmJr3fyKbsEG043kX1oGuBrmELAbkhmuDigl1cN88g0VPb9rMG2/dJ1h08wg+h0RJP+Ex4LjRWw==",
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6"
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-fs": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs/-/dsh-fs-0.1.0-rc.6.tgz",
"integrity": "sha512-OTkwb4QsZgmjtA/8ZEPh1FapmrBr3N989/G4Wmo1JkAvKbMxkYty6LxjckOawTTz7GJTfUoZCrw9uopDfIMMNw==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs/-/dsh-fs-0.1.0-rc.8.tgz",
"integrity": "sha512-ge5GW56+z20vYACQ2jqtevusdgMXa64laeU/Chql+VIA3+6xyJ8gLX+oj/1O5TlzhvmZ7ZPfZGBFBEVsEZzTQw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6"
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-sandbox": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-fs-local": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-local/-/dsh-fs-local-0.1.0-rc.6.tgz",
"integrity": "sha512-zP0OW474s7tvzEVQnBBTGL6Jrv/YyQXt4nswIbc3Aqr9JyHSfhZ/5IJoipZqfYXLS3ISWMhrF2npRg8L1NGsIQ==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-local/-/dsh-fs-local-0.1.0-rc.8.tgz",
"integrity": "sha512-yWk6WrAHs8b6395XnZZOCf3wY90xYKjh/tyU3/rwFy4oU8zq9gzn/x8rdaZD0zUp+wE0XqfBf2AS4Jo03SKHyQ==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1",
@@ -1641,25 +1643,25 @@
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-fs": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6"
"@deepseek-ai/dsh-fs": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-home-paths": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-home-paths/-/dsh-home-paths-0.1.0-rc.6.tgz",
"integrity": "sha512-gIiUBmqB3L8inFr+hvjZv2/i6EVJOHIHCHg7bBFVhcl4HDK94+8wUCXTLGy80tcuISzIQiIaLuJq/NhSmV9Amw==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-home-paths/-/dsh-home-paths-0.1.0-rc.8.tgz",
"integrity": "sha512-9cQhH9CZsw7Zi73VnZk7CcuEVA6yR/Ox+7lp3VHq53fFYSPi0RL+kMWmh5XCrZB4U1OM3+PVsWsA5II9tB+t0Q==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-invariants": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-invariants/-/dsh-invariants-0.1.0-rc.6.tgz",
"integrity": "sha512-WfEfOi99a4cpOugRAHTBSTnesLieu3ist1q9PXDXFBHX++K1rAl9+sB7YrdnbB8LH0UOY532gS9xJUYU6w0SLw==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-invariants/-/dsh-invariants-0.1.0-rc.8.tgz",
"integrity": "sha512-u0lYqyxOYwfsVnbsfGXZos5vFvA4cqFnBEW3/ezgljNwkYwzeUP/Y5wjPnQjP+ZzBn3CnVeIF6s2N2Vk3iA5mQ==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -1670,50 +1672,50 @@
}
},
"node_modules/@deepseek-ai/dsh-jobs": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-jobs/-/dsh-jobs-0.1.0-rc.6.tgz",
"integrity": "sha512-fmyvSOVsNObmRnciuH57ZntuCSb0gflRleCeQx1ToGHRoGrR4Ndnstx33SuIXUQt6OSUhD2w2WKQTReXogpnSQ==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-jobs/-/dsh-jobs-0.1.0-rc.8.tgz",
"integrity": "sha512-HqQudOA4VkjCMpxWiqTCMmuiTa/pehW6yG7dDsctO1vW5K48hWQ/h7czdJKgrh4SMuK4kchv3IipS1KaJmdaog==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6"
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-launch-environment": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-launch-environment/-/dsh-launch-environment-0.1.0-rc.6.tgz",
"integrity": "sha512-tTRJ1464PJUDe1Em1qq0mfdgGREzGGWo3JSqP6xeYDoX+MRXVP9/ChsZ5k6VBMjARAxw0HGBf7WR6VcupHbMZg==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-launch-environment/-/dsh-launch-environment-0.1.0-rc.8.tgz",
"integrity": "sha512-VvYo5R7tu4QhX/dsNpuBy3AaauizU6yPCeWQUNzI3ZjGA0VHkkZhInilcgxVJkPA1lwEXYB+OoCAo07PUy6D/w==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-llm": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.1.0-rc.6.tgz",
"integrity": "sha512-kuFGC8bHlzGTwlRxQhXjf3CYWl8M4NzH+EYIkrW8rri4iMc9W53xrdvkil5No/DUlMm8g1u7GdeiWYFy0TMvtA==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.1.0-rc.8.tgz",
"integrity": "sha512-hf6RmX2Stn1UtzpktSHQLkIktQkBm9i/3yFfJlbGSkwyw5Cv9sTYJbyoHzsSnnh6i8nRRcMcBhvMgm23I00oIw==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.6"
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.8",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-llm-pi-ai": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm-pi-ai/-/dsh-llm-pi-ai-0.1.0-rc.6.tgz",
"integrity": "sha512-5RvzkpVCYLg9A3IGdm04px7XOaF/xikuMLe2toBY4A0qtJraXiZtUN1QBOL9i6u7DTOLG9oHP/USsbWRpyI+1Q==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm-pi-ai/-/dsh-llm-pi-ai-0.1.0-rc.8.tgz",
"integrity": "sha512-r9dmHYy759gKtAG0S96wTCS6O8WFDqGLQu8t+zGY6LnfdT4qUJdmWtJ+GwCn+BGwq1AglBzjabvyRBp6BxXcMQ==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1",
@@ -1721,104 +1723,104 @@
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.6"
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.8",
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-pwsh-local": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-pwsh-local/-/dsh-pwsh-local-0.1.0-rc.6.tgz",
"integrity": "sha512-TtITwtlDvxyXDE4HKJW9R6uWKU5kr72Y+8JHYV3Kj/HPAzQqwbVo/2e9wvq7H4t5Soo8K+KG5ZqBpMmpp+FAWg==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-pwsh-local/-/dsh-pwsh-local-0.1.0-rc.8.tgz",
"integrity": "sha512-lHAEwC4pL5bVSKgwaeO2dMM9s/jnpDBRuTeVD2HkunpFfT1Kq4lFi+/YXj00L7mW9DBP6FMfwRRV/0Wgo1FtdA==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-shell": "^0.1.0-rc.6",
"@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
"@deepseek-ai/dsh-shell": "^0.1.0-rc.8",
"@deepseek-ai/dsh-subprocess": "^0.1.0-rc.8",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-sandbox": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox/-/dsh-sandbox-0.1.0-rc.6.tgz",
"integrity": "sha512-SLZjuivQQKHTx4H7xlsjraEGGMIEA8BYOwvlm/9uZBrBrlttMU+d8Ne6QYpDcOwIEubL8mvrWRg8j3rtY3lVyA==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox/-/dsh-sandbox-0.1.0-rc.8.tgz",
"integrity": "sha512-dJpy5p/KjKkHG9AnMKU4+/ilj/4DCglsq3kof/jhIwtsZJCix07TEXP9vV8LCv4fU8af7cK3antXw4eVMqkwuQ==",
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-sandbox-policy": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-policy/-/dsh-sandbox-policy-0.1.0-rc.6.tgz",
"integrity": "sha512-XEB7+pJZVPWmZXv27EX/4V/1noVLUF5ZHyy3EY5tC4XtEyayWKtvHL4y9OTLIYhlU4Ualv5ag8s5ueGGq3ID8Q==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-policy/-/dsh-sandbox-policy-0.1.0-rc.8.tgz",
"integrity": "sha512-xQOlYWbUr1Oj6cnRd5aHZIz52EOEAdSO1HJIfeDkQrVaOgohFsiiCIp6PNFumuGNWdCEnf4Q18ebafLNtazYlA==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6"
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-sandbox": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-scope": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-scope/-/dsh-scope-0.1.0-rc.6.tgz",
"integrity": "sha512-UlDLV4syLoJinNg9imhXrSAHrdaTa5Ff8gg46rzjFJGPUOhAk3DZff0hryT5OhrBi0A5Tj92qVpg2pRVvxnUzQ==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-scope/-/dsh-scope-0.1.0-rc.8.tgz",
"integrity": "sha512-/o+PxkVV2zUy9lYlMFr4TWSb+ESW4DE+fI891yIbsLzOJdFgfbmmLpGrlWgnz8/unIGJZxjeXdwyppV04Z277Q==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-session": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session/-/dsh-session-0.1.0-rc.6.tgz",
"integrity": "sha512-8tu8I6VWC7050GAUXWhcEWQw4pakALQc8TlhKr52m7Y4+kIKeNt3FBgP86PaGPBtpK0p5zUPRQNkFpzZbBdxyw==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session/-/dsh-session-0.1.0-rc.8.tgz",
"integrity": "sha512-Qs5Mgn32WPPVB4rKHjsZBgLOkh0TQurUAcc9GaDVudK4dUkpno2HDh++qu/PrwRA3CZ/iwPAoFHCpB3AMMaoLg==",
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6",
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6"
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.8",
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-session-persistence": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-persistence/-/dsh-session-persistence-0.1.0-rc.6.tgz",
"integrity": "sha512-AbNBe+IYCbZqSHqOACVdj8QTynm2HZ0cThrEuI6nGMtlWLYLx6lzZ1rgO/56Av9mIScjyTBGJAIKhEYaMTBG9g==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-persistence/-/dsh-session-persistence-0.1.0-rc.8.tgz",
"integrity": "sha512-JujorG/UCern0+zxJzNswyWl2tFG9sLzfVNvA5YkTLaRLZt7ZsEhxG4DxWZ6uj+3RJ8sf4bqdKqvpIn9t1pwUw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.6"
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-session-projection": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-projection/-/dsh-session-projection-0.1.0-rc.6.tgz",
"integrity": "sha512-DYLALBPdEI1LZjJ4B6rdGdGY4gy+iR2+5Xh2xJoAGa/pTyN5Z55TNfvuMJrmeRBjAuDXNUQpmURbvos5rJ4veg==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-projection/-/dsh-session-projection-0.1.0-rc.8.tgz",
"integrity": "sha512-q00B+rl7WAuK0Bgh93SY2+ZpqaH2kJhfJ9n6Jg6R/m4plnH0Ip4oCAHl6u8rBzDgb1mniQGEQDz/7BN5Q3It8Q==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -1826,126 +1828,127 @@
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-settings": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-settings/-/dsh-settings-0.1.0-rc.6.tgz",
"integrity": "sha512-5ZlH2FNRU0kkdcCoEJohvdb2fiNnxM+iZqN3icbR3WQbMm8RPRrXlZlps5YUaQKPRbNRHA2LkOXW998QRlhHcA==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-settings/-/dsh-settings-0.1.0-rc.8.tgz",
"integrity": "sha512-pqbMoiSP+Ieww9/4gcHYXcBCDlno1zYDu2OPq+5xdZ+McnK+5YBtHsiRpnR9cHQxGTH7lkfIv+adkOueQW0utg==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/schemastery": "^3.18.1"
}
},
"node_modules/@deepseek-ai/dsh-shell": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-shell/-/dsh-shell-0.1.0-rc.6.tgz",
"integrity": "sha512-l/pcwbdpM+Zgp2AzH0kH0z7bPU7L3k4uG1t5Du1V5ilk70tyMSxz2upNfAbjkDHZZZO18LTs33aq+zRWHX1CUg==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-shell/-/dsh-shell-0.1.0-rc.8.tgz",
"integrity": "sha512-RNhp7Y25DXUH7o36X4GcN0lnOLg6NXTlWZpYNURT7aDj3UHcLLajRsyddiAh1EbwQnl0GRKRZhLaZHrrG6gMIw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-sandbox": "^0.1.0-rc.8",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
"@deepseek-ai/dsh-subprocess": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-shell-env": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-shell-env/-/dsh-shell-env-0.1.0-rc.6.tgz",
"integrity": "sha512-znxHFSqduw8U/AxKSO8SgSt703Bl+irRzTU0ukJT+ySlnyAwSAg8sZHLo5ZlEt5K8BdWhxz8Vc5jibOtJPB3ZQ==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-shell-env/-/dsh-shell-env-0.1.0-rc.8.tgz",
"integrity": "sha512-E/Rlka8BnpB7GM67ZJPjvZyp0O9L5x28giyJhHWZcPHN4rsEQFVVqk0cH6iDT5STy+dMc9BAUW+im18kATIxsA==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6",
"@deepseek-ai/dsh-shell": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.8",
"@deepseek-ai/dsh-shell": "^0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-skill": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-skill/-/dsh-skill-0.1.0-rc.6.tgz",
"integrity": "sha512-VyRlCOASIRuTflKwn4NvbdAXxSPnnLJ+RAQUI4GK5pOtPwI2DhJrbovqpyp5kNAkZMsqvIeHWu5PcCEJ+FpQ2w==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-skill/-/dsh-skill-0.1.0-rc.8.tgz",
"integrity": "sha512-MqcOzl4Y5pQEvFBNVAi5w0IZWH5kVbXmp1RYScrl+8HpVATZbgumDhztVnEc2kUrZu+o6LKb1hnbunK6JHUE5g==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-subprocess": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subprocess/-/dsh-subprocess-0.1.0-rc.6.tgz",
"integrity": "sha512-nZaZRjSnE1he+GAd14vURAH7n3Fw5eGx3nzMbkB96Y6Qx+oQB/7PTuXxKVxlCh8TSfMJTf851ahwWb1eALjKlw==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subprocess/-/dsh-subprocess-0.1.0-rc.8.tgz",
"integrity": "sha512-rTWwWCq/WrBTjaXhApu7gaho6UHwMZq0+l7nZ06YMUreNZoCHfg67pAXopMqTgr/iotphmXQdChrLgBabzL1gA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-subprocess-local": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subprocess-local/-/dsh-subprocess-local-0.1.0-rc.6.tgz",
"integrity": "sha512-D2daTRaprE25ti1Ra69eURPwLULpnRyUHqAGtcMHddcjNIsZ4yR0DGinYOsgoRymzu3t02qLxyoio05atSViRw==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subprocess-local/-/dsh-subprocess-local-0.1.0-rc.8.tgz",
"integrity": "sha512-2QMd6Nwjd+ARor4g+GSZ3dNyTvEf2IPOje47nQwa3UCmltLxn8K8gjDAN/5sGr/IF56P14FeF4xFag7tMK+Zdg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-pty": "^1.1.0"
"koffi": "^3.1.0",
"node-pty": "1.2.0-beta.15"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-subprocess": "^0.1.0-rc.8",
"@deepseek-ai/dsh-timeout": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-system-prompt": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-system-prompt/-/dsh-system-prompt-0.1.0-rc.6.tgz",
"integrity": "sha512-E7g+XChh4q4/wX++v56z1pV4SA1Rtz42xkznLPPi9FlXrrzJxwHMOUzBZ9Rz3Y1kLhQ++HJG3ZatmNx3rjFilg==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-system-prompt/-/dsh-system-prompt-0.1.0-rc.8.tgz",
"integrity": "sha512-yk8Z85rzX2EwpKNWA5i+K3I3mqsIIUc9niwbDpXO8vlHvpC2qR3RNHjJIJN2EZYDqfSzTVtkbxHFsuMzDXIbew==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-timeout": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-timeout/-/dsh-timeout-0.1.0-rc.6.tgz",
"integrity": "sha512-CUean0fAnfsJVszFEip7PsU/S26W+JfDFfsza2dCtlw8n6xlkbHA9Gjxdk2aTwqDGCgXEPkRW7mYkdJ0n6FR7w==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-timeout/-/dsh-timeout-0.1.0-rc.8.tgz",
"integrity": "sha512-RwOC/qHribE6b+LStAO7aAgweefLE5sqDO7Uz6/mnTDyJxCt7HNQnE/Op8sAvRa33d752aUkPfO9S0mgzVAoFQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-token-meter": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-token-meter/-/dsh-token-meter-0.1.0-rc.6.tgz",
"integrity": "sha512-qU5FT4n1RJXP3Ss8NJ5TTvPo8rZ6cxcrCedp1t0sTAj93SGssAanq47voSmznPP52nwz/SLeunznAIT0jhbsZQ==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-token-meter/-/dsh-token-meter-0.1.0-rc.8.tgz",
"integrity": "sha512-ZcS6vuuJhoqjUrbyROuiiYk4nrvChkkYEfIoj7CjGbNArWOhbStmBaiPyahtcB1kThcnUKdu7vv7DaZJdLw8Lg==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1",
@@ -1953,40 +1956,40 @@
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-compaction": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session-projection": "^0.1.0-rc.6"
"@deepseek-ai/dsh-compaction": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session-projection": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-tool-bash": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-bash/-/dsh-tool-bash-0.1.0-rc.6.tgz",
"integrity": "sha512-yzo/xPCObiFe+OU0e8E5nVOGpW6mp9vx/3q+MHQ0aqEbqrqHUfVMGPobfDAd/I1Ja/9Nag5NIvHpI2Hwx7lACg==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-bash/-/dsh-tool-bash-0.1.0-rc.8.tgz",
"integrity": "sha512-VvP8Ky8ifqS7Iy7XUxc6cWB7sse3X6gqr92m0hBuodwQblzrZq2b6IyzfTzXKZKLqt5mMUaLRt7IPIDOOG+WEQ==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-jobs": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^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-shell": "^0.1.0-rc.6",
"@deepseek-ai/dsh-shell-env": "^0.1.0-rc.6",
"@deepseek-ai/dsh-system-prompt": "^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-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-jobs": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^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-shell": "^0.1.0-rc.8",
"@deepseek-ai/dsh-shell-env": "^0.1.0-rc.8",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
"@deepseek-ai/dsh-user-approval": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-tool-fs": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-fs/-/dsh-tool-fs-0.1.0-rc.6.tgz",
"integrity": "sha512-bJfo8cCacX3WBygG8GbIlGz3jQ15MyIv4htGCjKpIIRHrXHSAelNtzceO2xQ21/9BkXrvg+i50mqySOcKQ0b3w==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-fs/-/dsh-tool-fs-0.1.0-rc.8.tgz",
"integrity": "sha512-pxo2YfVlqcw0sCA2+4DtqaaYsuxkx1QfvjT3VO+ckZMWn/UnGOUuzjJtKhVynwjm8prttGqK2LBXUTBZPlKTWg==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1",
@@ -1994,106 +1997,106 @@
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-fs": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^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-system-prompt": "^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-attachment": "^0.1.0-rc.8",
"@deepseek-ai/dsh-fs": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^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-system-prompt": "^0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
"@deepseek-ai/dsh-user-approval": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-tool-pwsh": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-pwsh/-/dsh-tool-pwsh-0.1.0-rc.6.tgz",
"integrity": "sha512-x8ZlXUpOmcEiQobSTNSj18TPWVzyGe68pXJi6njea28DKVSe5Cx/L1bl9WImxru9f06+BkAm2ievJ+VKe89aeA==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-pwsh/-/dsh-tool-pwsh-0.1.0-rc.8.tgz",
"integrity": "sha512-B5F08tsSD7DHqXDXjn/Q11FavHw1U8/WAEqp8AC47vDRymPJIgTM8zSlJ0Lx7aZaKPTW4zQYtSmwUhy93q6zfw==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-jobs": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^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-shell": "^0.1.0-rc.6",
"@deepseek-ai/dsh-shell-env": "^0.1.0-rc.6",
"@deepseek-ai/dsh-system-prompt": "^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-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-jobs": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^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-shell": "^0.1.0-rc.8",
"@deepseek-ai/dsh-shell-env": "^0.1.0-rc.8",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
"@deepseek-ai/dsh-user-approval": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-tool-skill": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-skill/-/dsh-tool-skill-0.1.0-rc.6.tgz",
"integrity": "sha512-AebSfHSt6j0PXuN3yK9+X4aC6tHSnFdm2hZglII7aEHr/lL9UbynPb3NTumh6thXZ9Earvwhf9JdOxHMsjHqfw==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-skill/-/dsh-tool-skill-0.1.0-rc.8.tgz",
"integrity": "sha512-oQAPyXiQDDZ73rZwST7KqwUAr0Al3U1dxWdtzETsMzvNSQE9fmMBz5jUObFIEjxxJcA07Pp1DFzuJdpkCIDeCw==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6"
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-skill": "^0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-tools": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tools/-/dsh-tools-0.1.0-rc.6.tgz",
"integrity": "sha512-Tu08EPK3JyK0iNjH4FGzu/1uADynNSS6SmwOLdfytUN0YNqwNuKFSt2OJUg19famNlTgy992DcHfDu0T+gLXFg==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tools/-/dsh-tools-0.1.0-rc.8.tgz",
"integrity": "sha512-tvuGAVUS4yoPW9zH7CGWJgMlAzGjfu2UlRiricldlcwSKAaJZPZx/K/YfTivUyDTER6aK1DJUnIpVG7RD/DoHQ==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-code-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-system-prompt": "^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-code-runtime": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8",
"@deepseek-ai/dsh-user-approval": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-typert-protocol": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-typert-protocol/-/dsh-typert-protocol-0.1.0-rc.6.tgz",
"integrity": "sha512-weWzN8r01YCkoDCAM7BsKw2YhRrD4zL8N2SAZu9hovYtXSq8xHXsP4Zh8RLYIlYcuotjyff/6hic+0TJPd14YA==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-typert-protocol/-/dsh-typert-protocol-0.1.0-rc.8.tgz",
"integrity": "sha512-scwEAefRBL7gRpaXiEdiHRMvlr8dLmUbiEvy0LOAX/8X3FZBd/5tHQ/8lCppAQNTDZMI/LSiSMpu5XrFId+7cQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6"
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/dsh-user-approval": {
"version": "0.1.0-rc.6",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-user-approval/-/dsh-user-approval-0.1.0-rc.6.tgz",
"integrity": "sha512-9rnkSDGOpu2XUeGwbPeTzVUTFWTND1PMPM5L/ZQPptV5yyZlQiNxM2rCC6OdL+ZVerwxEqrRhZIQn/KVtQfKag==",
"version": "0.1.0-rc.8",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-user-approval/-/dsh-user-approval-0.1.0-rc.8.tgz",
"integrity": "sha512-6SFCxp7dgNFEyzQIcs5i9C+5djgXsAeV0iKmZUCLlcIeVM1chcCMHyGcoXlw8yyB14qFMrMs+TPvhvpu2WWxAw==",
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6"
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
"@deepseek-ai/dsh-brand": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-scope": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.8"
}
},
"node_modules/@deepseek-ai/schemastery": {
@@ -12161,9 +12164,9 @@
"license": "MIT"
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"version": "1.2.0-beta.15",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz",
"integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+38 -24
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.11.2",
"version": "0.11.6",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
@@ -193,7 +193,7 @@
"to": "licenses/ppu-ocv-MIT.txt"
},
{
"from": "node_modules/onnxruntime-web/LICENSE",
"from": "resources/licenses/onnxruntime-MIT.txt",
"to": "licenses/onnxruntime-web-MIT.txt"
},
{
@@ -261,28 +261,28 @@
"@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",
@@ -357,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
}
}
+2 -17
View File
@@ -2,23 +2,8 @@
"formatVersion": 1,
"keys": [
{
"keyId": "e2e-20260823-020914-ce95d6f6",
"publicKeySpkiBase64": "MCowBQYDK2VwAyEA8w12MNcxOrU18MArvcuJmVkGKzJsa/r93q14HGX7DUU=",
"environment": "production"
},
{
"keyId": "goodbuddy-e2e-agent-8b0b39d2e63d7cf163c23bb7",
"publicKeySpkiBase64": "MCowBQYDK2VwAyEAx5lS8/stctKe/ri3kSNLR3nmkTV1eTpS0c1TYtJuHoM=",
"environment": "production"
},
{
"keyId": "goodbuddy-e2e-v8-de8355f998071143411bc075",
"publicKeySpkiBase64": "MCowBQYDK2VwAyEAY7rOjqPT3FAw2+F5roTqyssz4Zzzx0iJhk6tGhCVivc=",
"environment": "production"
},
{
"keyId": "goodbuddy-e2e-v12-b5adef071b79b4fdec57ea98",
"publicKeySpkiBase64": "MCowBQYDK2VwAyEATKD9wXjCeC35SMDrczzpou5PusRkxqSgNb5Se/jYOFU=",
"keyId": "goodbuddy-signing-2026-08",
"publicKeySpkiBase64": "MCowBQYDK2VwAyEAfUkXqa3WYwb1QevpHW/XWNhGo59dETJYjrJONx6SKEQ=",
"environment": "production"
}
],
+21
View File
@@ -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.
+88 -8
View File
@@ -2,12 +2,90 @@
"formatVersion": 1,
"releases": [
{
"version": "0.11.2",
"releasedAt": "2026-08-25",
"version": "0.11.6",
"releasedAt": "2026-08-27",
"notes": {
"zh-CN": {
"highlights": [
"GoodBuddy 0.11.2 新增托管 SSH 远程项目、内置 Granite 向量模型、会话分支和应用内反馈,并改进魔法笔记、知识图谱、模型下载及桌面布局。"
"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 上的任务可继续运行并恢复未同步输出。",
@@ -15,6 +93,7 @@
"**独立会话分支。** 本地会话可通过“在新会话中继续”创建独立分支并保留来源,不复制原会话的任务、队列、运行记录、记忆或成果归属。",
"**应用内反馈。** 可在“关于与更新”中提交问题、建议或体验反馈;邮箱和单张截图均为可选项,失败后会保留草稿供重试。",
"**魔法笔记待办数量。** 左侧导航可显示未完成待办数量,并可在“平台功能 → 魔法笔记”中关闭;超过 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": [
@@ -26,14 +105,14 @@
"**界面信息与会话列表。** 恢复关键页面说明和视觉层级;会话更多操作仅在悬停、键盘聚焦或菜单打开时显示,为标题和时间保留更多空间。"
],
"notices": [
"远程项目默认关闭且完全可选。首次使用前必须手动准备兼容的 `.gbagent` 包,下载源跟随“关于与更新”中的 GitHub 或镜像节点选择;本次正式 Agent 版本为 `0.11.2`。",
"LoongArch 仍是独立实验性预览,不进入标准更新索引;当前不包含 Agent、Remote Runtime、OpenCode 和原生 Canvas。",
"远程项目默认关闭且完全可选。首次使用前必须手动准备兼容的 `.gbagent` 包,下载源跟随“关于与更新”中的 GitHub 或镜像节点选择;本次正式 Agent 版本为 `0.11.4`。",
"LoongArch 仍是独立实验性预览,不进入标准更新索引;当前不包含 Agent、Remote Runtime、OpenCode 和原生 Canvas。本次 `0.11.4` 不提供 LoongArch 预览包。",
"反馈不会自动附带会话、日志、文件、设置或凭据。"
]
},
"en-US": {
"highlights": [
"GoodBuddy 0.11.2 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."
"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.",
@@ -41,6 +120,7 @@
"**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": [
@@ -52,8 +132,8 @@
"**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.2`.",
"LoongArch remains a separate experimental preview outside the standard update index. It currently excludes Agent, Remote Runtime, OpenCode, and native Canvas.",
"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."
]
}
+59 -1
View File
@@ -4,7 +4,7 @@
正式站点地址:<https://mesalogo.github.io/goodbuddy/>
首页将 GoodBuddy 定位为“首个支持龙芯的开源桌面 AI 助理产品”和
首页将 GoodBuddy 定位为“首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品”和
“免注册、支持信创软硬件的一站式 AI 助手”,优先展示三大桌面系统、正式
x64 / arm64 下载入口、独立编译的龙芯 loong64 预览版、统一 Agent Runtime,
以及知识库、魔法笔记、智能心跳、桌面上下文和远程消息通道等桌面助手能力。
@@ -89,6 +89,64 @@ https://github.com/mesalogo/goodbuddy/releases/latest
`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。
## 字体与可访问性
+199 -69
View File
@@ -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,8 @@
const downloadCards = [
...document.querySelectorAll("[data-download-card]"),
];
let verifiedRelease;
let verifiedLoongArchPreview;
const interfaceCopy = isEnglish
? {
themeDark: "Switch to dark theme",
@@ -65,8 +69,7 @@
}
};
const readBoundedJson = async (response) => {
const maximumBytes = releaseIndexApi?.maximumIndexBytes;
const readBoundedJson = async (response, maximumBytes) => {
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) {
throw new Error("发布索引大小上限无效");
}
@@ -111,30 +114,163 @@
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 = () => {
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 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) {
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) {
setReleaseLink(
link,
releaseFallbackUrl,
`前往 GitHub 下载 ${platformNames[platform] ?? platform} →`,
);
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) {
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) {
setReleaseLink(
link,
releaseFallbackUrl,
`前往 GitHub 下载 ${platformNames[platform] ?? platform ?? ""} →`,
);
}
if (meta instanceof HTMLElement) {
meta.textContent = "请在 GitHub Release 中选择对应的安装文件。";
}
updateDownloadCard(card);
}
};
const setFallbackDownloads = () => {
verifiedRelease = undefined;
updateAllDownloadCards();
};
const setLoongArchPreviewUnavailable = () => {
verifiedLoongArchPreview = undefined;
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);
const linuxCard = downloadCards.find(
(card) => card.dataset.downloadCard === "linux",
);
if (linuxCard) {
updateDownloadCard(linuxCard);
}
};
@@ -142,71 +278,37 @@
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);
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",
@@ -214,16 +316,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");
@@ -311,8 +439,10 @@
applyTheme(getSavedTheme() ?? (systemTheme.matches ? "dark" : "light"));
setHeaderState();
initializeDownloadCards();
if (!isEnglish) {
void loadRelease();
void loadLoongArchPreview();
}
themeToggle?.addEventListener("click", () => {
+8 -8
View File
@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="GoodBuddy 是首个支持龙芯的开源桌面 AI 助理产品,也是免注册、支持信创软硬件的一站式 AI 助手;龙芯 LoongArch 当前提供独立编译预览版。"
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"
@@ -124,9 +124,8 @@
<span>一站式 AI 助手。</span>
</h1>
<p class="hero-lead">
GoodBuddy 是首个支持龙芯的开源桌面 AI 助理产品,龙芯 LoongArch
当前以独立编译预览版提供。作为桌面助手与 AI 编程工具台,GoodBuddy
管理对话、知识、笔记与任务,
GoodBuddy 是首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品。作为桌面助手与
AI 编程工具台,GoodBuddy 管理对话、知识、笔记与任务,
也通过独创的统一 Agent Runtime 接入直连模型、OpenCode、Continue 和 DeepSeek Harness。
无需反复配置命令行,选择工具和项目即可开始。
</p>
@@ -134,7 +133,7 @@
<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
@@ -327,6 +326,7 @@
<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>
@@ -353,7 +353,7 @@
<aside class="domestic-support" aria-labelledby="domestic-support-title">
<div>
<p class="kicker">信创软硬件支持</p>
<h3 id="domestic-support-title">首个支持龙芯的开源桌面 AI 助理产品</h3>
<h3 id="domestic-support-title">首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品</h3>
<p>
正式下载覆盖国产 x64 与 ARM64;龙芯 LoongArch 使用独立编译的
loong64 预览版。
@@ -525,7 +525,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>
+88
View File
@@ -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;
@@ -201,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,
});
})();
+165
View File
@@ -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,75 @@ 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 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();
});
+80 -1
View File
@@ -9,7 +9,10 @@ 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"]],
@@ -90,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);
@@ -204,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);
});
});
+36 -2
View File
@@ -250,8 +250,9 @@ for (const breakpoint of ["1199px", "959px", "719px"]) {
const requiredCopy = [
"免注册",
"支持信创软硬件的一站式 AI 助手",
"首个支持龙芯的开源桌面 AI 助理产品",
"首个全面覆盖国产化 CPU 架构的开源桌面 AI 助理产品",
"龙芯 LoongArch 独立编译预览版",
"龙芯 LoongArch(实验预览)",
"桌面助手",
"AI 编程工具台",
"Windows、macOS、Linux",
@@ -328,6 +329,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}`),
"官网不得显示下载源状态提示",
@@ -362,6 +375,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 回退地址",
@@ -502,7 +536,7 @@ const expectedDownloadOptions = {
formats: ["dmg", "zip"],
},
linux: {
arches: ["x64", "arm64"],
arches: ["x64", "arm64", "loong64"],
formats: ["AppImage", "deb", "rpm"],
},
};
+17 -1
View File
@@ -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);
+6 -35
View File
@@ -1,10 +1,13 @@
import {
chmodSync,
mkdtempSync,
rmSync,
unlinkSync
rmSync
} from 'node:fs'
import { createConnection, type Socket } from 'node:net'
import {
createConnection,
createServer,
type Socket
} from 'node:net'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { PassThrough } from 'node:stream'
@@ -79,38 +82,6 @@ describe('private endpoint lifecycle', () => {
}
)
runOnUnix(
'never unlinks a replacement socket it did not bind',
async () => {
const root = privateTemporaryDirectory()
const socketPath = resolve(root, 'agent.sock')
const endpoint = new PrivateEndpoint({
socketPath,
peerIdentity: currentPeerIdentity(),
challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)),
controllers: new ControllerRegistry(),
...endpointIdentity,
onAttach: vi.fn()
})
await endpoint.listen()
unlinkSync(socketPath)
const replacement = createServer()
await new Promise<void>((resolveListen, reject) => {
replacement.once('error', reject)
replacement.listen(socketPath, resolveListen)
})
await endpoint.close()
const client = createConnection(socketPath)
await expect(onceConnected(client)).resolves.toBeUndefined()
client.destroy()
await new Promise<void>((resolveClose) =>
replacement.close(() => resolveClose())
)
}
)
runOnUnix(
'destroys an authenticated persistent socket during shutdown',
async () => {
+4 -1
View File
@@ -243,7 +243,10 @@ describe('WorkspaceRegistry', () => {
abort.abort(new Error('cancelled'))
releaseInspection?.('not-a-repository')
await expect(pending).rejects.toThrow('cancelled')
await expect(pending).rejects.toMatchObject({
code: 'aborted',
message: 'Workspace operation was aborted'
})
expect(registry.activeHandleCount()).toBe(0)
expect(existsSync(storagePath)).toBe(false)
})
+1 -1
View File
@@ -7,7 +7,7 @@ export type BundledRuntimePaths = {
}
export const bundledContinueVersion = '1.5.47'
export const bundledDeepSeekHarnessVersion = '0.1.0-rc.6'
export const bundledDeepSeekHarnessVersion = '0.1.0-rc.8'
export function resolveBundledRuntimePaths(input: {
appPath: string
+4 -1
View File
@@ -96,7 +96,8 @@ function resolveContextCompression(
'openai-images-generations'
>,
authentication: summaryProfile.authentication,
contextWindowTokens: summaryProfile.contextWindowTokens
contextWindowTokens: summaryProfile.contextWindowTokens,
maximumOutputTokens: summaryProfile.maximumOutputTokens
}
}
: {})
@@ -120,6 +121,7 @@ export function createDefaultModelRuntime(
protocol: settings.modelProtocol,
authentication: settings.modelAuthentication,
supportsImageInput: settings.supportsImageInput,
maximumOutputTokens: currentProfile?.maximumOutputTokens,
defaultWorkspace: settings.workspacePath || defaultWorkspace,
contextCompression: resolveContextCompression(
settings,
@@ -141,6 +143,7 @@ export function createModelProfileRuntime(
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality:
profile.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
@@ -329,7 +329,7 @@ function createInProcessLaunch(
provider: 'goodbuddy',
model: options.model,
supportsImageInput: options.supportsImageInput,
harnessVersion: '0.1.0-rc.6',
harnessVersion: '0.1.0-rc.8',
credentialRefs: options.credentialRefs,
skillPackages: options.skillPackages,
extensionPackages: options.extensionPackages,
@@ -5,11 +5,13 @@ import {
runtimeExtensionConfigurationSchema,
runtimeExtensionIdSchema
} from '../../shared/runtime-extension-contracts'
import { bundledDeepSeekHarnessVersion } from './bundled-runtimes'
export const DEEPSEEK_HARNESS_CONTROL_PROTOCOL =
'goodbuddy.deepseek-harness.control'
export const DEEPSEEK_HARNESS_CONTROL_VERSION = 2
export const DEEPSEEK_HARNESS_HOST_VERSION = '0.1.0-rc.6'
export const DEEPSEEK_HARNESS_HOST_VERSION =
bundledDeepSeekHarnessVersion
export const DEEPSEEK_HARNESS_CREDENTIAL_REF =
'GOODBUDDY_HARNESS_MODEL_API_KEY'
export const DEEPSEEK_HARNESS_MAX_FRAME_BYTES =
@@ -112,9 +112,10 @@ describe('DeepSeek Harness extension loader', () => {
it('bounds the complete extension startup sequence', async () => {
const ctx = new Context()
const importModule = vi.fn(
() => new Promise<never>(() => undefined)
)
const importModule = vi.fn(async () => {
blockEventLoop(25)
return {}
})
const result = await loadControlledHarnessExtensions(
ctx,
@@ -156,7 +156,7 @@ function setup(
if (method === 'goodbuddy/handshake') {
return {
controlProtocolVersion: 1,
harnessVersion: '0.1.0-rc.6',
harnessVersion: '0.1.0-rc.8',
acpProtocolVersion: 1,
supports: {
cancellation: true,
@@ -95,7 +95,7 @@ describe.skipIf(!enabled)('DSH marketplace live E2E', () => {
api: 'openai-completions',
provider: 'goodbuddy',
model: 'deepseek-test',
harnessVersion: '0.1.0-rc.6',
harnessVersion: '0.1.0-rc.8',
credentialRefs: ['GOODBUDDY_API_KEY'],
skillPackages: [],
extensionPackages: extensions,
@@ -8,6 +8,7 @@ const canvas = createCanvas(1, 1)
const transparentPng = canvas.toBuffer('image/png')
const jpeg = canvas.toBuffer('image/jpeg')
const secondPng = createCanvas(2, 1).toBuffer('image/png')
const overwidePng = createCanvas(8_193, 1).toBuffer('image/png')
describe('GoodBuddy Harness attachment store', () => {
it('decodes, stores, verifies, and releases inline images', async () => {
@@ -32,12 +33,18 @@ describe('GoodBuddy Harness attachment store', () => {
const stored = await store.readImage(first)
expect(stored.ref).toBe(first)
expect(Buffer.from(stored.data).equals(transparentPng)).toBe(true)
await expect(
store.readImage({
...first,
width: first.width + 1
})
).rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' })
store.releaseImage(first)
await expect(store.readImage(first)).resolves.toBeDefined()
store.releaseImage(second)
await expect(store.readImage(first)).rejects.toMatchObject({
code: 'NOT_FOUND'
code: 'ATTACHMENT_NOT_FOUND'
})
const jpegRef = await store.saveImage({
@@ -62,7 +69,7 @@ describe('GoodBuddy Harness attachment store', () => {
data: transparentPng,
mediaType: 'image/jpeg'
})
).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' })
await expect(
store.saveImage({
data: Buffer.from([
@@ -81,6 +88,14 @@ describe('GoodBuddy Harness attachment store', () => {
mediaType: 'image/png'
})
).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
await expect(
store.saveImage({
data: overwidePng,
mediaType: 'image/png'
})
).rejects.toMatchObject({
code: 'IMAGE_DIMENSION_TOO_LARGE'
})
await store.saveImage({
data: transparentPng,
@@ -91,7 +106,7 @@ describe('GoodBuddy Harness attachment store', () => {
data: secondPng,
mediaType: 'image/png'
})
).rejects.toMatchObject({ code: 'STORAGE_LIMIT' })
).rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' })
})
it('does not retain a partial batch when capacity is exceeded', async () => {
@@ -104,7 +119,7 @@ describe('GoodBuddy Harness attachment store', () => {
{ data: transparentPng, mediaType: 'image/png' },
{ data: jpeg, mediaType: 'image/jpeg' }
])
).rejects.toMatchObject({ code: 'STORAGE_LIMIT' })
).rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' })
await expect(
store.saveImage({
data: jpeg,
@@ -130,4 +145,21 @@ describe('GoodBuddy Harness attachment store', () => {
height: 1
})
})
})
it('enforces the upstream per-message image count', async () => {
const store = new GoodBuddyHarnessAttachmentStore(new Context())
const input = {
data: transparentPng,
mediaType: 'image/png' as const
}
await expect(
store.saveImages(
Array.from(
{ length: store.imageLimits.maxImagesPerMessage + 1 },
() => input
)
)
).rejects.toMatchObject({ code: 'TOO_MANY_IMAGES' })
})
})
@@ -6,6 +6,7 @@ import {
AttachmentError,
AttachmentId,
AttachmentStore,
type ImageAdmissionErrorCode,
type ImageAttachmentLimits,
type ImageAttachmentRef,
type ImageMediaType,
@@ -23,6 +24,7 @@ export const GOODBUDDY_HARNESS_IMAGE_LIMITS: ImageAttachmentLimits =
maxImagesPerMessage: 8,
maxMessageImageBytes: 2 * 1024 * 1024,
maxImagePixels: 16_000_000,
maxImageDimension: 8_192,
mediaTypes: Object.freeze([
'image/png',
'image/jpeg'
@@ -47,8 +49,12 @@ export type GoodBuddyHarnessAttachmentStoreConfig = {
maxBatchImagePixels?: number
}
function invalidImage(message: string, cause?: unknown): AttachmentError {
return new AttachmentError(message, 'INVALID_IMAGE', {
function imageError(
message: string,
code: ImageAdmissionErrorCode,
cause?: unknown
): AttachmentError {
return new AttachmentError(message, code, {
...(cause === undefined ? {} : { cause })
})
}
@@ -88,6 +94,57 @@ function matchesSignature(
)
}
function validateImageInputBounds(
input: SaveImageAttachment,
limits: ImageAttachmentLimits
): void {
if (!limits.mediaTypes.includes(input.mediaType)) {
throw imageError(
'Image media type is not supported',
'UNSUPPORTED_IMAGE_TYPE'
)
}
if (input.data.byteLength === 0) {
throw imageError('Image data is empty', 'INVALID_IMAGE')
}
if (input.data.byteLength > limits.maxImageBytes) {
throw imageError(
'Image exceeds the per-image byte limit',
'IMAGE_TOO_LARGE'
)
}
}
function validateImageDimensions(
width: number,
height: number,
limits: ImageAttachmentLimits
): void {
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width < 1 ||
height < 1
) {
throw imageError('Image dimensions are invalid', 'INVALID_IMAGE')
}
if (
width > limits.maxImageDimension ||
height > limits.maxImageDimension
) {
throw imageError(
'Image exceeds the per-side dimension limit',
'IMAGE_DIMENSION_TOO_LARGE'
)
}
if (width * height > limits.maxImagePixels) {
throw imageError(
'Image dimensions exceed the pixel limit',
'IMAGE_TOO_MANY_PIXELS'
)
}
}
function pngDimensions(data: Buffer): {
width: number
height: number
@@ -200,34 +257,26 @@ async function inspectImage(
input: SaveImageAttachment,
limits: ImageAttachmentLimits
): Promise<InspectedImage> {
if (!limits.mediaTypes.includes(input.mediaType)) {
throw invalidImage('Image media type is not supported')
}
if (
input.data.byteLength === 0 ||
input.data.byteLength > limits.maxImageBytes
) {
throw invalidImage('Image exceeds the per-image byte limit')
}
validateImageInputBounds(input, limits)
const data = Buffer.from(input.data)
if (!matchesSignature(data, input.mediaType)) {
throw invalidImage('Image media type does not match its bytes')
throw imageError(
'Image media type does not match its bytes',
'IMAGE_TYPE_MISMATCH'
)
}
const encodedDimensions =
input.mediaType === 'image/png'
? pngDimensions(data)
: jpegDimensions(data)
if (!encodedDimensions) {
throw invalidImage('Image container is malformed')
}
if (
encodedDimensions.width < 1 ||
encodedDimensions.height < 1 ||
encodedDimensions.width * encodedDimensions.height >
limits.maxImagePixels
) {
throw invalidImage('Image dimensions exceed the pixel limit')
throw imageError('Image container is malformed', 'INVALID_IMAGE')
}
validateImageDimensions(
encodedDimensions.width,
encodedDimensions.height,
limits
)
let width: number
let height: number
let loadImage: typeof import('@napi-rs/canvas')['loadImage']
@@ -237,7 +286,7 @@ async function inspectImage(
} catch (error) {
throw new AttachmentError(
'Harness image decoder is unavailable',
'DECODER_UNAVAILABLE',
'ATTACHMENT_WRITE_FAILED',
{ cause: error }
)
}
@@ -246,18 +295,21 @@ async function inspectImage(
width = image.naturalWidth || image.width
height = image.naturalHeight || image.height
} catch (error) {
throw invalidImage('Image bytes could not be decoded', error)
throw imageError(
'Image bytes could not be decoded',
'INVALID_IMAGE',
error
)
}
validateImageDimensions(width, height, limits)
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width < 1 ||
height < 1 ||
width * height > limits.maxImagePixels ||
width !== encodedDimensions.width ||
height !== encodedDimensions.height
) {
throw invalidImage('Image dimensions exceed the pixel limit')
throw imageError(
'Decoded image dimensions do not match its container',
'INVALID_IMAGE'
)
}
return { data, width, height }
}
@@ -314,6 +366,23 @@ export class GoodBuddyHarnessAttachmentStore extends AttachmentStore {
async saveImages(
inputs: readonly SaveImageAttachment[]
): Promise<ImageAttachmentRef[]> {
if (inputs.length > this.imageLimits.maxImagesPerMessage) {
throw new AttachmentError(
'Images exceed the per-message count limit',
'TOO_MANY_IMAGES'
)
}
let batchBytes = 0
for (const input of inputs) {
validateImageInputBounds(input, this.imageLimits)
batchBytes += input.data.byteLength
if (batchBytes > this.imageLimits.maxMessageImageBytes) {
throw new AttachmentError(
'Images exceed the per-message byte limit',
'IMAGES_TOO_LARGE'
)
}
}
const inspectedByContent = new Map<string, InspectedImage>()
const candidates: Array<{
input: SaveImageAttachment
@@ -322,13 +391,6 @@ export class GoodBuddyHarnessAttachmentStore extends AttachmentStore {
}> = []
let batchPixels = 0
for (const input of inputs) {
if (
!this.imageLimits.mediaTypes.includes(input.mediaType) ||
input.data.byteLength === 0 ||
input.data.byteLength > this.imageLimits.maxImageBytes
) {
throw invalidImage('Image exceeds the attachment limits')
}
const digest = createHash('sha256')
.update(input.data)
.digest('hex')
@@ -340,7 +402,10 @@ export class GoodBuddyHarnessAttachmentStore extends AttachmentStore {
}
batchPixels += inspected.width * inspected.height
if (batchPixels > this.maxBatchImagePixels) {
throw invalidImage('Images exceed the batch pixel limit')
throw imageError(
'Images exceed the batch pixel limit',
'INVALID_IMAGE'
)
}
candidates.push({
input,
@@ -370,7 +435,7 @@ export class GoodBuddyHarnessAttachmentStore extends AttachmentStore {
) {
throw new AttachmentError(
'Harness attachment store is full',
'STORAGE_LIMIT'
'ATTACHMENT_WRITE_FAILED'
)
}
return candidates.map(({ input, inspected, attachmentId }) => {
@@ -407,7 +472,7 @@ export class GoodBuddyHarnessAttachmentStore extends AttachmentStore {
if (!stored) {
throw new AttachmentError(
'Harness image attachment was not found',
'NOT_FOUND'
'ATTACHMENT_NOT_FOUND'
)
}
if (
@@ -419,8 +484,8 @@ export class GoodBuddyHarnessAttachmentStore extends AttachmentStore {
stored.ref.name !== ref.name
) {
throw new AttachmentError(
'Harness image attachment failed integrity validation',
'INTEGRITY'
'Harness image attachment reference is invalid',
'INVALID_ATTACHMENT_REF'
)
}
return {
@@ -22,7 +22,7 @@ function controlPlane() {
provider: 'goodbuddy',
model: 'deepseek-test',
workspace: resolve('workspace'),
harnessVersion: '0.1.0-rc.6',
harnessVersion: '0.1.0-rc.8',
execution: { mode: 'host' },
credentialRefs: ['GOODBUDDY_API_KEY'],
skills: []
@@ -75,7 +75,7 @@ function stubAgentContext() {
provider: 'goodbuddy',
model: 'deepseek-test',
workspace: resolve('workspace'),
harnessVersion: '0.1.0-rc.6',
harnessVersion: '0.1.0-rc.8',
execution: { mode: 'host' },
credentialRefs: ['GOODBUDDY_API_KEY'],
skills: [],
@@ -172,7 +172,7 @@ describe('GoodBuddy Harness internal control plane', () => {
model: 'vision-test',
supportsImageInput: true,
workspace: resolve('workspace'),
harnessVersion: '0.1.0-rc.6',
harnessVersion: '0.1.0-rc.8',
execution: { mode: 'host' },
credentialRefs: ['GOODBUDDY_API_KEY'],
skills: []
@@ -223,7 +223,7 @@ describe('GoodBuddy Harness internal control plane', () => {
subject.releaseAttachments(refs)
await expect(store.readImage(refs[0]!)).rejects.toMatchObject({
code: 'NOT_FOUND'
code: 'ATTACHMENT_NOT_FOUND'
})
await expect(
subject.storePromptImages([
@@ -13,6 +13,7 @@ import type { Context } from '@deepseek-ai/cordis'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import {
AttachmentError,
isImageAdmissionError,
type ImageAttachmentRef,
type SaveImageAttachment
} from '@deepseek-ai/dsh-attachment'
@@ -1059,16 +1060,13 @@ export class GoodBuddyHarnessControlPlane {
return await store.saveImages(inputs)
} catch (error) {
if (error instanceof AttachmentError) {
if (error.code === 'INVALID_IMAGE') {
if (isImageAdmissionError(error)) {
throw RequestError.invalidParams(
undefined,
'invalid inline image prompt content'
)
}
if (
error.code === 'STORAGE_LIMIT' ||
error.code === 'DECODER_UNAVAILABLE'
) {
if (error.code === 'ATTACHMENT_WRITE_FAILED') {
throw RequestError.internalError(
undefined,
'Harness image attachment service is unavailable'
+3
View File
@@ -476,6 +476,7 @@ describe('ModelAgentRuntime', () => {
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
maximumOutputTokens: 48_000,
skillInstructions: '# 文档写作',
fetcher
})
@@ -500,11 +501,13 @@ describe('ModelAgentRuntime', () => {
const body = JSON.parse(init?.body as string) as {
model: string
max_tokens: number
stream: boolean
system: string
}
expect(body).toMatchObject({
model: 'sonnet-5',
max_tokens: 48_000,
stream: true
})
expect(body.system).toMatch(
+8 -5
View File
@@ -8,6 +8,7 @@ import type {
ModelProtocol,
RuntimeConversationCompactInput
} from '../../shared/contracts'
import { defaultAnthropicMaximumOutputTokens } from '../../shared/contracts'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BrowserToolService } from '../browser/browser-model-tools'
import type { WorkspaceAccess } from '../workspace'
@@ -233,8 +234,10 @@ export type ModelRuntimeOptions = {
protocol: Exclude<ModelProtocol, 'openai-images-generations'>
authentication: ModelAuthentication
contextWindowTokens?: number
maximumOutputTokens?: number
}
}
maximumOutputTokens?: number
}
function getErrorMessage(value: unknown): string | undefined {
@@ -1794,10 +1797,9 @@ export class ModelAgentRuntime implements AgentRuntime {
}
private get anthropicProtocolMaximumTokens(): number {
return Math.max(
this.options.contextCompression?.contextWindowTokens ??
minimumModelContextWindowTokens,
minimumModelContextWindowTokens
return (
this.options.maximumOutputTokens ??
defaultAnthropicMaximumOutputTokens
)
}
@@ -1877,7 +1879,8 @@ export class ModelAgentRuntime implements AgentRuntime {
ModelProtocol,
'openai-images-generations'
>,
authentication: this.options.authentication
authentication: this.options.authentication,
maximumOutputTokens: this.options.maximumOutputTokens
}
const summaryRuntime = new ModelAgentRuntime({
...summaryModel,
+1
View File
@@ -386,6 +386,7 @@ export function createResolvedModelProfileDigest(
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput === true,
contextWindowTokens: profile.contextWindowTokens ?? null,
maximumOutputTokens: profile.maximumOutputTokens ?? null,
imageGenerationQuality: profile.imageGenerationQuality ?? null
})
)
+2 -2
View File
@@ -192,7 +192,7 @@ describe('runtime discovery', () => {
binaryPath: '',
bundledPath: bundledScript,
bundledValidation: 'canonical-file',
bundledVersion: '0.1.0-rc.6',
bundledVersion: '0.1.0-rc.8',
binaryNames: [],
label: 'GoodBuddy DeepSeek Harness Host'
})
@@ -200,7 +200,7 @@ describe('runtime discovery', () => {
expect(detection).toMatchObject({
available: true,
path: await realpath(bundledScript),
version: '0.1.0-rc.6',
version: '0.1.0-rc.8',
source: 'bundled'
})
expect(detection.detail).toContain('内置')
+5 -5
View File
@@ -34,7 +34,7 @@ describe('controlled DeepSeek Harness host', () => {
api: 'openai-completions',
provider: 'goodbuddy',
model: 'deepseek-test',
harnessVersion: '0.1.0-rc.6',
harnessVersion: '0.1.0-rc.8',
credentialRefs: ['GOODBUDDY_API_KEY'],
dshHome: 'C:\\controlled-dsh-home',
skillPackages: []
@@ -82,7 +82,7 @@ describe('controlled DeepSeek Harness host', () => {
provider: 'goodbuddy',
model: 'qwen-plus',
supportsImageInput: true,
harnessVersion: '0.1.0-rc.6',
harnessVersion: '0.1.0-rc.8',
credentialRefs: ['GOODBUDDY_API_KEY'],
skillPackages: [],
stream: {
@@ -140,7 +140,7 @@ describe('controlled DeepSeek Harness host', () => {
api: 'openai-completions',
provider: 'goodbuddy',
model: 'deepseek-test',
harnessVersion: '0.1.0-rc.6',
harnessVersion: '0.1.0-rc.8',
credentialRefs: ['GOODBUDDY_API_KEY'],
skillPackages: [],
stream: {
@@ -177,7 +177,7 @@ describe('controlled DeepSeek Harness host', () => {
api: 'openai-completions',
provider: 'goodbuddy',
model: 'deepseek-test',
harnessVersion: '0.1.0-rc.6',
harnessVersion: '0.1.0-rc.8',
credentialRefs: ['GOODBUDDY_API_KEY'],
skillPackages: [],
extensionPackages: [
@@ -232,7 +232,7 @@ describe('controlled DeepSeek Harness host', () => {
api: 'openai-completions',
provider: 'goodbuddy',
model: 'deepseek-test',
harnessVersion: '0.1.0-rc.6',
harnessVersion: '0.1.0-rc.8',
credentialRefs: ['GOODBUDDY_API_KEY'],
skillPackages: [
{ id: 'web-3d-game', directory: skillDirectory }
@@ -31,7 +31,7 @@ describe.skipIf(!entrypoint)(
api: 'openai-completions',
provider: 'goodbuddy',
model: 'deepseek-test',
harnessVersion: '0.1.0-rc.6',
harnessVersion: '0.1.0-rc.8',
credentialRefs: ['GOODBUDDY_API_KEY'],
skillPackages: [],
extensionPackages: [
+1 -30
View File
@@ -1,4 +1,5 @@
import { extname } from 'node:path'
export { detectSupportedImage } from '../shared/image-media-type'
const mimeTypes: Readonly<Record<string, string>> = {
'.c': 'text/x-c',
@@ -40,33 +41,3 @@ export function mimeTypeFromFileName(
): string {
return mimeTypes[extname(name).toLocaleLowerCase()] ?? fallback
}
export function detectSupportedImage(data: Buffer): {
extension: 'jpg' | 'png' | 'webp'
mimeType: 'image/jpeg' | 'image/png' | 'image/webp'
} {
if (
data.byteLength >= 3 &&
data[0] === 0xff &&
data[1] === 0xd8 &&
data[2] === 0xff
) {
return { extension: 'jpg', mimeType: 'image/jpeg' }
}
if (
data.byteLength >= 8 &&
data.subarray(0, 8).equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
)
) {
return { extension: 'png', mimeType: 'image/png' }
}
if (
data.byteLength >= 12 &&
data.subarray(0, 4).toString('ascii') === 'RIFF' &&
data.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return { extension: 'webp', mimeType: 'image/webp' }
}
throw new Error('图片格式不受支持')
}
+9
View File
@@ -1091,12 +1091,19 @@ describe('registerIpcHandlers SSH hosts', () => {
}
const agentPackageInventory = {
checkedAt: '2030-01-01T00:00:00.000Z',
catalog: {
state: 'available',
checkedAt: '2030-01-01T00:00:00.000Z',
error: null
},
entries: [
{
platform: 'linux',
architecture: 'x64',
state: 'verified',
version: '0.11.2',
latestVersion: '0.11.4',
updateAvailable: true,
remoteRuntimeVersion: '1.18.9',
agentProtocol: { major: 2, minor: 0 }
},
@@ -1105,6 +1112,8 @@ describe('registerIpcHandlers SSH hosts', () => {
architecture: 'arm64',
state: 'not-downloaded',
version: null,
latestVersion: '0.11.4',
updateAvailable: false,
remoteRuntimeVersion: null,
agentProtocol: null
}
@@ -344,25 +344,31 @@ describe('AgentAttachTransport', () => {
expect(transport.closed).toBe(true)
})
it('writes one maximum-sized blob frame in one SSH write', async () => {
const channel = new FakeChannel()
channel.onWrite = () => {
if (channel.writes.length === 1) {
queueMicrotask(() => channel.remote(packet(welcome)))
it(
'writes one maximum-sized blob frame in one SSH write',
async () => {
const channel = new FakeChannel()
channel.onWrite = () => {
if (channel.writes.length === 1) {
queueMicrotask(() => channel.remote(packet(welcome)))
}
}
}
const transport = await connect(channel)
const blob = frame(
{ direction: 'main-to-agent', kind: 'blob' },
Buffer.alloc(AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes)
)
const transport = await connect(channel)
const blob = frame(
{ direction: 'main-to-agent', kind: 'blob' },
Buffer.alloc(AGENT_PROTOCOL_LIMITS.maximumBlobFrameBytes)
)
await transport.send(blob)
await transport.send(blob)
expect(channel.writes).toHaveLength(2)
expect(channel.writes[1]).toEqual(Buffer.from(encodeAgentFrame(blob)))
transport.dispose()
})
expect(channel.writes).toHaveLength(2)
expect(channel.writes[1]).toEqual(
Buffer.from(encodeAgentFrame(blob))
)
transport.dispose()
},
15_000
)
it('rejects an oversized blob before writing to SSH', async () => {
const channel = new FakeChannel()
@@ -32,6 +32,8 @@ import type {
import {
agentManifestSignaturePayload,
canonicalAgentManifestBytes,
canonicalAgentReleaseKeyRegistryBytes,
parseAgentReleaseKeyRegistryBytes,
verifyAgentBundleDirectory,
verifyAgentManifestSignature,
type VerifiedAgentBundle
@@ -280,6 +282,35 @@ afterEach(() => {
})
describe('runtime and build-time Agent verifier parity', () => {
it('accepts equivalent registry whitespace and canonicalizes validated keys', () => {
const canonical = canonicalAgentReleaseKeyRegistryBytes(registry)
const crlf = Buffer.from(
canonical.toString('utf8').replace(/\n/gu, '\r\n'),
'utf8'
)
const parsed = parseAgentReleaseKeyRegistryBytes(crlf)
expect(parsed).toEqual(registry)
expect(canonicalAgentReleaseKeyRegistryBytes(parsed)).toEqual(
canonical
)
})
it('rejects malformed or non-Ed25519 registry keys', () => {
expect(() =>
parseAgentReleaseKeyRegistryBytes(
Buffer.from(JSON.stringify({
...registry,
keys: [{
...registry.keys[0],
publicKeySpkiBase64: 'AAAA'
}]
}))
)
).toThrow('contract is invalid')
})
it('uses the exact build-time canonical bytes and signature domain', async () => {
const manifest = writeBundle()
const manifestBytes = readFileSync(
+31 -4
View File
@@ -60,11 +60,15 @@ export type VerifyBundledAgentOptions = {
export async function readAgentReleaseKeyRegistry(
filePath: string
): Promise<AgentReleaseKeyRegistry> {
const value = await readJsonMetadata(
const bytes = await readBoundedFile(
filePath,
MAXIMUM_METADATA_BYTES,
'Agent trusted key registry'
)
return parseAgentReleaseKeyRegistryBytes(
bytes,
'Agent trusted key registry'
)
return parseAgentReleaseKeyRegistry(value)
}
export async function readAgentRuntimeLock(
@@ -233,13 +237,36 @@ export function canonicalAgentManifestBytes(
export function canonicalAgentReleaseKeyRegistryBytes(
registry: AgentReleaseKeyRegistry
): Buffer {
const canonical = agentReleaseKeyRegistrySchema.parse(registry)
const canonical = parseAgentReleaseKeyRegistry(registry)
return Buffer.from(
`${JSON.stringify(canonical, null, 2)}\n`,
'utf8'
)
}
export function parseAgentReleaseKeyRegistryBytes(
bytes: Uint8Array,
description = 'Agent trusted key registry'
): AgentReleaseKeyRegistry {
let value: unknown
try {
value = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
) as unknown
} catch (error) {
throw new Error(`${description} is invalid JSON`, {
cause: error
})
}
try {
return parseAgentReleaseKeyRegistry(value)
} catch (error) {
throw new Error(`${description} contract is invalid`, {
cause: error
})
}
}
export function agentManifestSignaturePayload(
manifestBytes: Uint8Array
): Buffer {
@@ -451,7 +478,7 @@ function importEd25519PublicKey(key: AgentReleaseKey) {
return publicKey
}
function parseAgentReleaseKeyRegistry(
export function parseAgentReleaseKeyRegistry(
value: unknown
): AgentReleaseKeyRegistry {
const registry = parseContract(
@@ -243,7 +243,10 @@ const registry: AgentReleaseKeyRegistry = {
formatVersion: 1,
keys: [{
keyId: 'test-key',
publicKeySpkiBase64: 'AAAA',
publicKeySpkiBase64: Buffer.from(
'302a300506032b6570032100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f',
'hex'
).toString('base64'),
environment: 'test'
}],
revocations: []
@@ -1,7 +1,6 @@
import { createHash, randomUUID } from 'node:crypto'
import { lstat, readFile } from 'node:fs/promises'
import {
agentReleaseKeyRegistrySchema,
type AgentArchitecture,
type AgentBundleManifest,
type AgentReleaseKeyRegistry
@@ -31,6 +30,8 @@ import {
import {
assertAgentManifestMatchesRuntimeLock,
canonicalAgentReleaseKeyRegistryBytes,
parseAgentReleaseKeyRegistry,
parseAgentReleaseKeyRegistryBytes,
readAgentReleaseKeyRegistry,
readAgentRuntimeLock,
verifyAgentManifestSignature,
@@ -599,7 +600,7 @@ export class AgentInstallationManager {
)
}
const canonicalRegistry =
agentReleaseKeyRegistrySchema.parse(loaded.registry)
parseAgentReleaseKeyRegistry(loaded.registry)
return {
bundle: loaded.bundle,
registry: canonicalRegistry,
@@ -627,7 +628,7 @@ export class AgentInstallationManager {
verificationEnvironment: this.#verificationEnvironment
}
)
const canonicalRegistry = agentReleaseKeyRegistrySchema.parse(
const canonicalRegistry = parseAgentReleaseKeyRegistry(
registry
)
return {
@@ -648,14 +649,14 @@ export class AgentInstallationManager {
},
signal: AbortSignal
): Promise<AgentInstallationIdentity> {
const [canonicalRegistryBytes, runtimeLock] = await Promise.all([
readFile(this.#resourcePaths.keyRegistryPath),
const [registry, runtimeLock] = await Promise.all([
readAgentReleaseKeyRegistry(
this.#resourcePaths.keyRegistryPath
),
readAgentRuntimeLock(this.#resourcePaths.runtimeLockPath)
])
const registry = parseCanonicalAgentReleaseKeyRegistry(
canonicalRegistryBytes,
'Agent trusted key registry'
)
const canonicalRegistryBytes =
canonicalAgentReleaseKeyRegistryBytes(registry)
let metadataSftp: StagedSftp | undefined
let installationSftp: StagedSftp | undefined
@@ -706,7 +707,7 @@ export class AgentInstallationManager {
signal
)
const remoteReleaseKeyRegistry =
parseCanonicalAgentReleaseKeyRegistry(
parseAgentReleaseKeyRegistryBytes(
remoteReleaseKeyBytes,
'Installed Agent release-key registry'
)
@@ -851,21 +852,6 @@ function parseJsonBytes(
}
}
function parseCanonicalAgentReleaseKeyRegistry(
value: Buffer,
description: string
): AgentReleaseKeyRegistry {
const registry = agentReleaseKeyRegistrySchema.parse(
parseJsonBytes(value, description)
)
const canonicalBytes =
canonicalAgentReleaseKeyRegistryBytes(registry)
if (!canonicalBytes.equals(value)) {
throw new Error(`${description} is not canonical`)
}
return registry
}
function decodeDetachedSignature(
value: Buffer,
description: string
+153 -27
View File
@@ -32,7 +32,6 @@ import {
} from '../../shared/agent-protocol/contracts'
import {
agentArchitectureSchema,
agentReleaseKeyRegistrySchema,
type AgentArchitecture,
type AgentReleaseKeyRegistry
} from '../../shared/agent-installation-contracts'
@@ -49,7 +48,7 @@ import type {
AgentInstallationBundleLoader
} from './agent-installation-manager'
import {
canonicalAgentReleaseKeyRegistryBytes
readAgentReleaseKeyRegistry
} from './agent-bundle-verifier'
import type {
RemoteRuntimeInstallationBundleLoader,
@@ -84,6 +83,18 @@ type InstalledRecord = {
verified: VerifiedAgentPackage
}
type CatalogState =
| {
state: 'available'
checkedAt: string
catalog: AgentPackageCatalog
}
| {
state: 'unavailable'
checkedAt: string
error: string
}
export type AgentPackageManagerOptions = {
userDataPath: string
desktopVersion: string
@@ -111,6 +122,7 @@ export class AgentPackageManager {
>()
readonly #leaseCounts = new Map<string, number>()
readonly #pendingRemovals = new Set<string>()
#catalogState?: CatalogState
constructor(options: AgentPackageManagerOptions) {
this.#rootDirectory = resolve(
@@ -170,11 +182,17 @@ export class AgentPackageManager {
} catch {
state = 'invalid'
}
const latestVersion = this.#latestVersion(architecture)
return {
platform: 'linux' as const,
architecture,
state,
version: record?.verified.descriptor.version ?? null,
latestVersion,
updateAvailable: this.#updateAvailable(
latestVersion,
record?.verified.descriptor.version ?? null
),
remoteRuntimeVersion:
record?.verified.descriptor.remoteRuntime.version ?? null,
agentProtocol:
@@ -184,14 +202,35 @@ export class AgentPackageManager {
)
return agentPackageInventorySchema.parse({
checkedAt: this.#now().toISOString(),
catalog: this.#publicCatalogState(),
entries
})
}
getSnapshot(
async getSnapshot(
options: { refresh?: boolean } = {}
): Promise<AgentPackageInventory> {
return this.getInventory(options)
const catalogCheck = this.#checkCatalog()
const inventory = await this.getInventory(options)
await catalogCheck
return agentPackageInventorySchema.parse({
...inventory,
checkedAt: this.#now().toISOString(),
catalog: this.#publicCatalogState(),
entries: inventory.entries.map((entry) => {
const latestVersion = this.#latestVersion(
entry.architecture
)
return {
...entry,
latestVersion,
updateAvailable: this.#updateAvailable(
latestVersion,
entry.version
)
}
})
})
}
async getExpectedCatalog(
@@ -229,24 +268,34 @@ export class AgentPackageManager {
totalBytes: null
})
const source = await this.#getUpdateSource()
const catalog = await this.#loadCatalog(source)
const catalog = await this.#loadCatalogAndRemember(source)
const entry = selectLatestCompatibleEntry(
catalog,
architecture,
this.#desktopVersion
)
const current = await this.#loadInstalled(architecture)
const comparison = current
? compareSemanticVersions(
entry.version,
current.verified.descriptor.version
)
: 1
if (
current &&
compareSemanticVersions(
entry.version,
current.verified.descriptor.version
) < 0
comparison < 0
) {
throw new Error(
'Agent 发布目录不能降级已安装的兼容版本'
)
}
if (
current &&
comparison === 0 &&
current.archiveSha256 === entry.sha256
) {
return this.getInventory()
}
const staging = await this.#createStaging(architecture)
const archivePath = join(staging, entry.archive)
try {
@@ -710,6 +759,90 @@ export class AgentPackageManager {
return catalog
}
async #loadCatalogAndRemember(
source: UpdateSource
): Promise<AgentPackageCatalog> {
const checkedAt = this.#now().toISOString()
try {
const catalog = await this.#loadCatalog(source)
this.#catalogState = {
state: 'available',
checkedAt,
catalog
}
return catalog
} catch (error) {
this.#catalogState = {
state: 'unavailable',
checkedAt,
error: boundedErrorMessage(error)
}
throw error
}
}
async #checkCatalog(): Promise<void> {
try {
await this.#loadCatalogAndRemember(
await this.#getUpdateSource()
)
} catch {
// The failed catalog state is already recorded. Local packages remain usable.
}
}
#latestVersion(
architecture: AgentArchitecture
): string | null {
if (this.#catalogState?.state !== 'available') {
return null
}
try {
return selectLatestCompatibleEntry(
this.#catalogState.catalog,
architecture,
this.#desktopVersion
).version
} catch {
return null
}
}
#updateAvailable(
latestVersion: string | null,
installedVersion: string | null
): boolean {
return installedVersion !== null &&
latestVersion !== null &&
compareSemanticVersions(
latestVersion,
installedVersion
) > 0
}
#publicCatalogState():
AgentPackageInventory['catalog'] {
if (this.#catalogState === undefined) {
return {
state: 'not-checked',
checkedAt: null,
error: null
}
}
if (this.#catalogState.state === 'available') {
return {
state: 'available',
checkedAt: this.#catalogState.checkedAt,
error: null
}
}
return {
state: 'unavailable',
checkedAt: this.#catalogState.checkedAt,
error: this.#catalogState.error
}
}
async #fetchMirrorCatalog(): Promise<[Buffer, Buffer]> {
const pointerBytes = await this.#fetchBounded(
`${MIRROR_ROOT}latest.json`
@@ -874,7 +1007,7 @@ export class AgentPackageManager {
}
#loadTrustedRegistry(): Promise<AgentReleaseKeyRegistry> {
this.#trustedRegistry ??= readCanonicalRegistry(
this.#trustedRegistry ??= readAgentReleaseKeyRegistry(
this.#keyRegistryPath
)
return this.#trustedRegistry
@@ -1010,23 +1143,6 @@ function assertCanonicalDownload(
}
}
async function readCanonicalRegistry(
filePath: string
): Promise<AgentReleaseKeyRegistry> {
const bytes = await readFile(filePath)
const registry = agentReleaseKeyRegistrySchema.parse(
JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
)
)
if (
!canonicalAgentReleaseKeyRegistryBytes(registry).equals(bytes)
) {
throw new Error('Agent 签名公钥目录不是规范格式')
}
return registry
}
function parseCanonicalCatalog(bytes: Buffer): AgentPackageCatalog {
const catalog = agentPackageCatalogSchema.parse(
JSON.parse(
@@ -1125,6 +1241,16 @@ function verifyCatalogSignature(
}
}
function boundedErrorMessage(error: unknown): string {
const message = error instanceof Error
? error.message
: String(error)
return (
message.trim().slice(0, 2_000) ||
'Agent 发布目录检查失败'
)
}
async function copyAndHashBoundedRegularFile(
sourcePath: string,
destinationPath: string,
+14 -20
View File
@@ -38,12 +38,12 @@ import {
canonicalJson
} from '../../shared/agent-protocol/canonical'
import {
agentReleaseKeyRegistrySchema,
type AgentArchitecture,
type AgentReleaseKeyRegistry
} from '../../shared/agent-installation-contracts'
import {
canonicalAgentReleaseKeyRegistryBytes,
parseAgentReleaseKeyRegistry,
parseAgentReleaseKeyRegistryBytes,
verifyAgentBundleDirectory,
type AgentVerificationEnvironment,
type VerifiedAgentBundle
@@ -67,6 +67,7 @@ const MAXIMUM_EXPANDED_BYTES = 1024 * 1024 * 1024
const MAXIMUM_FILE_BYTES = 384 * 1024 * 1024
const MAXIMUM_METADATA_BYTES = 1024 * 1024
const MAXIMUM_ENTRIES = 50_002
const PRIVATE_DIRECTORY_MODE = 0o700
const SIGNATURE_DOMAIN = Buffer.from(
'GoodBuddy Agent Package Descriptor Signature v1\0',
'utf8'
@@ -91,7 +92,10 @@ export async function extractAndVerifyAgentPackage(options: {
const archivePath = resolve(options.archivePath)
const destinationDirectory = resolve(options.destinationDirectory)
await assertArchiveFile(archivePath)
await mkdir(destinationDirectory, { recursive: false })
await mkdir(destinationDirectory, {
recursive: false,
mode: PRIVATE_DIRECTORY_MODE
})
try {
await extractArchive(archivePath, destinationDirectory)
return await verifyExtractedAgentPackage({
@@ -152,8 +156,11 @@ export async function verifyExtractedAgentPackage(options: {
'Agent package is not compatible with this GoodBuddy version'
)
}
const registry = parseCanonicalRegistry(registryBytes)
const trustedRegistry = agentReleaseKeyRegistrySchema.parse(
const registry = parseAgentReleaseKeyRegistryBytes(
registryBytes,
'Agent package key registry'
)
const trustedRegistry = parseAgentReleaseKeyRegistry(
options.trustedRegistry
)
verifyDescriptorSignature(
@@ -354,7 +361,8 @@ async function extractArchive(
throw new Error('Agent package entry escapes its destination')
}
const handlePromise = mkdir(dirname(destinationPath), {
recursive: true
recursive: true,
mode: PRIVATE_DIRECTORY_MODE
}).then(async () => {
const handle = await open(destinationPath, 'wx')
openHandles.add(handle)
@@ -540,20 +548,6 @@ function parseCanonicalDescriptor(
return descriptor
}
function parseCanonicalRegistry(bytes: Buffer) {
const registry = agentReleaseKeyRegistrySchema.parse(
JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(bytes)
)
)
if (
!canonicalAgentReleaseKeyRegistryBytes(registry).equals(bytes)
) {
throw new Error('Agent package key registry is not canonical')
}
return registry
}
function verifyDescriptorSignature(
descriptor: AgentPackageDescriptor,
descriptorBytes: Buffer,
@@ -236,7 +236,10 @@ const registry: AgentReleaseKeyRegistry = {
formatVersion: 1,
keys: [{
keyId: 'test-key',
publicKeySpkiBase64: 'AAAA',
publicKeySpkiBase64: Buffer.from(
'302a300506032b6570032100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f',
'hex'
).toString('base64'),
environment: 'test'
}],
revocations: []
@@ -35,6 +35,9 @@ import type {
SshConnectionPoolTarget
} from '../ssh/ssh-connection-pool'
import { settleBoundedly } from './bounded-settlement'
import {
parseAgentReleaseKeyRegistryBytes
} from './agent-bundle-verifier'
import { isMissingPathError } from './path-errors'
const MAXIMUM_INSTALLATION_FILES = 200
@@ -711,18 +714,13 @@ export class RemoteRuntimeInstallationManager {
metadataSftp.readFile(RELEASE_KEYS_PATH, signal),
metadataSftp.readFile(RUNTIME_LOCK_PATH, signal)
])
const remoteReleaseKeyRegistryBytes = canonicalJsonBytes(
remoteReleaseKeyBytes,
agentReleaseKeyRegistrySchema,
const remoteReleaseKeyRegistryBytes =
Buffer.from(remoteReleaseKeyBytes)
const remoteReleaseKeyRegistry =
parseAgentReleaseKeyRegistryBytes(
remoteReleaseKeyRegistryBytes,
'Installed Runtime release-key registry'
)
const remoteReleaseKeyRegistry =
agentReleaseKeyRegistrySchema.parse(
parseJsonBytes(
remoteReleaseKeyRegistryBytes,
'Installed Runtime release-key registry'
)
)
if (!remoteRuntimeLockFileBytes.equals(remoteRuntimeLockBytes)) {
throw new Error(
'Installed Runtime lock does not match this GoodBuddy build'
@@ -173,6 +173,23 @@ describe('remote Runtime resource loader', () => {
})
})
it('accepts equivalent key-registry formatting and returns canonical bytes', async () => {
const fixture = await createFixture()
await writeFile(
fixture.paths.keyRegistryPath,
Buffer.from(
canonical(registry).toString('utf8').replace(/\n/gu, '\r\n')
)
)
await expect(
loadRemoteRuntimeVerificationMetadata(fixture.paths)
).resolves.toMatchObject({
releaseKeyRegistry: registry,
canonicalReleaseKeyRegistryBytes: canonical(registry)
})
})
it('rejects a symlinked digest directory', async () => {
const fixture = await createFixture({ digestDirectories: 0 })
const target = join(fixture.root, 'target')
@@ -226,11 +243,6 @@ describe('remote Runtime resource loader', () => {
bytes: Buffer.from('not json\n'),
error: 'invalid JSON'
},
{
name: 'noncanonical',
bytes: Buffer.from(JSON.stringify(registry)),
error: 'not canonical'
},
{
name: 'oversize',
bytes: Buffer.alloc(1024 * 1024 + 1, 0x20),
@@ -11,7 +11,6 @@ import {
type VerifyRuntimeBundleOptions
} from '../../shared/node/runtime-bundle-verifier'
import {
agentReleaseKeyRegistrySchema,
type AgentArchitecture,
type AgentReleaseKeyRegistry
} from '../../shared/agent-installation-contracts'
@@ -30,6 +29,9 @@ import {
getBundledRemoteRuntimeRoot,
type BundledRemoteRuntimeResourcePaths
} from './bundled-remote-runtime-resources'
import {
parseAgentReleaseKeyRegistry
} from './agent-bundle-verifier'
import { isMissingPathError } from './path-errors'
const MAXIMUM_METADATA_BYTES = 1024 * 1024
@@ -106,8 +108,9 @@ export async function loadRemoteRuntimeVerificationMetadata(
const [keyRegistry, runtimeLock] = await Promise.all([
readCanonicalMetadata(
paths.keyRegistryPath,
agentReleaseKeyRegistrySchema,
'Runtime release-key registry'
{ parse: parseAgentReleaseKeyRegistry },
'Runtime release-key registry',
true
),
readCanonicalMetadata(
paths.runtimeLockPath,
@@ -139,7 +142,8 @@ function assertSafeVerificationInjection(
async function readCanonicalMetadata<T>(
filePathInput: string,
schema: { parse(value: unknown): T },
label: string
label: string,
acceptEquivalentJsonFormatting = false
): Promise<CanonicalMetadata<T>> {
const filePath = resolve(filePathInput)
const pathStat = await lstat(filePath)
@@ -212,7 +216,10 @@ async function readCanonicalMetadata<T>(
`${JSON.stringify(value, null, 2)}\n`,
'utf8'
)
if (!canonicalBytes.equals(bytes)) {
if (
!acceptEquivalentJsonFormatting &&
!canonicalBytes.equals(bytes)
) {
throw new Error(`${label} is not canonical`)
}
return { bytes: canonicalBytes, value }
+102 -16
View File
@@ -80,6 +80,84 @@ afterEach(async () => {
})
describe('RuntimeSettingsStore', () => {
it('seeds a local provider-neutral model connection', async () => {
const { store } = await createStore()
await expect(store.getPublicSettings()).resolves.toMatchObject({
modelBaseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
modelProtocol: 'openai-chat-completions',
modelAuthentication: 'none',
modelProfiles: [
expect.objectContaining({
id: '00000000-0000-4000-8000-000000000001',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
authentication: 'none'
})
]
})
})
it('replaces only the untouched legacy BigToken seed', async () => {
const { filePath, store } = await createStore()
await store.update(settings())
const version19 = JSON.parse(
await readFile(filePath, 'utf8')
) as Record<string, unknown>
version19.version = 19
await writeFile(filePath, JSON.stringify(version19), 'utf8')
const migrated = new RuntimeSettingsStore(filePath, cipher, {})
await expect(migrated.getPublicSettings()).resolves.toMatchObject({
modelBaseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
modelAuthentication: 'none'
})
})
it('preserves an explicitly credentialed BigToken connection', async () => {
const { filePath, store } = await createStore()
await store.update(
settings({
apiKey: { action: 'replace', value: 'explicit-key' }
})
)
const version19 = JSON.parse(
await readFile(filePath, 'utf8')
) as Record<string, unknown>
version19.version = 19
await writeFile(filePath, JSON.stringify(version19), 'utf8')
const migrated = new RuntimeSettingsStore(filePath, cipher, {})
await expect(migrated.getResolvedSettings()).resolves.toMatchObject({
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
apiKey: 'explicit-key'
})
})
it('preserves the historical connection for a legacy environment key', async () => {
const { filePath, store } = await createStore()
await store.update(settings())
const version19 = JSON.parse(
await readFile(filePath, 'utf8')
) as Record<string, unknown>
version19.version = 19
await writeFile(filePath, JSON.stringify(version19), 'utf8')
const migrated = new RuntimeSettingsStore(filePath, cipher, {
GOODBUDDY_BIGTOKEN_API_KEY: 'legacy-key'
})
await expect(migrated.getResolvedSettings()).resolves.toMatchObject({
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
modelProtocol: 'anthropic-messages',
modelAuthentication: 'api-key',
apiKey: 'legacy-key'
})
})
it('restores an exact credential-bearing snapshot after a failed activation', async () => {
const { store } = await createStore()
await store.update(
@@ -205,6 +283,7 @@ describe('RuntimeSettingsStore', () => {
authentication: 'none',
supportsImageInput: false,
contextWindowTokens: 256_000,
maximumOutputTokens: 48_000,
imageGenerationQuality: 'auto',
apiKey: { action: 'keep' }
}
@@ -224,7 +303,8 @@ describe('RuntimeSettingsStore', () => {
modelProfiles: [
{
id: profileId,
contextWindowTokens: 256_000
contextWindowTokens: 256_000,
maximumOutputTokens: 48_000
}
],
contextCompression: {
@@ -238,7 +318,8 @@ describe('RuntimeSettingsStore', () => {
modelProfiles: [
{
id: profileId,
contextWindowTokens: 256_000
contextWindowTokens: 256_000,
maximumOutputTokens: 48_000
}
],
contextCompression: {
@@ -382,7 +463,7 @@ describe('RuntimeSettingsStore', () => {
})
await expect(store.getResolvedSettings()).resolves.toMatchObject({
modelProtocol: 'anthropic-messages',
modelProtocol: 'openai-chat-completions',
deepseekHarnessModelProfile: {
id: 'goodbuddy-platform-harness',
name: '管理员预置模型',
@@ -398,7 +479,9 @@ describe('RuntimeSettingsStore', () => {
const publicSettings = await store.getPublicSettings()
expect(JSON.stringify(publicSettings)).not.toContain(apiKey)
expect(publicSettings.modelProtocol).toBe('anthropic-messages')
expect(publicSettings.modelProtocol).toBe(
'openai-chat-completions'
)
})
it.each([
@@ -481,7 +564,7 @@ describe('RuntimeSettingsStore', () => {
const persisted = JSON.parse(
await readFile(filePath, 'utf8')
) as Record<string, unknown>
expect(persisted.version).toBe(19)
expect(persisted.version).toBe(20)
expect(persisted).not.toHaveProperty(
'deepseekHarnessBinaryPath'
)
@@ -760,7 +843,7 @@ describe('RuntimeSettingsStore', () => {
const persisted = JSON.parse(await readFile(filePath, 'utf8')) as {
version: number
}
expect(persisted.version).toBe(19)
expect(persisted.version).toBe(20)
})
it('migrates version 11 and removes the obsolete intranet toggle', async () => {
@@ -780,7 +863,7 @@ describe('RuntimeSettingsStore', () => {
version: number
intranetCompatibilityEnabled?: boolean
}
expect(persisted.version).toBe(19)
expect(persisted.version).toBe(20)
expect(persisted).not.toHaveProperty('intranetCompatibilityEnabled')
})
@@ -1058,7 +1141,7 @@ describe('RuntimeSettingsStore', () => {
(JSON.parse(await readFile(filePath, 'utf8')) as {
version: number
}).version
).toBe(19)
).toBe(20)
})
it('migrates version 13 with reranking disabled by default', async () => {
@@ -1565,7 +1648,7 @@ describe('RuntimeSettingsStore', () => {
version: number
modelProfiles: Array<Record<string, unknown>>
}
expect(persisted.version).toBe(19)
expect(persisted.version).toBe(20)
expect(persisted.modelProfiles).toContainEqual(
expect.objectContaining({
id: imageId,
@@ -1653,7 +1736,7 @@ describe('RuntimeSettingsStore', () => {
})
})
it('does not mix an environment key with a stored base URL', async () => {
it('keeps provider-specific environment aliases for existing deployments', async () => {
const { filePath, store } = await createStore()
await store.update(
settings({
@@ -1663,11 +1746,14 @@ describe('RuntimeSettingsStore', () => {
)
const environmentStore = new RuntimeSettingsStore(filePath, cipher, {
GOODBUDDY_BIGTOKEN_API_KEY: 'YOUR_API_KEY_HERE'
GOODBUDDY_BIGTOKEN_API_KEY: 'legacy-key',
GOODBUDDY_BIGTOKEN_BASE_URL: 'https://legacy.example',
GOODBUDDY_BIGTOKEN_MODEL: 'legacy-model'
})
await expect(environmentStore.getResolvedSettings()).resolves.toMatchObject({
apiKey: 'YOUR_API_KEY_HERE',
modelBaseUrl: 'https://bigtoken.ai'
apiKey: 'legacy-key',
modelBaseUrl: 'https://legacy.example',
modelName: 'legacy-model'
})
})
@@ -1724,7 +1810,7 @@ describe('RuntimeSettingsStore', () => {
})
})
it('prefers generic model environment variables over legacy fallbacks', async () => {
it('prefers generic model environment variables over legacy aliases', async () => {
const { filePath } = await createStore()
const store = new RuntimeSettingsStore(filePath, cipher, {
GOODBUDDY_MODEL_API_KEY: 'generic-key',
@@ -1864,7 +1950,7 @@ describe('RuntimeSettingsStore', () => {
unknown
>
expect(saved).toMatchObject({
version: 19,
version: 20,
provider: 'model',
continueBinaryPath: '',
continueMode: 'chat',
@@ -2143,7 +2229,7 @@ describe('RuntimeSettingsStore', () => {
version: number
modelProfiles: Array<Record<string, unknown>>
}
expect(persisted.version).toBe(19)
expect(persisted.version).toBe(20)
expect(persisted.modelProfiles[0]).not.toHaveProperty('credential')
})
+108 -23
View File
@@ -170,6 +170,12 @@ const currentStoredModelProfileSchema = storedModelProfileSchema.extend({
.int()
.min(8_000)
.max(10_000_000)
.optional(),
maximumOutputTokens: z
.number()
.int()
.min(1)
.max(10_000_000)
.optional()
})
@@ -259,7 +265,7 @@ const storedEmbeddingConnectionSchema = z.discriminatedUnion('kind', [
.strict()
])
const storedSettingsSchema = version18StoredSettingsSchema
const version19StoredSettingsSchema = version18StoredSettingsSchema
.omit({ version: true })
.extend({
version: z.literal(19),
@@ -270,6 +276,12 @@ const storedSettingsSchema = version18StoredSettingsSchema
activeEmbeddingConnectionId: z.string().uuid()
})
const storedSettingsSchema = version19StoredSettingsSchema
.omit({ version: true })
.extend({
version: z.literal(20)
})
type StoredSettings = z.infer<typeof storedSettingsSchema>
export type RuntimeSettingsRollback = {
publicSettings: RuntimeSettings
@@ -281,6 +293,9 @@ type Version17StoredSettings = z.infer<
type Version18StoredSettings = z.infer<
typeof version18StoredSettingsSchema
>
type Version19StoredSettings = z.infer<
typeof version19StoredSettingsSchema
>
type Version16StoredSettings = z.infer<
typeof version16StoredSettingsSchema
>
@@ -366,6 +381,9 @@ const encryptSavedApiKey = (
})
const platformHarnessProfileId = 'goodbuddy-platform-harness'
const historicalDefaultModelBaseUrl = 'https://bigtoken.ai'
const historicalDefaultModelName = 'sonnet-5'
const historicalDefaultModelProtocol = 'anthropic-messages'
export type CredentialCipher = SettingsCredentialCipher
@@ -422,6 +440,7 @@ export type ResolvedModelProfile = {
authentication: RuntimeSettings['modelAuthentication']
supportsImageInput?: boolean
contextWindowTokens?: number
maximumOutputTokens?: number
imageGenerationQuality?: RuntimeSettings['imageGenerationQuality']
apiKey?: string
}
@@ -449,7 +468,7 @@ type ResolvedModelCredential = {
}
const defaultSettings: StoredSettings = {
version: 19,
version: 20,
provider: defaultRuntimeSettings.provider,
modelProfiles: [
{
@@ -653,7 +672,7 @@ function migrateVersion17(
function migrateVersion18(
settings: Version18StoredSettings
): StoredSettings {
return {
return migrateVersion19({
...settings,
version: 19,
embeddingConnections: [
@@ -673,6 +692,36 @@ function migrateVersion18(
}
],
activeEmbeddingConnectionId: legacyEmbeddingConnectionId
})
}
function migrateVersion19(
settings: Version19StoredSettings
): StoredSettings {
return {
...settings,
version: 20,
modelProfiles: settings.modelProfiles.map((profile) =>
profile.id === defaultModelProfileId &&
profile.name === '默认模型' &&
profile.baseUrl === historicalDefaultModelBaseUrl &&
profile.modelName === historicalDefaultModelName &&
profile.protocol === historicalDefaultModelProtocol &&
profile.authentication === 'api-key' &&
profile.supportsImageInput === false &&
profile.imageGenerationQuality === 'auto' &&
profile.contextWindowTokens === undefined &&
profile.credential === undefined
? {
...profile,
baseUrl: defaultRuntimeSettings.modelBaseUrl,
modelName: defaultRuntimeSettings.modelName,
protocol: defaultRuntimeSettings.modelProtocol,
authentication:
defaultRuntimeSettings.modelAuthentication
}
: profile
)
}
}
@@ -995,13 +1044,17 @@ export class RuntimeSettingsStore {
const parsed: unknown = JSON.parse(contents)
assertSupportedSettingsVersion(
parsed,
19,
20,
(version) =>
`当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试`
)
const current = storedSettingsSchema.safeParse(parsed)
const version19 =
version19StoredSettingsSchema.safeParse(parsed)
if (current.success) {
this.settings = current.data
} else if (version19.success) {
this.settings = migrateVersion19(version19.data)
} else {
const version18 =
version18StoredSettingsSchema.safeParse(parsed)
@@ -1377,10 +1430,7 @@ export class RuntimeSettingsStore {
settings.modelProfiles.find(
(profile) => profile.id === settings.defaultModelProfileId
) ?? settings.modelProfiles[0]
const environmentApiKey =
defaultProfile?.authentication === 'api-key'
? this.getEnvironmentApiKey()
: undefined
const environmentApiKey = this.getEnvironmentApiKey()
return new Map(
settings.modelProfiles.map((profile) => {
const environmentManaged =
@@ -1401,11 +1451,11 @@ export class RuntimeSettingsStore {
profile.id,
{
activeApiKey:
profile.authentication === 'api-key'
? environmentManaged
? environmentApiKey
: storedApiKey
: undefined,
environmentManaged
? environmentApiKey
: profile.authentication === 'api-key'
? storedApiKey
: undefined,
configured: environmentManaged || Boolean(storedApiKey),
source
}
@@ -1425,6 +1475,7 @@ export class RuntimeSettingsStore {
authentication: RuntimeSettings['modelAuthentication']
supportsImageInput: boolean
contextWindowTokens?: number
maximumOutputTokens?: number
imageGenerationQuality: RuntimeSettings['imageGenerationQuality']
credentialSource: RuntimeSettings['credentialSource']
} {
@@ -1440,26 +1491,53 @@ export class RuntimeSettingsStore {
throw new Error(`模型连接不存在:${profile.id}`)
}
const environmentManaged = credential.source === 'environment'
const environmentBaseUrl =
this.environment.GOODBUDDY_MODEL_BASE_URL?.trim() ||
const genericEnvironmentApiKey =
this.environment.GOODBUDDY_MODEL_API_KEY?.trim()
const legacyEnvironmentApiKey =
this.environment.GOODBUDDY_BIGTOKEN_API_KEY?.trim()
const genericEnvironmentBaseUrl =
this.environment.GOODBUDDY_MODEL_BASE_URL?.trim()
const legacyEnvironmentBaseUrl =
this.environment.GOODBUDDY_BIGTOKEN_BASE_URL?.trim()
const environmentModel =
this.environment.GOODBUDDY_MODEL_NAME?.trim() ||
const genericEnvironmentModel =
this.environment.GOODBUDDY_MODEL_NAME?.trim()
const legacyEnvironmentModel =
this.environment.GOODBUDDY_BIGTOKEN_MODEL?.trim()
const legacyEnvironmentConnection =
environmentManaged &&
!genericEnvironmentApiKey &&
Boolean(legacyEnvironmentApiKey) &&
!genericEnvironmentBaseUrl &&
!genericEnvironmentModel
const environmentBaseUrl =
genericEnvironmentBaseUrl || legacyEnvironmentBaseUrl
const environmentModel =
genericEnvironmentModel || legacyEnvironmentModel
const baseUrl = environmentManaged
? environmentBaseUrl || defaultRuntimeSettings.modelBaseUrl
? environmentBaseUrl ||
(legacyEnvironmentConnection
? historicalDefaultModelBaseUrl
: defaultRuntimeSettings.modelBaseUrl)
: profile.baseUrl
const model = environmentManaged
? environmentModel || defaultRuntimeSettings.modelName
? environmentModel ||
(legacyEnvironmentConnection
? historicalDefaultModelName
: defaultRuntimeSettings.modelName)
: profile.modelName
return {
apiKey: credential.activeApiKey,
baseUrl,
model,
protocol: profile.protocol,
authentication: profile.authentication,
protocol: legacyEnvironmentConnection
? historicalDefaultModelProtocol
: profile.protocol,
authentication: environmentManaged
? 'api-key'
: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality,
credentialSource: credential.source
}
@@ -1483,6 +1561,7 @@ export class RuntimeSettingsStore {
authentication: effective.authentication,
supportsImageInput: effective.supportsImageInput,
contextWindowTokens: effective.contextWindowTokens,
maximumOutputTokens: effective.maximumOutputTokens,
imageGenerationQuality: effective.imageGenerationQuality,
apiKey: effective.apiKey
}
@@ -1500,6 +1579,7 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: credential.activeApiKey
}
@@ -1582,6 +1662,7 @@ export class RuntimeSettingsStore {
authentication: resolved.authentication,
supportsImageInput: resolved.supportsImageInput,
contextWindowTokens: resolved.contextWindowTokens,
maximumOutputTokens: resolved.maximumOutputTokens,
imageGenerationQuality:
resolved.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
@@ -1603,6 +1684,7 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality:
profile.imageGenerationQuality ??
defaultRuntimeSettings.imageGenerationQuality,
@@ -1897,6 +1979,7 @@ export class RuntimeSettingsStore {
authentication: input.modelAuthentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: input.imageGenerationQuality,
apiKey: input.apiKey
}
@@ -1909,6 +1992,7 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: { action: 'keep' as const }
}
@@ -1960,6 +2044,7 @@ export class RuntimeSettingsStore {
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput ?? false,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality
}
if (
@@ -2228,7 +2313,7 @@ export class RuntimeSettingsStore {
const next: StoredSettings = {
...current,
version: 19,
version: 20,
provider: input.provider,
modelProfiles,
defaultModelProfileId,
@@ -2289,7 +2374,7 @@ export class RuntimeSettingsStore {
const current = await this.load()
const next: StoredSettings = {
...current,
version: 19,
version: 20,
runtimeCustomization: parsed
}
await writeJsonFileAtomically(this.filePath, next)
+79 -4
View File
@@ -18,6 +18,10 @@ import type {
} from '../../shared/contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import {
loadBrandingPreferences,
saveBrandingPreferences
} from './branding'
import type {
AssistantProject,
AssistantSchedule,
@@ -1163,7 +1167,7 @@ describe('App', () => {
screen.getByLabelText('Message GoodBuddy')
).toHaveAttribute(
'placeholder',
'Message GoodBuddy…'
'Message GoodBuddy…\nEnter to send, Shift+Enter for a new line, Ctrl+V to paste an image or text'
)
expect(
screen.getByLabelText('Message GoodBuddy')
@@ -1336,13 +1340,40 @@ describe('App', () => {
/你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件/u
)
).toBeInTheDocument()
expect(screen.queryByText('桌面工作区')).not.toBeInTheDocument()
expect(screen.getByText('智能工作台')).toBeInTheDocument()
expect(screen.queryByText('GOODBUDDY 工作台')).not.toBeInTheDocument()
expect(
document.querySelector('.composer__runtime-toolbar')
).not.toBeInTheDocument()
})
it('loads customized branding into the primary sidebar', async () => {
expect(
saveBrandingPreferences({
name: '研发伙伴',
subtitleZhCN: '团队工作台',
subtitleEnUS: 'Team Workspace',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
).toBe(true)
render(<App />)
await screen.findByRole('heading', {
name: '今天想一起完成什么?'
})
const brand = document.querySelector('.brand')
expect(brand).not.toBeNull()
expect(within(brand as HTMLElement).getByText('研发伙伴'))
.toBeInTheDocument()
expect(within(brand as HTMLElement).getByText('团队工作台'))
.toBeInTheDocument()
expect(brand?.querySelector('img')).toHaveAttribute(
'src',
'data:image/png;base64,iVBORw0KGgo='
)
})
it('renders the unified user and Scheduled Task queue above the Composer', async () => {
const conversationId =
'00000000-0000-4000-8000-000000000951'
@@ -1751,6 +1782,41 @@ describe('App', () => {
})
})
it('saves branding from Appearance and updates the sidebar immediately', async () => {
render(<App />)
fireEvent.click(
await screen.findByRole('button', {
name: /本地工作区/u
})
)
fireEvent.click(
await screen.findByRole('tab', { name: '外观' })
)
fireEvent.change(screen.getByLabelText('品牌名称'), {
target: { value: '研发伙伴' }
})
fireEvent.change(screen.getByLabelText('中文副标题'), {
target: { value: '团队工作台' }
})
fireEvent.click(
screen.getByRole('button', { name: '保存品牌设置' })
)
expect(await screen.findByText('品牌设置已保存'))
.toBeInTheDocument()
const brand = document.querySelector('.brand')
expect(brand).not.toBeNull()
expect(within(brand as HTMLElement).getByText('研发伙伴'))
.toBeInTheDocument()
expect(within(brand as HTMLElement).getByText('团队工作台'))
.toBeInTheDocument()
expect(loadBrandingPreferences()).toMatchObject({
name: '研发伙伴',
subtitleZhCN: '团队工作台'
})
})
it('preloads a heavy route once across repeated pointer and focus intent', async () => {
render(<App />)
const activity = await screen.findByRole('button', {
@@ -5581,7 +5647,7 @@ describe('App', () => {
).toHaveTextContent(/^Ask$/u)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'给 GoodBuddy 发消息…'
'给 GoodBuddy 发消息…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本'
)
expect(
within(conversationSettings).getByRole('button', {
@@ -6148,12 +6214,19 @@ describe('App', () => {
})),
getAgentPackageInventory: vi.fn(async () => ({
checkedAt: '2026-08-24T00:00:00.000Z',
catalog: {
state: 'available' as const,
checkedAt: '2026-08-24T00:00:00.000Z',
error: null
},
entries: [
{
platform: 'linux' as const,
architecture: 'x64' as const,
state: 'verified' as const,
version: '0.11.2',
latestVersion: '0.11.4',
updateAvailable: true,
agentProtocol: { major: 2, minor: 0 },
remoteRuntimeVersion: '1.18.9'
},
@@ -6162,6 +6235,8 @@ describe('App', () => {
architecture: 'arm64' as const,
state: 'not-downloaded' as const,
version: null,
latestVersion: '0.11.4',
updateAvailable: false,
agentProtocol: null,
remoteRuntimeVersion: null
}
@@ -8701,7 +8776,7 @@ describe('App', () => {
expect((await screen.findAllByText('生图')).length).toBeGreaterThan(0)
expect(screen.getByLabelText('向 GoodBuddy 提问')).toHaveAttribute(
'placeholder',
'描述你想生成的图片…'
'描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴图片或文本'
)
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenCalled()
+45 -18
View File
@@ -180,6 +180,11 @@ import type { SettingsLeaveRequester } from './SettingsPanel'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
import {
loadBrandingPreferences,
saveBrandingPreferences
} from './branding'
import { BrandLockup } from './BrandLockup'
import {
applyAppearanceTheme,
loadAppearanceTheme,
@@ -2084,6 +2089,9 @@ function App(): React.JSX.Element {
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
const [appearanceTheme, setAppearanceTheme] =
useState<AppearanceTheme>(loadAppearanceTheme)
const [brandingPreferences, setBrandingPreferences] = useState(
loadBrandingPreferences
)
const [systemPrefersDark, setSystemPrefersDark] = useState(
() =>
typeof window.matchMedia === 'function' &&
@@ -2093,6 +2101,10 @@ function App(): React.JSX.Element {
appearanceTheme,
systemPrefersDark
)
const brandingSubtitle =
locale === 'en-US'
? brandingPreferences.subtitleEnUS
: brandingPreferences.subtitleZhCN
const toggleAppearanceTheme = useCallback((): void => {
setAppearanceTheme(
resolvedAppearanceTheme === 'dark' ? 'light' : 'dark'
@@ -7902,22 +7914,19 @@ function App(): React.JSX.Element {
ref={sidebarRef}
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
>
<div className="brand">
<div className="brand__mark">
<img
alt=""
aria-hidden="true"
src={
resolvedAppearanceTheme === 'dark'
? goodbuddyDarkIcon
: goodbuddyLightIcon
}
/>
</div>
<div className="brand__copy">
<strong>GoodBuddy</strong>
</div>
</div>
<BrandLockup
className="brand"
copyClassName="brand__copy"
logo={
brandingPreferences.logoDataUrl ??
(resolvedAppearanceTheme === 'dark'
? goodbuddyDarkIcon
: goodbuddyLightIcon)
}
markClassName="brand__mark"
name={brandingPreferences.name}
subtitle={brandingSubtitle || undefined}
/>
<ProjectSwitcher
activeProjectId={activeProjectId}
@@ -8908,11 +8917,11 @@ function App(): React.JSX.Element {
<div className="composer__input">
<textarea
aria-label={t('composer.inputLabel')}
placeholder={
placeholder={`${
runtime?.capability === 'image-generation'
? t('composer.imagePlaceholder')
: t('composer.placeholder')
}
}\n${t('composer.keyboardHint')}`}
ref={inputRef}
rows={3}
title={t('composer.keyboardHint')}
@@ -10125,11 +10134,29 @@ function App(): React.JSX.Element {
>
<SettingsPanel
appearanceTheme={appearanceTheme}
brandingFallbackLogo={
resolvedAppearanceTheme === 'dark'
? goodbuddyDarkIcon
: goodbuddyLightIcon
}
brandingPreferences={brandingPreferences}
initialCategory={settingsInitialCategory}
initialChannel={settingsInitialChannel}
magicNotesEnabled={magicNotesEnabled}
remoteProjectsEnabled={remoteProjectsEnabled}
onAppearanceThemeChange={setAppearanceTheme}
onBrandingPreferencesChange={(preferences) => {
if (!saveBrandingPreferences(preferences)) {
return false
}
setBrandingPreferences(preferences)
notify({
tone: 'success',
message: t('notices.brandingSaved'),
dedupeKey: 'branding-saved'
})
return true
}}
onClearLocalData={clearLocalData}
onClose={() => {
setSettingsInitialCategory(undefined)
+33
View File
@@ -0,0 +1,33 @@
type BrandLockupProps = {
ariaLabel?: string
className: string
copyClassName: string
logo?: string
markClassName: string
name: string
role?: 'img'
subtitle?: string
}
export function BrandLockup({
ariaLabel,
className,
copyClassName,
logo,
markClassName,
name,
role,
subtitle
}: BrandLockupProps): React.JSX.Element {
return (
<div aria-label={ariaLabel} className={className} role={role}>
<div className={markClassName}>
{logo && <img alt="" aria-hidden="true" src={logo} />}
</div>
<div className={copyClassName}>
<strong>{name}</strong>
{subtitle && <span>{subtitle}</span>}
</div>
</div>
)
}
@@ -0,0 +1,268 @@
import { ImagePlus, RotateCcw, Save, Trash2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
brandingLogoMaximumBytes,
brandingNameMaximumLength,
brandingPreferencesEqual,
brandingSubtitleMaximumLength,
defaultBrandingPreferences,
isValidBrandingLogoDataUrl,
normalizeBrandingPreferences,
type BrandingPreferences
} from './branding'
import { BrandLockup } from './BrandLockup'
import {
loadImageDimensions,
readFileAsDataUrl
} from './file-data-url'
type BrandingSettingsSectionProps = {
preferences: BrandingPreferences
fallbackLogo?: string
onChange: (preferences: BrandingPreferences) => boolean
onDirtyChange?: (dirty: boolean) => void
}
export function BrandingSettingsSection({
preferences,
fallbackLogo,
onChange,
onDirtyChange
}: BrandingSettingsSectionProps): React.JSX.Element {
const { i18n, t } = useTranslation('settings')
const [draft, setDraft] = useState(preferences)
const [error, setError] = useState<string>()
const fileInputRef = useRef<HTMLInputElement>(null)
const logoReadRef = useRef<AbortController>(null)
const dirty = !brandingPreferencesEqual(draft, preferences)
useEffect(() => {
onDirtyChange?.(dirty)
}, [dirty, onDirtyChange])
useEffect(
() => () => {
logoReadRef.current?.abort()
},
[]
)
const save = (): void => {
const normalized = normalizeBrandingPreferences(draft)
if (!onChange(normalized)) {
setError(t('appearance.branding.errors.saveFailed'))
return
}
setDraft(normalized)
setError(undefined)
}
const selectLogo = async (file: File | undefined): Promise<void> => {
logoReadRef.current?.abort()
if (!file) {
return
}
if (
!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)
) {
setError(t('appearance.branding.errors.unsupportedLogo'))
return
}
if (file.size === 0 || file.size > brandingLogoMaximumBytes) {
setError(t('appearance.branding.errors.logoTooLarge'))
return
}
const controller = new AbortController()
logoReadRef.current = controller
try {
const dataUrl = await readFileAsDataUrl(
file,
file.type,
controller.signal
)
if (!isValidBrandingLogoDataUrl(dataUrl)) {
setError(t('appearance.branding.errors.invalidLogo'))
return
}
const { width, height } = await loadImageDimensions(
dataUrl,
controller.signal
)
if (
width <= 0 ||
height <= 0 ||
width > 4_096 ||
height > 4_096 ||
width * height > 16_000_000
) {
setError(t('appearance.branding.errors.invalidLogo'))
return
}
if (logoReadRef.current !== controller) {
return
}
setDraft((current) => ({
...current,
logoDataUrl: dataUrl
}))
setError(undefined)
} catch {
if (!controller.signal.aborted) {
setError(t('appearance.branding.errors.readLogoFailed'))
}
} finally {
if (logoReadRef.current === controller) {
logoReadRef.current = null
}
}
}
const previewSubtitle =
(i18n.resolvedLanguage === 'en-US'
? draft.subtitleEnUS
: draft.subtitleZhCN) || undefined
const previewLogo = draft.logoDataUrl ?? fallbackLogo
return (
<div className="settings-section appearance-settings branding-settings">
<div className="settings-section__title">
<ImagePlus aria-hidden="true" size={17} />
<div>
<strong>{t('appearance.branding.title')}</strong>
<small>{t('appearance.branding.description')}</small>
</div>
</div>
<BrandLockup
ariaLabel={t('appearance.branding.previewLabel')}
className="branding-settings__preview"
copyClassName="branding-settings__preview-copy"
logo={previewLogo}
markClassName="branding-settings__preview-mark"
name={draft.name || defaultBrandingPreferences.name}
role="img"
subtitle={previewSubtitle}
/>
<div className="branding-settings__fields">
<label className="field">
<span>{t('appearance.branding.fields.name')}</span>
<input
maxLength={brandingNameMaximumLength}
onChange={(event) =>
setDraft((current) => ({
...current,
name: event.target.value
}))
}
value={draft.name}
/>
</label>
<label className="field">
<span>{t('appearance.branding.fields.subtitleZhCN')}</span>
<input
lang="zh-CN"
maxLength={brandingSubtitleMaximumLength}
onChange={(event) =>
setDraft((current) => ({
...current,
subtitleZhCN: event.target.value
}))
}
value={draft.subtitleZhCN}
/>
</label>
<label className="field">
<span>{t('appearance.branding.fields.subtitleEnUS')}</span>
<input
lang="en-US"
maxLength={brandingSubtitleMaximumLength}
onChange={(event) =>
setDraft((current) => ({
...current,
subtitleEnUS: event.target.value
}))
}
value={draft.subtitleEnUS}
/>
</label>
</div>
<div className="branding-settings__logo">
<input
accept="image/png,image/jpeg,image/webp"
aria-label={t('appearance.branding.logo.select')}
hidden
onChange={(event) => {
void selectLogo(event.target.files?.[0])
event.target.value = ''
}}
ref={fileInputRef}
type="file"
/>
<button
className="secondary-button"
onClick={() => fileInputRef.current?.click()}
type="button"
>
<ImagePlus aria-hidden="true" size={13} />
{t('appearance.branding.logo.select')}
</button>
{draft.logoDataUrl && (
<button
className="secondary-button"
onClick={() => {
logoReadRef.current?.abort()
setDraft((current) => ({
name: current.name,
subtitleZhCN: current.subtitleZhCN,
subtitleEnUS: current.subtitleEnUS
}))
setError(undefined)
}}
type="button"
>
<Trash2 aria-hidden="true" size={13} />
{t('appearance.branding.logo.useDefault')}
</button>
)}
<small>{t('appearance.branding.logo.help')}</small>
</div>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
<div className="update-settings__actions">
<button
className="secondary-button"
disabled={brandingPreferencesEqual(
draft,
defaultBrandingPreferences
)}
onClick={() => {
logoReadRef.current?.abort()
setDraft({ ...defaultBrandingPreferences })
setError(undefined)
}}
type="button"
>
<RotateCcw aria-hidden="true" size={13} />
{t('appearance.branding.actions.restore')}
</button>
<button
className="primary-button"
disabled={!dirty}
onClick={save}
type="button"
>
<Save aria-hidden="true" size={13} />
{t('appearance.branding.actions.save')}
</button>
</div>
</div>
)
}
+2 -16
View File
@@ -24,6 +24,7 @@ import {
type FeedbackSubmissionErrorCode
} from '../../shared/feedback-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { loadImageDimensions } from './file-data-url'
type FeedbackDialogProps = {
appInfo: AppInfo
@@ -49,21 +50,6 @@ function formatScreenshotBytes(bytes: number): string {
: `${Math.max(1, Math.ceil(bytes / 1_024))} KB`
}
function imageDimensions(
objectUrl: string
): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () =>
resolve({
width: image.naturalWidth,
height: image.naturalHeight
})
image.onerror = () => reject(new Error('Image could not be decoded'))
image.src = objectUrl
})
}
export function FeedbackDialog({
appInfo,
onClose
@@ -174,7 +160,7 @@ export function FeedbackDialog({
}
objectUrl = URL.createObjectURL(file)
pendingScreenshotUrlsRef.current.add(objectUrl)
const { width, height } = await imageDimensions(objectUrl)
const { width, height } = await loadImageDimensions(objectUrl)
if (selection !== screenshotSelectionRef.current) {
pendingScreenshotUrlsRef.current.delete(objectUrl)
URL.revokeObjectURL(objectUrl)
+4 -40
View File
@@ -22,6 +22,7 @@ import {
magicNoteDataBytes,
type MagicNoteRichContent
} from '../../shared/magic-notes-contracts'
import { readFileAsDataUrl } from './file-data-url'
const supportedImageTypes = new Set([
'image/jpeg',
@@ -41,32 +42,6 @@ export type MagicNoteEditorProps = {
onParagraphCommit?: (content: MagicNoteRichContent) => void
}
function readFileAsDataUrl(
file: File,
mimeType: string,
readFailedMessage: string
): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error(readFailedMessage))
return
}
const separatorIndex = reader.result.indexOf(',')
if (separatorIndex < 0) {
reject(new Error(readFailedMessage))
return
}
resolve(
`data:${mimeType};base64,${reader.result.slice(separatorIndex + 1)}`
)
}
reader.onerror = () => reject(new Error(readFailedMessage))
reader.readAsDataURL(file)
})
}
function safeEmbeddedFileName(file: File): string {
const fallback = file.type.startsWith('video/')
? 'video'
@@ -273,9 +248,6 @@ export function MagicNoteEditor({
return
}
try {
const readFailedMessage = translateRef.current(
'editor.fileReadFailed'
)
const embeds = await Promise.all(
classified.map(async ({ file, kind, mimeType }) => ({
kind,
@@ -283,11 +255,7 @@ export function MagicNoteEditor({
name: safeEmbeddedFileName(file),
mimeType,
size: file.size,
dataUrl: await readFileAsDataUrl(
file,
mimeType,
readFailedMessage
)
dataUrl: await readFileAsDataUrl(file, mimeType)
}
}))
)
@@ -302,12 +270,8 @@ export function MagicNoteEditor({
index += 2
}
quill.setSelection(index, 0, 'silent')
} catch (error) {
onErrorRef.current(
error instanceof Error
? error.message
: translateRef.current('editor.fileReadFailed')
)
} catch {
onErrorRef.current(translateRef.current('editor.fileReadFailed'))
}
}
@@ -930,10 +930,26 @@ export function PlatformFeaturesSettingsSection({
Linux {entry.architecture}
</strong>
<span
className={`bundled-agent-inventory__badge bundled-agent-inventory__badge--${entry.state}`}
className={`bundled-agent-inventory__badge bundled-agent-inventory__badge--${
entry.updateAvailable
? 'update-available'
: agentInventory.catalog.state ===
'available' &&
entry.state === 'verified' &&
entry.latestVersion !== null
? 'up-to-date'
: entry.state
}`}
>
{t(
`platformFeatures.remoteProjects.agentInventory.states.${entry.state}`
entry.updateAvailable
? 'platformFeatures.remoteProjects.agentInventory.states.updateAvailable'
: agentInventory.catalog.state ===
'available' &&
entry.state === 'verified' &&
entry.latestVersion !== null
? 'platformFeatures.remoteProjects.agentInventory.states.upToDate'
: `platformFeatures.remoteProjects.agentInventory.states.${entry.state}`
)}
</span>
</div>
@@ -946,6 +962,14 @@ export function PlatformFeaturesSettingsSection({
</dt>
<dd>{entry.version ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.latestVersion'
)}
</dt>
<dd>{entry.latestVersion ?? '—'}</dd>
</div>
<div>
<dt>
{t(
@@ -985,23 +1009,36 @@ export function PlatformFeaturesSettingsSection({
</p>
)}
<div className="capability-card__actions">
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() =>
void downloadAgentPackage(
entry.architecture
)
}
type="button"
>
<Download aria-hidden="true" size={13} />
{t(
entry.state === 'verified'
? 'platformFeatures.remoteProjects.agentInventory.update'
: 'platformFeatures.remoteProjects.agentInventory.download'
)}
</button>
{(
entry.updateAvailable ||
(
entry.state !== 'verified' &&
entry.latestVersion !== null
) ||
agentInventory.catalog.state !== 'available'
) && (
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() =>
void downloadAgentPackage(
entry.architecture
)
}
type="button"
>
<Download aria-hidden="true" size={13} />
{t(
entry.updateAvailable
? 'platformFeatures.remoteProjects.agentInventory.updateTo'
: entry.state !== 'verified' &&
entry.latestVersion !== null
? 'platformFeatures.remoteProjects.agentInventory.downloadVersion'
: 'platformFeatures.remoteProjects.agentInventory.update',
{ version: entry.latestVersion }
)}
</button>
)}
{entry.state === 'verified' && (
<button
className="secondary-button"
@@ -1024,6 +1061,21 @@ export function PlatformFeaturesSettingsSection({
))}
</div>
) : null}
{agentInventory?.catalog.state === 'available' && (
<p className="settings-notice" role="status">
{t(
'platformFeatures.remoteProjects.agentInventory.catalog.available'
)}
</p>
)}
{agentInventory?.catalog.state === 'unavailable' && (
<p className="settings-warning" role="alert">
{t(
'platformFeatures.remoteProjects.agentInventory.catalog.unavailable',
{ error: agentInventory.catalog.error }
)}
</p>
)}
</article>
</section>
+206 -11
View File
@@ -48,8 +48,21 @@ import {
} from './RuntimeCustomizationSection'
import { changeUiLocale } from './i18n'
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
import type { BrandingPreferences } from './branding'
const modelProfileId = '00000000-0000-4000-8000-000000000001'
const nativeImage = globalThis.Image
class DecodableBrandImage {
naturalWidth = 64
naturalHeight = 64
onload: (() => void) | null = null
onerror: (() => void) | null = null
set src(_value: string) {
queueMicrotask(() => this.onload?.())
}
}
const browserProfileId = '00000000-0000-4000-8000-000000000201'
const runtimeSettings: RuntimeSettings = {
provider: 'auto',
@@ -171,7 +184,7 @@ const detectAgentRuntimes = vi.fn<
deepseekHarness: {
available: true,
path: 'bundled://deepseek-harness',
version: '0.1.0-rc.6',
version: '0.1.0-rc.8',
source: 'bundled',
detail: '内置 Harness Adapter 已就绪'
}
@@ -514,12 +527,19 @@ let applicationSettings: ApplicationSettings = {
}
const agentPackageInventory: AgentPackageInventory = {
checkedAt: '2026-08-24T00:00:00.000Z',
catalog: {
state: 'available',
checkedAt: '2026-08-24T00:00:00.000Z',
error: null
},
entries: [
{
platform: 'linux',
architecture: 'x64',
state: 'verified',
version: '0.11.2',
latestVersion: '0.11.4',
updateAvailable: true,
agentProtocol: { major: 2, minor: 0 },
remoteRuntimeVersion: '1.18.9'
},
@@ -528,6 +548,8 @@ const agentPackageInventory: AgentPackageInventory = {
architecture: 'arm64',
state: 'not-downloaded',
version: null,
latestVersion: '0.11.4',
updateAvailable: false,
agentProtocol: null,
remoteRuntimeVersion: null
}
@@ -925,6 +947,10 @@ describe('SettingsPanel runtime files', () => {
afterEach(() => {
cleanup()
Object.defineProperty(globalThis, 'Image', {
configurable: true,
value: nativeImage
})
})
it('offers system, light, and dark appearance modes', async () => {
@@ -954,6 +980,99 @@ describe('SettingsPanel runtime files', () => {
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
})
it('customizes and restores the sidebar brand from Appearance', async () => {
const onBrandingPreferencesChange = vi.fn<
(preferences: BrandingPreferences) => boolean
>(() => true)
function BrandingHarness(): React.JSX.Element {
const [preferences, setPreferences] = useState({
name: 'GoodBuddy',
subtitleZhCN: '智能工作台',
subtitleEnUS: 'Intelligent Workspace'
})
return (
<SettingsPanel
{...heartbeatSettingsProps}
brandingFallbackLogo="default-logo.png"
brandingPreferences={preferences}
onBrandingPreferencesChange={(next) => {
onBrandingPreferencesChange(next)
setPreferences(next)
return true
}}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
}
render(<BrandingHarness />)
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(screen.getByRole('img', { name: '品牌效果预览' }))
.toHaveTextContent('GoodBuddy')
expect(screen.getByLabelText('品牌名称')).toHaveValue('GoodBuddy')
fireEvent.change(screen.getByLabelText('品牌名称'), {
target: { value: '研发伙伴' }
})
fireEvent.change(screen.getByLabelText('中文副标题'), {
target: { value: '团队工作台' }
})
fireEvent.change(screen.getByLabelText('英文副标题'), {
target: { value: 'Team Workspace' }
})
expect(screen.getByRole('img', { name: '品牌效果预览' }))
.toHaveTextContent('研发伙伴')
Object.defineProperty(globalThis, 'Image', {
configurable: true,
value: DecodableBrandImage
})
fireEvent.change(screen.getByLabelText('选择 Logo'), {
target: {
files: [
new File(
[
Uint8Array.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a
])
],
'brand.png',
{ type: 'image/png' }
)
]
}
})
await screen.findByRole('button', { name: '使用默认 Logo' })
fireEvent.click(
screen.getByRole('button', { name: '保存品牌设置' })
)
expect(onBrandingPreferencesChange).toHaveBeenCalledWith({
name: '研发伙伴',
subtitleZhCN: '团队工作台',
subtitleEnUS: 'Team Workspace',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
fireEvent.click(screen.getByRole('button', { name: '恢复默认' }))
expect(screen.getByLabelText('品牌名称')).toHaveValue('GoodBuddy')
expect(screen.getByLabelText('中文副标题')).toHaveValue(
'智能工作台'
)
})
it('configures direct model context compression with explicit token budgets', async () => {
render(
<SettingsPanel
@@ -1103,6 +1222,10 @@ describe('SettingsPanel runtime files', () => {
expect(contextWindow).toHaveValue(null)
expect(contextWindow).toHaveAttribute('min', '32')
fireEvent.change(contextWindow, { target: { value: '256' } })
const maximumOutput = screen.getByLabelText('Anthropic 最大输出')
expect(maximumOutput).toHaveValue(32)
expect(maximumOutput).toHaveAttribute('min', '1')
fireEvent.change(maximumOutput, { target: { value: '48' } })
fireEvent.click(screen.getByRole('button', { name: '保存设置' }))
await waitFor(() =>
@@ -1111,7 +1234,8 @@ describe('SettingsPanel runtime files', () => {
modelProfiles: expect.arrayContaining([
expect.objectContaining({
id: modelProfileId,
contextWindowTokens: 256_000
contextWindowTokens: 256_000,
maximumOutputTokens: 48_000
})
])
})
@@ -1878,13 +2002,13 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
expect(screen.getByText('Linux x64')).toBeInTheDocument()
expect(screen.getByText('Linux arm64')).toBeInTheDocument()
expect(screen.getByText('已下载并验证')).toBeInTheDocument()
expect(screen.getByText('有更新')).toBeInTheDocument()
expect(screen.getByText('未下载')).toBeInTheDocument()
expect(getAgentPackageInventory).toHaveBeenCalledOnce()
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, false)
fireEvent.click(
screen.getByRole('button', { name: '刷新本地清单' })
screen.getByRole('button', { name: '刷新包清单' })
)
await waitFor(() =>
expect(getAgentPackageInventory).toHaveBeenCalledTimes(2)
@@ -1899,14 +2023,34 @@ describe('SettingsPanel runtime files', () => {
.closest('[role="listitem"]')
expect(x64Card).not.toBeNull()
expect(arm64Card).not.toBeNull()
downloadAgentPackage.mockResolvedValueOnce({
...agentPackageInventory,
entries: agentPackageInventory.entries.map((entry) =>
entry.architecture === 'x64'
? {
...entry,
version: '0.11.4',
updateAvailable: false
}
: entry
)
})
fireEvent.click(
within(x64Card as HTMLElement).getByRole('button', {
name: '检查并更新'
name: '更新到 0.11.4'
})
)
await waitFor(() =>
expect(downloadAgentPackage).toHaveBeenCalledWith('x64')
)
expect(
await within(x64Card as HTMLElement).findByText('已是最新')
).toBeInTheDocument()
expect(
within(x64Card as HTMLElement).queryByRole('button', {
name: /更新到/u
})
).not.toBeInTheDocument()
fireEvent.click(
within(x64Card as HTMLElement).getByRole('button', {
name: '导出离线包'
@@ -1917,7 +2061,7 @@ describe('SettingsPanel runtime files', () => {
)
fireEvent.click(
within(arm64Card as HTMLElement).getByRole('button', {
name: '下载'
name: '下载 0.11.4'
})
)
await waitFor(() =>
@@ -1967,7 +2111,9 @@ describe('SettingsPanel runtime files', () => {
})
)
expect(
await screen.findByText('正在校验本地 Agent 包…')
await screen.findByText(
'正在校验本地 Agent 包并检查在线版本…'
)
).toBeInTheDocument()
await act(async () => {
@@ -1982,14 +2128,14 @@ describe('SettingsPanel runtime files', () => {
new Error('inventory unavailable')
)
fireEvent.click(
screen.getByRole('button', { name: '刷新本地清单' })
screen.getByRole('button', { name: '刷新包清单' })
)
expect(
await screen.findByText('inventory unavailable')
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '刷新本地清单' })
screen.getByRole('button', { name: '刷新包清单' })
)
await waitFor(() =>
expect(
@@ -1999,6 +2145,55 @@ describe('SettingsPanel runtime files', () => {
expect(getAgentPackageInventory).toHaveBeenCalledTimes(3)
})
it('keeps local Agent packages usable when the online catalog is unavailable', async () => {
getAgentPackageInventory.mockResolvedValueOnce({
...agentPackageInventory,
catalog: {
state: 'unavailable',
checkedAt: '2026-08-24T00:00:00.000Z',
error: 'network unavailable'
},
entries: agentPackageInventory.entries.map((entry) => ({
...entry,
latestVersion: null,
updateAvailable: false
}))
})
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
await screen.findByRole('tab', {
name: '远程项目(技术预览)'
})
)
expect(
await screen.findByText(/network unavailable/u)
).toBeInTheDocument()
const x64Card = screen
.getByText('Linux x64')
.closest('[role="listitem"]')
expect(
within(x64Card as HTMLElement).getByRole('button', {
name: '检查并更新'
})
).toBeInTheDocument()
expect(
within(x64Card as HTMLElement).getByRole('button', {
name: '导出离线包'
})
).toBeInTheDocument()
})
it('shows the SSH Hosts category only while Remote Projects is enabled', () => {
const commonProps = {
...heartbeatSettingsProps,
@@ -2665,11 +2860,11 @@ describe('SettingsPanel runtime files', () => {
)
).toHaveClass('runtime-overview__path')
expect(
within(harnessOverview).getByText('0.1.0-rc.6')
within(harnessOverview).getByText('0.1.0-rc.8')
).toBeInTheDocument()
expect(
within(harnessOverview).getByText(
'内置 DeepSeek Harness 0.1.0-rc.6 已就绪'
'内置 DeepSeek Harness 0.1.0-rc.8 已就绪'
)
).toBeInTheDocument()
expect(
+59
View File
@@ -34,6 +34,7 @@ import type {
} from '../../shared/contracts'
import {
defaultContextCompressionSettings,
defaultAnthropicMaximumOutputTokens,
builtinEmbeddingConnectionId,
defaultModelProfileId as builtInDefaultModelProfileId,
defaultRuntimeSettings,
@@ -75,6 +76,11 @@ import { EmbeddingSettingsSection } from './EmbeddingSettingsSection'
import { useUiLocale } from './i18n/UiLocaleProvider'
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
import { findPreferredCompatibleModelProfile } from './model-profile-selection'
import { BrandingSettingsSection } from './BrandingSettingsSection'
import {
defaultBrandingPreferences,
type BrandingPreferences
} from './branding'
type ModelType = 'llm' | 'embedding' | 'rerank' | 'speech'
type AgentRuntimeType =
@@ -217,6 +223,11 @@ type SettingsPanelProps = {
onClearLocalData: () => Promise<void>
appearanceTheme?: AppearanceTheme
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
brandingPreferences?: BrandingPreferences
brandingFallbackLogo?: string
onBrandingPreferencesChange?: (
preferences: BrandingPreferences
) => boolean
magicNotesEnabled?: boolean
remoteProjectsEnabled?: boolean
onMagicNotesEnabledChange?: (enabled: boolean) => void
@@ -257,6 +268,9 @@ function toModelProfileDrafts(
return settings.modelProfiles.map((profile) => ({
...profile,
supportsImageInput: profile.supportsImageInput ?? false,
maximumOutputTokens:
profile.maximumOutputTokens ??
defaultAnthropicMaximumOutputTokens,
apiKey: '',
clearApiKey: false
}))
@@ -580,6 +594,9 @@ export function SettingsPanel({
onExpertsChanged = () => {},
appearanceTheme = 'system',
onAppearanceThemeChange = () => {},
brandingPreferences = defaultBrandingPreferences,
brandingFallbackLogo,
onBrandingPreferencesChange = () => false,
magicNotesEnabled = false,
remoteProjectsEnabled = false,
onMagicNotesEnabledChange = () => {},
@@ -594,6 +611,7 @@ export function SettingsPanel({
setPreference: setUiLocalePreference
} = useUiLocale()
const [settings, setSettings] = useState<RuntimeSettings>()
const [brandingDirty, setBrandingDirty] = useState(false)
const [provider, setProvider] =
useState<RuntimeSettingsInput['provider']>(
defaultRuntimeSettings.provider
@@ -923,11 +941,13 @@ export function SettingsPanel({
runtimeDraftDirty ||
speechModelSelectionDirty ||
runtimeCustomizationDirty ||
brandingDirty ||
platformFeaturesDirty ||
documentParsingDirty ||
sshHostsDirty
const navigationWouldLoseDraft =
runtimeCustomizationDirty ||
brandingDirty ||
platformFeaturesDirty ||
documentParsingDirty ||
sshHostsDirty
@@ -935,6 +955,7 @@ export function SettingsPanel({
runtimeCustomizationDirty &&
!runtimeDraftDirty &&
!speechModelSelectionDirty &&
!brandingDirty &&
!platformFeaturesDirty &&
!documentParsingDirty &&
!sshHostsDirty
@@ -1184,6 +1205,7 @@ export function SettingsPanel({
setSpeechModelDraftId(undefined)
setPersistedSpeechModelId(undefined)
setSpeechModelSelectionDirty(false)
setBrandingDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setSshHostsDirty(false)
@@ -1209,6 +1231,7 @@ export function SettingsPanel({
setSpeechModelSelectionDirty(false)
runtimeCustomizationRef.current?.discard()
setRuntimeCustomizationDirty(false)
setBrandingDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setSshHostsDirty(false)
@@ -1285,6 +1308,7 @@ export function SettingsPanel({
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput,
contextWindowTokens: profile.contextWindowTokens,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality: profile.imageGenerationQuality,
apiKey: profile.clearApiKey
? ({ action: 'clear' } as const)
@@ -1670,6 +1694,7 @@ export function SettingsPanel({
protocol: 'openai-chat-completions',
authentication: defaultRuntimeSettings.modelAuthentication,
supportsImageInput: defaultRuntimeSettings.supportsImageInput,
maximumOutputTokens: defaultAnthropicMaximumOutputTokens,
imageGenerationQuality:
defaultRuntimeSettings.imageGenerationQuality,
apiKeyConfigured: false,
@@ -2104,6 +2129,12 @@ export function SettingsPanel({
))}
</div>
</div>
<BrandingSettingsSection
fallbackLogo={brandingFallbackLogo}
onChange={onBrandingPreferencesChange}
onDirtyChange={setBrandingDirty}
preferences={brandingPreferences}
/>
</>
)}
{activeTab === 'platform-features' && (
@@ -3136,6 +3167,34 @@ export function SettingsPanel({
{t('model.profile.contextWindowDescription')}
</small>
</label>
{profile.protocol === 'anthropic-messages' && (
<label className="field">
<span>{t('model.profile.maximumOutput')}</span>
<input
aria-label={t('model.profile.maximumOutput')}
inputMode="numeric"
max={10_000}
min={1}
onChange={(event) => {
const value = event.target.valueAsNumber
updateModelProfile(profile.id, {
maximumOutputTokens: Number.isFinite(value)
? Math.round(value * 1_000)
: defaultAnthropicMaximumOutputTokens
})
}}
placeholder="32"
type="number"
value={
(profile.maximumOutputTokens ??
defaultAnthropicMaximumOutputTokens) / 1_000
}
/>
<small>
{t('model.profile.maximumOutputDescription')}
</small>
</label>
)}
</>
)}
{profile.protocol ===
+97
View File
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
brandingLogoMaximumBytes,
brandingNameMaximumLength,
defaultBrandingPreferences,
loadBrandingPreferences,
normalizeBrandingPreferences,
saveBrandingPreferences
} from './branding'
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
})
describe('branding preferences', () => {
it('uses defaults when no valid preference is stored', () => {
expect(loadBrandingPreferences()).toEqual(defaultBrandingPreferences)
localStorage.setItem('goodbuddy.branding.v1', '{"name":true}')
expect(loadBrandingPreferences()).toEqual(defaultBrandingPreferences)
})
it('normalizes text and accepts only bounded inline image data', () => {
expect(
normalizeBrandingPreferences({
name: ` ${'B'.repeat(brandingNameMaximumLength + 4)} `,
subtitleZhCN: ' 中文品牌 ',
subtitleEnUS: ' English brand ',
logoDataUrl: 'https://example.com/logo.png'
})
).toEqual({
name: 'B'.repeat(brandingNameMaximumLength),
subtitleZhCN: '中文品牌',
subtitleEnUS: 'English brand'
})
expect(
normalizeBrandingPreferences({
name: ' ',
subtitleZhCN: '',
subtitleEnUS: '',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
).toEqual({
name: 'GoodBuddy',
subtitleZhCN: '',
subtitleEnUS: '',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
})
})
it('persists and reloads valid preferences', () => {
const preferences = {
name: '团队助手',
subtitleZhCN: '研发工作台',
subtitleEnUS: 'Engineering Workspace',
logoDataUrl: 'data:image/png;base64,iVBORw0KGgo='
}
expect(saveBrandingPreferences(preferences)).toBe(true)
expect(loadBrandingPreferences()).toEqual(preferences)
})
it('rejects a logo whose bytes do not match its declared type', () => {
expect(
normalizeBrandingPreferences({
...defaultBrandingPreferences,
logoDataUrl: 'data:image/jpeg;base64,iVBORw0KGgo='
})
).toEqual(defaultBrandingPreferences)
})
it('rejects oversized encoded logos before decoding them', () => {
const decode = vi.spyOn(globalThis, 'atob')
expect(
normalizeBrandingPreferences({
...defaultBrandingPreferences,
logoDataUrl: `data:image/png;base64,${'A'.repeat(
Math.ceil((brandingLogoMaximumBytes * 4) / 3) + 8
)}`
})
).toEqual(defaultBrandingPreferences)
expect(decode).not.toHaveBeenCalled()
})
it('reports unavailable local storage without throwing', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('blocked')
})
expect(
saveBrandingPreferences(defaultBrandingPreferences)
).toBe(false)
})
})
+140
View File
@@ -0,0 +1,140 @@
import { detectSupportedImage } from '../../shared/image-media-type'
export type BrandingPreferences = {
name: string
subtitleZhCN: string
subtitleEnUS: string
logoDataUrl?: string
}
export const defaultBrandingPreferences: BrandingPreferences = {
name: 'GoodBuddy',
subtitleZhCN: '智能工作台',
subtitleEnUS: 'Intelligent Workspace'
}
export const brandingNameMaximumLength = 32
export const brandingSubtitleMaximumLength = 48
export const brandingLogoMaximumBytes = 512 * 1024
const storageKey = 'goodbuddy.branding.v1'
const supportedLogoDataUrlPattern =
/^data:(image\/(?:png|jpeg|webp));base64,([a-z0-9+/]+={0,2})$/iu
export function isValidBrandingLogoDataUrl(value: string): boolean {
const match = supportedLogoDataUrlPattern.exec(value)
if (!match) {
return false
}
try {
const encoded = match[2]!
if (
encoded.length >
Math.ceil((brandingLogoMaximumBytes * 4) / 3) + 4
) {
return false
}
const decoded = atob(encoded)
if (
decoded.length === 0 ||
decoded.length > brandingLogoMaximumBytes
) {
return false
}
const header = Uint8Array.from(
decoded
.slice(0, 12)
.split('')
.map((character) => character.charCodeAt(0))
)
return (
detectSupportedImage(header).mimeType === match[1]!.toLowerCase()
)
} catch {
return false
}
}
export function normalizeBrandingPreferences(
value: BrandingPreferences
): BrandingPreferences {
const name = value.name.trim()
const subtitleZhCN = value.subtitleZhCN.trim()
const subtitleEnUS = value.subtitleEnUS.trim()
return {
name:
name.length > 0
? name.slice(0, brandingNameMaximumLength)
: defaultBrandingPreferences.name,
subtitleZhCN: subtitleZhCN.slice(
0,
brandingSubtitleMaximumLength
),
subtitleEnUS: subtitleEnUS.slice(
0,
brandingSubtitleMaximumLength
),
...(value.logoDataUrl &&
isValidBrandingLogoDataUrl(value.logoDataUrl)
? { logoDataUrl: value.logoDataUrl }
: {})
}
}
export function loadBrandingPreferences(): BrandingPreferences {
try {
const value = localStorage.getItem(storageKey)
if (!value) {
return { ...defaultBrandingPreferences }
}
const parsed = JSON.parse(value) as unknown
if (
typeof parsed !== 'object' ||
parsed === null ||
typeof (parsed as Record<string, unknown>).name !== 'string' ||
typeof (parsed as Record<string, unknown>).subtitleZhCN !==
'string' ||
typeof (parsed as Record<string, unknown>).subtitleEnUS !==
'string'
) {
return { ...defaultBrandingPreferences }
}
const candidate = parsed as BrandingPreferences
return normalizeBrandingPreferences({
name: candidate.name,
subtitleZhCN: candidate.subtitleZhCN,
subtitleEnUS: candidate.subtitleEnUS,
...(typeof candidate.logoDataUrl === 'string'
? { logoDataUrl: candidate.logoDataUrl }
: {})
})
} catch {
return { ...defaultBrandingPreferences }
}
}
export function saveBrandingPreferences(
value: BrandingPreferences
): boolean {
try {
localStorage.setItem(
storageKey,
JSON.stringify(normalizeBrandingPreferences(value))
)
return true
} catch {
return false
}
}
export function brandingPreferencesEqual(
left: BrandingPreferences,
right: BrandingPreferences
): boolean {
return (
left.name === right.name &&
left.subtitleZhCN === right.subtitleZhCN &&
left.subtitleEnUS === right.subtitleEnUS &&
left.logoDataUrl === right.logoDataUrl
)
}
+82
View File
@@ -0,0 +1,82 @@
export function readFileAsDataUrl(
file: Blob,
mimeType: string,
signal?: AbortSignal
): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
const cleanup = (): void => {
signal?.removeEventListener('abort', abort)
reader.onload = null
reader.onerror = null
reader.onabort = null
}
const fail = (): void => {
cleanup()
reject(new Error('File could not be read'))
}
const abort = (): void => {
reader.abort()
}
if (signal?.aborted) {
fail()
return
}
signal?.addEventListener('abort', abort, { once: true })
reader.onload = () => {
if (typeof reader.result !== 'string') {
fail()
return
}
const separatorIndex = reader.result.indexOf(',')
if (separatorIndex < 0) {
fail()
return
}
const dataUrl =
`data:${mimeType};base64,${reader.result.slice(separatorIndex + 1)}`
cleanup()
resolve(dataUrl)
}
reader.onerror = fail
reader.onabort = fail
reader.readAsDataURL(file)
})
}
export function loadImageDimensions(
source: string,
signal?: AbortSignal
): Promise<{ width: number; height: number }> {
return new Promise((resolve, reject) => {
const image = new Image()
const cleanup = (): void => {
signal?.removeEventListener('abort', abort)
image.onload = null
image.onerror = null
}
const abort = (): void => {
cleanup()
image.src = ''
reject(new Error('Image loading was cancelled'))
}
if (signal?.aborted) {
abort()
return
}
signal?.addEventListener('abort', abort, { once: true })
image.onload = () => {
cleanup()
resolve({
width: image.naturalWidth,
height: image.naturalHeight
})
}
image.onerror = () => {
cleanup()
reject(new Error('Image could not be decoded'))
}
image.src = source
})
}
@@ -564,6 +564,7 @@ export const app = {
}
},
notices: {
brandingSaved: 'Branding saved',
updateAvailable:
'GoodBuddy {{version}} is available. View it in About & Updates.',
remoteProjectActivated:
@@ -16,8 +16,8 @@ export const settings = {
categories: {
appearance: {
label: 'Appearance',
navigationDescription: 'Theme and interface language',
description: 'Theme and interface language'
navigationDescription: 'Theme, language, and branding',
description: 'Theme, language, and brand customization'
},
platformFeatures: {
label: 'Platform features',
@@ -721,6 +721,9 @@ export const settings = {
contextWindow: 'Context window (optional)',
contextWindowDescription:
'Enter 32K–10000K tokens. Leave blank when unknown. This value is used only for GoodBuddy local budget calculations.',
maximumOutput: 'Anthropic maximum output',
maximumOutputDescription:
'The Anthropic Messages protocol requires this value on every request. Enter 1K–10000K tokens. It limits only one model response, not tool rounds or cumulative usage.',
imageQuality: 'Image quality',
imageQualityAriaLabel: 'Image quality for {{name}}',
quality: {
@@ -876,6 +879,36 @@ export const settings = {
description: 'Use the English interface'
}
}
},
branding: {
title: 'Brand customization',
description:
'Customize the brand name, subtitle, and logo in the top-left corner',
previewLabel: 'Brand preview',
fields: {
name: 'Brand name',
subtitleZhCN: 'Chinese subtitle',
subtitleEnUS: 'English subtitle'
},
logo: {
select: 'Choose logo',
useDefault: 'Use default logo',
help:
'Supports PNG, JPEG, or WebP up to 4096 × 4096 and 512 KB'
},
actions: {
restore: 'Restore defaults',
save: 'Save branding'
},
errors: {
unsupportedLogo: 'Choose a PNG, JPEG, or WebP image',
logoTooLarge: 'The logo file cannot exceed 512 KB',
invalidLogo:
'The logo is invalid, has the wrong format, or is too large',
readLogoFailed: 'The logo could not be read. Choose it again.',
saveFailed:
'Branding could not be saved. Check local storage and try again.'
}
}
}
} satisfies TranslationShape<typeof chineseSettings>
@@ -520,27 +520,38 @@ export const settingsSections = {
agentInventory: {
title: 'GoodBuddy Agent packages',
description:
'Agent packages are released independently and include the Agent, pinned Node, and the remote OpenCode Runtime adapted by GoodBuddy. Online actions use the source selected under About & Updates; nothing is downloaded until remote projects are used.',
loading: 'Verifying local Agent packages…',
refresh: 'Refresh local inventory',
'Agent packages are released independently and include the Agent, pinned Node, and the remote OpenCode Runtime adapted by GoodBuddy. Opening this page reads only the small signed catalog to show available updates and never downloads an Agent package automatically. Online actions use the source selected under About & Updates.',
loading: 'Verifying local Agent packages and checking online versions…',
refresh: 'Refresh package inventory',
import: 'Import offline package',
export: 'Export offline package',
download: 'Download',
update: 'Check for updates',
updateTo: 'Update to {{version}}',
downloadVersion: 'Download {{version}}',
listLabel: 'Local Agent package inventory',
summary:
'{{available}} of {{total}} Linux architectures are available.',
states: {
verified: 'Downloaded and verified',
'not-downloaded': 'Not downloaded',
invalid: 'Verification failed'
invalid: 'Verification failed',
updateAvailable: 'Update available',
upToDate: 'Up to date'
},
fields: {
agentVersion: 'Agent version',
agentVersion: 'Local Agent',
latestVersion: 'Latest online version',
architecture: 'Architecture',
runtimeVersion: 'Remote OpenCode',
protocol: 'Agent protocol'
},
catalog: {
available:
'Online versions were checked through the signed catalog. Agent packages are transferred only after you choose Download or Update.',
unavailable:
'Could not check online Agent versions: {{error}}. Verified local packages and offline imports remain available. Refresh to try again.'
},
progress: {
catalog: 'Reading the signed release catalog…',
downloading: 'Downloading the Agent package…',
@@ -556,7 +567,7 @@ export const settingsSections = {
unavailable:
'Agent package management is not available in this version.',
readFailed:
'Could not load local Agent packages. Try again.',
'Could not load Agent packages. Try again.',
downloadFailed:
'Agent package download or verification failed',
importFailed:
@@ -530,6 +530,7 @@ export const app = {
}
},
notices: {
brandingSaved: '品牌设置已保存',
updateAvailable: '发现 GoodBuddy {{version}},可在“关于与更新”中查看',
remoteProjectActivated: '远程项目 Agent 和 Runtime 已就绪',
remoteProjectActivationFailed:
@@ -12,8 +12,8 @@ export const settings = {
categories: {
appearance: {
label: '外观',
navigationDescription: '主题与界面语言',
description: '主题与界面语言'
navigationDescription: '主题、语言与品牌',
description: '主题、语言与品牌定制'
},
platformFeatures: {
label: '平台功能',
@@ -662,6 +662,9 @@ export const settings = {
contextWindow: '上下文上限(可选)',
contextWindowDescription:
'以 K tokens 填写,范围为 32K–10000K。留空表示未知;此值仅用于 GoodBuddy 本地预算计算。',
maximumOutput: 'Anthropic 最大输出',
maximumOutputDescription:
'Anthropic Messages 协议要求每次请求提供该值。填写 1K–10000K tokens;它只限制单次模型响应,不限制工具调用轮次或累计用量。',
imageQuality: '图片质量',
imageQualityAriaLabel: '图片质量 {{name}}',
quality: {
@@ -806,6 +809,33 @@ export const settings = {
description: 'Use the English interface'
}
}
},
branding: {
title: '品牌定制',
description: '定制左上角品牌名称、副标题与 Logo',
previewLabel: '品牌效果预览',
fields: {
name: '品牌名称',
subtitleZhCN: '中文副标题',
subtitleEnUS: '英文副标题'
},
logo: {
select: '选择 Logo',
useDefault: '使用默认 Logo',
help:
'支持 PNG、JPEG 或 WebP,尺寸不超过 4096 × 4096,文件不能超过 512 KB'
},
actions: {
restore: '恢复默认',
save: '保存品牌设置'
},
errors: {
unsupportedLogo: '请选择 PNG、JPEG 或 WebP 图片',
logoTooLarge: 'Logo 文件不能超过 512 KB',
invalidLogo: 'Logo 图片无效、格式不符或尺寸过大',
readLogoFailed: '读取 Logo 失败,请重新选择',
saveFailed: '保存品牌设置失败,请检查本地存储后重试'
}
}
}
} as const
@@ -491,26 +491,37 @@ export const settingsSections = {
agentInventory: {
title: 'GoodBuddy Agent 包',
description:
'Agent 包独立发布,包含 Agent、固定 Node 和桌面版本适配的远端 OpenCode Runtime。在线操作使用“关于与更新”中选择的更新源;未使用远程项目时不会下载。',
loading: '正在校验本地 Agent 包…',
refresh: '刷新本地清单',
'Agent 包独立发布,包含 Agent、固定 Node 和桌面版本适配的远端 OpenCode Runtime。打开此页只读取小型签名目录并显示可用更新,不会自动下载 Agent 包;在线操作使用“关于与更新”中选择的更新源。',
loading: '正在校验本地 Agent 包并检查在线版本…',
refresh: '刷新包清单',
import: '导入离线包',
export: '导出离线包',
download: '下载',
update: '检查并更新',
updateTo: '更新到 {{version}}',
downloadVersion: '下载 {{version}}',
listLabel: '本地 Agent 包清单',
summary: '当前 {{available}} / {{total}} 个 Linux 架构可用。',
states: {
verified: '已下载并验证',
'not-downloaded': '未下载',
invalid: '校验失败'
invalid: '校验失败',
updateAvailable: '有更新',
upToDate: '已是最新'
},
fields: {
agentVersion: 'Agent 版本',
agentVersion: '本地 Agent',
latestVersion: '在线最新版本',
architecture: '架构',
runtimeVersion: '远端 OpenCode',
protocol: 'Agent 协议'
},
catalog: {
available:
'在线版本已通过签名目录检查;只有点击下载或更新后才会传输 Agent 包。',
unavailable:
'未能检查在线 Agent 版本:{{error}}。本地已验证包和离线导入仍可使用,可刷新重试。'
},
progress: {
catalog: '正在读取签名发布目录…',
downloading: '正在下载 Agent 包…',
@@ -524,7 +535,7 @@ export const settingsSections = {
},
errors: {
unavailable: '当前版本未提供 Agent 包管理服务',
readFailed: '读取本地 Agent 包清单失败,请重试',
readFailed: '读取 Agent 包清单失败,请重试',
downloadFailed: 'Agent 包下载或校验失败',
importFailed: 'Agent 离线包导入或校验失败',
exportFailed: 'Agent 离线包导出失败'
+104 -3
View File
@@ -2359,10 +2359,18 @@ textarea:focus-visible {
.brand__copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.brand__copy strong,
.brand__copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand__copy strong {
font-size: 15px;
font-weight: 600;
@@ -3084,14 +3092,15 @@ textarea:focus-visible {
.nav-item__badge {
display: grid;
min-width: 19px;
height: 19px;
padding: 0 5px;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 999px;
background: var(--danger-solid);
color: var(--text-on-accent);
font-size: 10px;
font-weight: 700;
line-height: 1;
place-items: center;
}
@@ -6630,6 +6639,16 @@ button > svg {
color: var(--success);
}
.bundled-agent-inventory__badge--up-to-date {
background: var(--success-subtle);
color: var(--success);
}
.bundled-agent-inventory__badge--update-available {
background: var(--accent-subtle);
color: var(--accent);
}
.bundled-agent-inventory__badge--not-downloaded,
.bundled-agent-inventory__badge--invalid {
background: var(--warning-subtle);
@@ -13725,6 +13744,84 @@ details.settings-section > :not(summary) + :not(summary) {
gap: 14px;
}
.branding-settings__preview {
display: flex;
align-items: center;
min-height: 76px;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-raised);
gap: var(--space-3);
}
.branding-settings__preview-mark {
display: grid;
width: 44px;
height: 44px;
flex: 0 0 44px;
overflow: hidden;
border: 1px solid var(--accent-selected);
border-radius: 12px;
background: var(--accent-subtle);
place-items: center;
}
.branding-settings__preview-mark img {
width: 100%;
height: 100%;
object-fit: cover;
}
.branding-settings__preview-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--space-1);
}
.branding-settings__preview-copy strong,
.branding-settings__preview-copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.branding-settings__preview-copy strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.branding-settings__preview-copy span {
color: var(--text-muted);
font-size: var(--font-caption);
}
.branding-settings__fields {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.branding-settings__logo {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.branding-settings__logo button {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.branding-settings__logo small {
flex-basis: 100%;
color: var(--text-muted);
font-size: var(--font-caption);
}
.appearance-options {
display: grid;
gap: 10px;
@@ -14484,6 +14581,10 @@ details.settings-section > :not(summary) + :not(summary) {
grid-template-columns: 1fr;
}
.branding-settings__fields {
grid-template-columns: 1fr;
}
.workspace-picker {
flex-wrap: wrap;
}
@@ -133,6 +133,20 @@ describe('Agent installation contracts', () => {
).toThrow()
})
it('rejects Base64 encodings with non-zero padding bits', () => {
expect(() =>
agentReleaseKeyRegistrySchema.parse({
formatVersion: 1,
keys: [{
keyId: 'test-key',
publicKeySpkiBase64: 'ZE==',
environment: 'test'
}],
revocations: []
})
).toThrow('canonical Base64')
})
it('requires both locked Linux targets and the fixed Node patch', () => {
const target = {
archive: 'node.tar.gz',
+18 -1
View File
@@ -6,6 +6,19 @@ const sha256Pattern = /^[a-f0-9]{64}$/u
const keyIdPattern = /^[a-z0-9][a-z0-9._-]{0,63}$/u
const canonicalBase64Pattern =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u
const canonicalDoublePaddingCharacterPattern = /[AQgw]==$/u
const canonicalSinglePaddingCharacterPattern =
/[AEIMQUYcgkosw048]=$/u
function hasCanonicalBase64Padding(value: string): boolean {
if (value.endsWith('==')) {
return canonicalDoublePaddingCharacterPattern.test(value)
}
if (value.endsWith('=')) {
return canonicalSinglePaddingCharacterPattern.test(value)
}
return true
}
export const agentArchitectureSchema = z.enum(['x64', 'arm64'])
@@ -151,7 +164,11 @@ export const agentReleaseKeySchema = z
.string()
.min(4)
.max(4_096)
.regex(canonicalBase64Pattern),
.regex(canonicalBase64Pattern)
.refine(
hasCanonicalBase64Padding,
'Agent public key must use canonical Base64'
),
environment: z.enum(['production', 'test'])
})
.strict()
@@ -67,12 +67,19 @@ describe('Agent package contracts', () => {
platform: 'linux',
state: 'not-downloaded',
version: null,
latestVersion: null,
updateAvailable: false,
remoteRuntimeVersion: null,
agentProtocol: null
} as const
expect(
agentPackageInventorySchema.parse({
checkedAt: '2026-08-25T00:00:00.000Z',
catalog: {
state: 'not-checked',
checkedAt: null,
error: null
},
entries: [
{ ...entry, architecture: 'x64' },
{ ...entry, architecture: 'arm64' }
@@ -82,6 +89,11 @@ describe('Agent package contracts', () => {
expect(() =>
agentPackageInventorySchema.parse({
checkedAt: '2026-08-25T00:00:00.000Z',
catalog: {
state: 'not-checked',
checkedAt: null,
error: null
},
entries: [
{ ...entry, architecture: 'x64' },
{ ...entry, architecture: 'x64' }
+28
View File
@@ -209,14 +209,42 @@ export const agentPackageInventoryEntrySchema = z
architecture: agentArchitectureSchema,
state: agentPackageStateSchema,
version: semanticVersionSchema.nullable(),
latestVersion: semanticVersionSchema.nullable(),
updateAvailable: z.boolean(),
remoteRuntimeVersion: semanticVersionSchema.nullable(),
agentProtocol: agentProtocolVersionSchema.nullable()
})
.strict()
export const agentPackageCatalogStatusSchema = z
.discriminatedUnion('state', [
z
.object({
state: z.literal('not-checked'),
checkedAt: z.null(),
error: z.null()
})
.strict(),
z
.object({
state: z.literal('available'),
checkedAt: z.string().datetime(),
error: z.null()
})
.strict(),
z
.object({
state: z.literal('unavailable'),
checkedAt: z.string().datetime(),
error: z.string().trim().min(1).max(2_000)
})
.strict()
])
export const agentPackageInventorySchema = z
.object({
checkedAt: z.string().datetime(),
catalog: agentPackageCatalogStatusSchema,
entries: z
.array(agentPackageInventoryEntrySchema)
.length(agentArchitectureSchema.options.length)
+14 -4
View File
@@ -477,10 +477,10 @@ export const defaultContextCompressionSettings = {
export const defaultRuntimeSettings = {
provider: 'model',
modelBaseUrl: 'https://bigtoken.ai',
modelName: 'sonnet-5',
modelProtocol: 'anthropic-messages',
modelAuthentication: 'api-key',
modelBaseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
modelProtocol: 'openai-chat-completions',
modelAuthentication: 'none',
supportsImageInput: false,
imageGenerationQuality: 'auto',
opencodeBaseUrl: '',
@@ -568,6 +568,9 @@ export {
minimumModelContextWindowTokens
} from './context-window'
export const defaultAnthropicMaximumOutputTokens = 32_000
export const maximumModelOutputTokens = 10_000_000
const modelProfileInputSchema = z
.object({
id: modelProfileIdSchema,
@@ -588,6 +591,12 @@ const modelProfileInputSchema = z
.min(minimumModelContextWindowTokens)
.max(maximumModelContextWindowTokens)
.optional(),
maximumOutputTokens: z
.number()
.int()
.min(1)
.max(maximumModelOutputTokens)
.optional(),
imageGenerationQuality: imageGenerationQualitySchema,
apiKey: modelApiKeyUpdateSchema
})
@@ -968,6 +977,7 @@ export type ModelConnectionSettings = {
authentication: ModelAuthentication
supportsImageInput?: boolean
contextWindowTokens?: number
maximumOutputTokens?: number
imageGenerationQuality: ImageGenerationQuality
apiKeyConfigured: boolean
credentialSource: 'none' | 'encrypted' | 'environment' | 'unreadable'
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { detectSupportedImage } from './image-media-type'
describe('supported image detection', () => {
it.each([
{
bytes: [0xff, 0xd8, 0xff],
extension: 'jpg',
mimeType: 'image/jpeg'
},
{
bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
extension: 'png',
mimeType: 'image/png'
},
{
bytes: [
0x52,
0x49,
0x46,
0x46,
0,
0,
0,
0,
0x57,
0x45,
0x42,
0x50
],
extension: 'webp',
mimeType: 'image/webp'
}
])('detects $mimeType bytes', ({ bytes, extension, mimeType }) => {
expect(detectSupportedImage(Uint8Array.from(bytes))).toEqual({
extension,
mimeType
})
})
it('rejects unsupported bytes', () => {
expect(() =>
detectSupportedImage(Uint8Array.from([1, 2, 3]))
).toThrow('图片格式不受支持')
})
})
+44
View File
@@ -0,0 +1,44 @@
export type SupportedImageFormat = {
extension: 'jpg' | 'png' | 'webp'
mimeType: 'image/jpeg' | 'image/png' | 'image/webp'
}
function matchesAscii(
data: Uint8Array,
offset: number,
value: string
): boolean {
return [...value].every(
(character, index) =>
data[offset + index] === character.charCodeAt(0)
)
}
export function detectSupportedImage(
data: Uint8Array
): SupportedImageFormat {
if (
data.byteLength >= 3 &&
data[0] === 0xff &&
data[1] === 0xd8 &&
data[2] === 0xff
) {
return { extension: 'jpg', mimeType: 'image/jpeg' }
}
if (
data.byteLength >= 8 &&
[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every(
(value, index) => data[index] === value
)
) {
return { extension: 'png', mimeType: 'image/png' }
}
if (
data.byteLength >= 12 &&
matchesAscii(data, 0, 'RIFF') &&
matchesAscii(data, 8, 'WEBP')
) {
return { extension: 'webp', mimeType: 'image/webp' }
}
throw new Error('图片格式不受支持')
}
+4 -4
View File
@@ -512,7 +512,7 @@ describe('Agent production input handling', () => {
it('requires CI-only production key variables and redacts them', () => {
expect(() =>
agentBundle.productionSigningKey({})
).toThrow('required for production Agent signing')
).toThrow('required for production GoodBuddy signing')
const secret = [
'-----BEGIN PRIVATE KEY-----',
@@ -521,8 +521,8 @@ describe('Agent production input handling', () => {
].join('\n')
const error = `signing_key=${secret} key id prod-secret-id`
const redacted = agentBundle.redactSecrets(error, {
GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY: secret,
GOODBUDDY_AGENT_SIGNING_KEY_ID: 'prod-secret-id'
GOODBUDDY_SIGNING_PRIVATE_KEY: secret,
GOODBUDDY_SIGNING_KEY_ID: 'prod-secret-id'
})
expect(redacted).not.toContain('fixture-secret-material')
expect(redacted).not.toContain('prod-secret-id')
@@ -536,7 +536,7 @@ describe('Agent production input handling', () => {
registry
})
).toThrow(
'Production Agent public signing key ID "production-release-key" is absent from resources/agent-release-keys.json'
'Production GoodBuddy public signing key ID "production-release-key" is absent from resources/agent-release-keys.json'
)
})
+195 -18
View File
@@ -6,6 +6,7 @@ import {
} from 'node:crypto'
import {
chmodSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
@@ -106,6 +107,14 @@ type AgentPackageModule = {
}
type AgentCatalogModule = {
readPackageMetadata(
archivePath: string,
registry: AgentReleaseKeyRegistry
): {
descriptor: AgentPackageDescriptor
size: number
sha256: string
}
createCatalog(options: {
projectRoot: string
x64Package: string
@@ -269,7 +278,7 @@ afterAll(() => {
})
describe('compound Agent packages', () => {
it('assembles deterministic signed Agent and Runtime archives', async () => {
it('assembles deterministic signed archives with private extraction', async () => {
const secondRoot = join(temporaryRoot, 'second')
mkdirSync(secondRoot)
const secondPath = join(
@@ -288,17 +297,22 @@ describe('compound Agent packages', () => {
'remote-runtime-lock.json'
])
)
expect(packageResult.descriptor.files).toContainEqual(
expect.objectContaining({
path: 'agent/helpers/fixture-helper',
mode: '0755'
})
)
const contentRoot = join(temporaryRoot, 'verified')
await expect(
extractAndVerifyAgentPackage({
archivePath: packagePath,
destinationDirectory: contentRoot,
architecture: 'x64',
desktopVersion: '0.11.0',
trustedRegistry: registry
})
).resolves.toMatchObject({
const verified = await extractAndVerifyAgentPackage({
archivePath: packagePath,
destinationDirectory: contentRoot,
architecture: 'x64',
desktopVersion: '0.11.0',
trustedRegistry: registry
})
expect(verified).toMatchObject({
descriptor: {
version: agentLock.agentVersion,
architecture: 'x64',
@@ -308,6 +322,15 @@ describe('compound Agent packages', () => {
}
}
})
if (process.platform !== 'win32') {
for (const directory of [
verified.rootDirectory,
verified.agentBundle.bundleDirectory,
verified.runtimeBundle.bundleDirectory
]) {
expect(lstatSync(directory).mode & 0o777).toBe(0o700)
}
}
const second = agentPackage.assembleAgentPackage({
projectRoot: process.cwd(),
@@ -412,7 +435,10 @@ describe('compound Agent packages', () => {
)
const mismatched = {
...descriptor,
version: '0.11.3'
version:
descriptor.version === '999.0.0'
? '999.0.1'
: '999.0.0'
}
mismatched.contentDigest =
agentPackage.descriptorContentDigest(mismatched)
@@ -446,6 +472,74 @@ describe('compound Agent packages', () => {
).rejects.toThrow('does not match its Agent bundle')
})
it('accepts a signed package whose key registry uses CRLF', async () => {
const files = unzipSync(
new Uint8Array(readFileSync(packagePath))
)
const registryBytes = Buffer.from(
Buffer.from(files['agent-release-keys.json']!)
.toString('utf8')
.replace(/\n/gu, '\r\n')
)
files['agent-release-keys.json'] =
new Uint8Array(registryBytes)
const descriptor = agentPackageDescriptorSchema.parse(
JSON.parse(
Buffer.from(files['agent-package.json']!).toString('utf8')
)
)
const updatedDescriptor = {
...descriptor,
files: descriptor.files.map((file) =>
file.path === 'agent-release-keys.json'
? {
...file,
size: registryBytes.byteLength,
sha256: createHash('sha256')
.update(registryBytes)
.digest('hex')
}
: file
)
}
updatedDescriptor.contentDigest =
agentPackage.descriptorContentDigest(updatedDescriptor)
const descriptorBytes =
agentPackage.descriptorBytes(updatedDescriptor)
files['agent-package.json'] = new Uint8Array(descriptorBytes)
files['agent-package.sig'] = new Uint8Array(
Buffer.from(
`${sign(
null,
Buffer.concat([
agentPackage.signatureDomain,
descriptorBytes
]),
privateKey
).toString('base64')}\n`
)
)
const crlfPath = join(
temporaryRoot,
'crlf-registry.gbagent'
)
writeFileSync(crlfPath, Buffer.from(zipSync(files)))
await expect(
extractAndVerifyAgentPackage({
archivePath: crlfPath,
destinationDirectory: join(
temporaryRoot,
'crlf-registry-output'
),
desktopVersion: '0.11.0',
trustedRegistry: registry
})
).resolves.toMatchObject({
descriptor: { architecture: 'x64' }
})
})
it('orders SemVer prereleases and selects the latest compatible protocol', () => {
expect(compareSemanticVersions('1.0.0-e2e.10', '1.0.0-e2e.9'))
.toBeGreaterThan(0)
@@ -564,12 +658,15 @@ describe('compound Agent packages', () => {
changedRoot,
agentPackageArchiveName(agentLock.agentVersion, 'x64')
)
const changedEntries = unzipSync(
new Uint8Array(readFileSync(packagePath))
)
const originalPackage = readFileSync(packagePath)
const changedPackage = Buffer.concat([
originalPackage,
Buffer.from([0])
])
changedPackage.writeUInt16LE(1, originalPackage.length - 2)
writeFileSync(
changedPath,
Buffer.from(zipSync(changedEntries, { level: 1 }))
changedPackage
)
expect(() =>
agentCatalog.createCatalog({
@@ -596,6 +693,18 @@ describe('compound Agent packages', () => {
).toThrow('identity is immutable')
})
it('verifies stored payload bytes that contain a ZIP descriptor signature', () => {
expect(
agentCatalog.readPackageMetadata(packagePath, registry)
).toMatchObject({
descriptor: {
architecture: 'x64'
},
size: packageResult.size,
sha256: packageResult.sha256
})
})
it('imports and exports a fully verified offline package atomically', async () => {
const keyRegistryPath = join(
temporaryRoot,
@@ -674,7 +783,7 @@ describe('compound Agent packages', () => {
})
it.each(['github', 'mirror'] as const)(
'follows the selected %s source only after a manual download',
'checks the selected %s catalog without downloading until requested',
async (source) => {
const root = join(temporaryRoot, `online-${source}`)
mkdirSync(root)
@@ -682,6 +791,7 @@ describe('compound Agent packages', () => {
writeFileSync(
keyRegistryPath,
`${JSON.stringify(registry, null, 2)}\n`
.replace(/\n/gu, '\r\n')
)
const catalog = signedCatalog()
const requested: string[] = []
@@ -733,6 +843,42 @@ describe('compound Agent packages', () => {
await manager.getInventory()
expect(requested).toEqual([])
const snapshot = await manager.getSnapshot()
expect(snapshot.catalog).toMatchObject({
state: 'available',
error: null
})
expect(
snapshot.entries.find(
(entry) => entry.architecture === 'x64'
)
).toMatchObject({
state: 'not-downloaded',
latestVersion: agentLock.agentVersion,
updateAvailable: false
})
expect(requested).toContain(
source === 'github'
? 'https://api.github.com/repos/mesalogo/goodbuddy/releases?per_page=100'
: 'https://goodbuddy.oss-cn-beijing.aliyuncs.com/agent-releases/latest.json'
)
expect(requested).not.toContain(
source === 'github'
? packageEntry().downloads.github.url
: packageEntry().downloads.mirror.url
)
await manager.download('x64')
const installedSnapshot = await manager.getInventory()
expect(
installedSnapshot.entries.find(
(entry) => entry.architecture === 'x64'
)
).toMatchObject({
state: 'verified',
version: agentLock.agentVersion,
latestVersion: agentLock.agentVersion,
updateAvailable: false
})
await manager.download('x64')
expect(requested).toContain(
source === 'github'
@@ -744,6 +890,16 @@ describe('compound Agent packages', () => {
? packageEntry().downloads.github.url
: packageEntry().downloads.mirror.url
)
expect(
requested.filter(
(url) =>
url === (
source === 'github'
? packageEntry().downloads.github.url
: packageEntry().downloads.mirror.url
)
)
).toHaveLength(1)
}
)
@@ -797,6 +953,14 @@ describe('compound Agent packages', () => {
fetch: transport as typeof fetch
})
await expect(manager.getSnapshot()).resolves.toMatchObject({
catalog: {
state: 'unavailable',
error: 'Agent 发布目录签名校验失败'
},
entries: expect.any(Array)
})
expect(packageRequested).toBe(false)
await expect(manager.download('x64')).rejects.toThrow(
'Agent 发布目录签名校验失败'
)
@@ -872,6 +1036,7 @@ function writeAgentBundle(
): void {
mkdirSync(join(directory, 'lib'), { recursive: true })
mkdirSync(join(directory, 'licenses'), { recursive: true })
mkdirSync(join(directory, 'helpers'), { recursive: true })
writeFileSync(join(directory, 'node'), elf(architecture))
writeFileSync(
join(directory, 'goodbuddy-agent'),
@@ -881,6 +1046,10 @@ function writeAgentBundle(
join(directory, 'lib', 'agent.cjs'),
'module.exports={}\n'
)
writeFileSync(
join(directory, 'helpers', 'fixture-helper'),
'#!/bin/sh\n'
)
for (const [name, contents] of [
['GoodBuddy-0BSD.txt', '0BSD'],
['Node.js-MIT.txt', 'MIT'],
@@ -891,7 +1060,11 @@ function writeAgentBundle(
writeFileSync(join(directory, 'licenses', name), contents)
}
writeKoffiPayload(directory, architecture)
for (const path of ['node', 'goodbuddy-agent']) {
for (const path of [
'node',
'goodbuddy-agent',
'helpers/fixture-helper'
]) {
chmodSync(join(directory, path), 0o755)
}
const manifest = agentBundle.createManifest(directory, {
@@ -1015,7 +1188,11 @@ function createRuntimeInput(
)
writeFileSync(
join(packageRoot, 'bin', 'opencode'),
elf(architecture)
Buffer.concat([
elf(architecture),
Buffer.from([0x50, 0x4b, 0x07, 0x08]),
Buffer.from('runtime payload')
])
)
chmodSync(join(packageRoot, 'bin', 'opencode'), 0o755)
const archive = join(
+4 -7
View File
@@ -97,10 +97,10 @@ describe('independent Agent release workflow', () => {
)
expect(buildScripts).toContain('--ignore-scripts')
expect(build).toContain(
'GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY'
'GOODBUDDY_SIGNING_PRIVATE_KEY'
)
expect(build).toContain(
'GOODBUDDY_REMOTE_RUNTIME_SIGNING_PRIVATE_KEY'
expect(workflow).not.toContain(
'GOODBUDDY_REMOTE_RUNTIME_SIGNING_'
)
expect(buildScripts).toContain('.gbagent')
})
@@ -169,10 +169,7 @@ describe('independent Agent release workflow', () => {
it('keeps private keys out of authorization and public publication jobs', () => {
expect(serializedJob('authorize')).not.toContain('secrets.')
expect(serializedJob('publish')).not.toContain(
'GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY'
)
expect(serializedJob('publish')).not.toContain(
'GOODBUDDY_REMOTE_RUNTIME_SIGNING_PRIVATE_KEY'
'GOODBUDDY_SIGNING_PRIVATE_KEY'
)
})
})
+5 -1
View File
@@ -162,7 +162,7 @@ describe('GoodBuddy Agents workflow', () => {
)
expect(workflow).not.toContain('secrets.')
expect(workflow).not.toContain(
'GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY'
'GOODBUDDY_SIGNING_PRIVATE_KEY'
)
expect(ciBundleSource).toContain(
"generateKeyPairSync('ed25519')"
@@ -176,6 +176,10 @@ describe('GoodBuddy Agents workflow', () => {
)
expect(ciBundleSource).toContain('assembleAgentPackage')
expect(ciBundleSource).toContain('buildRuntimeBundle')
expect(ciBundleSource).toContain('archive: second.archive')
expect(ciBundleSource).not.toContain(
'second.package.archive'
)
expect(build).toContain('.gbagent')
expect(ciBundleSource).not.toContain(
'readTrustedKeyRegistry'
+36 -2
View File
@@ -93,6 +93,12 @@ interface ReleaseBuilderModule {
targetRuntimePackageNames: (
options: ReleaseOptions
) => string[]
targetHarnessPaths: (
options: ReleaseOptions
) => {
nodePtyBinary: string
nodePtyDirectory: string
}
stageTargetRuntimeDependencies: (
options: ReleaseOptions,
dependencies?: {
@@ -446,6 +452,10 @@ describe('release build arguments', () => {
build: {
asarUnpack: string[]
npmRebuild: boolean
extraResources: Array<{
from: string
to: string
}>
}
optionalDependencies: Record<string, string>
}
@@ -468,6 +478,10 @@ describe('release build arguments', () => {
expect(packageJson.build.asarUnpack).not.toContain(
'node_modules/npm/**/*'
)
expect(packageJson.build.extraResources).toContainEqual({
from: 'resources/licenses/onnxruntime-MIT.txt',
to: 'licenses/onnxruntime-web-MIT.txt'
})
expect(packageJson.optionalDependencies).toEqual({
'@koromix/koffi-darwin-arm64': '3.1.4',
'@koromix/koffi-darwin-x64': '3.1.4',
@@ -863,7 +877,7 @@ describe('release build arguments', () => {
join(projectRoot, 'package.json'),
JSON.stringify({
dependencies: {
'@deepseek-ai/dsh-llm': '0.1.0-rc.6'
'@deepseek-ai/dsh-llm': '0.1.0-rc.8'
}
})
)
@@ -923,7 +937,7 @@ describe('release build arguments', () => {
)
).toMatchObject({
name: '@deepseek-ai/dsh-llm',
version: '0.1.0-rc.6'
version: '0.1.0-rc.8'
})
} finally {
rmSync(projectRoot, { recursive: true, force: true })
@@ -948,6 +962,26 @@ describe('release binary architecture detection', () => {
})
describe('release Harness package verification', () => {
it.each([
['windows', 'x64', 'prebuilds/win32-x64/conpty.node'],
['windows', 'arm64', 'prebuilds/win32-arm64/conpty.node'],
['macos', 'x64', 'prebuilds/darwin-x64/pty.node'],
['macos', 'arm64', 'prebuilds/darwin-arm64/pty.node'],
['linux', 'x64', 'prebuilds/linux-x64/pty.node'],
['linux', 'arm64', 'prebuilds/linux-arm64/pty.node']
] as const)(
'verifies the rc.8 node-pty prebuild for %s %s',
(platform, arch, expected) => {
expect(
releaseBuilder.targetHarnessPaths({
...windowsOptions,
platform,
arch
}).nodePtyBinary
).toBe(expected)
}
)
it('fails closed when the packaged Harness Host is missing', () => {
const directory = mkdtempSync(
join(tmpdir(), 'goodbuddy-harness-closure-')

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