Compare commits

...
Author SHA1 Message Date
mesalogo 670171ce5d merge: preserve GitHub README updates
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
Deploy website to GitHub Pages / Deploy static website (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 / 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
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
2026-09-04 08:21:17 +08:00
mesalogo 4490e5fd15 fix: stabilize Runtime workflows for 0.12.3
OpenCode status checks could cold-start throwaway servers, unrelated
conversations could block each other, and model, Continue, or remote paths
could replay or duplicate work under failure and backlog conditions. Settings
could also lose edits or overwrite unrelated model fields.

This update makes status checks passive, keeps per-conversation ordering while
allowing concurrency, preserves native long-running work with bounded display
capture and backpressure, narrows retries to known pre-dispatch failures, and
aligns remote Ask permissions without reducing Execute. It also keeps
Embedding updates narrow, improves settings and browser guidance, removes the
unused legacy Main gateway state, and records the validated Desktop 0.12.3 and
Agent 0.11.17 release metadata.

Release note: 改善 OpenCode 切换、多会话长任务、模型重试、远程 Ask/Execute 与设置保存可靠性,并完成 Desktop 0.12.3 / Agent 0.11.17 发布准备。
2026-09-04 08:05:29 +08:00
mesalogo 374968d50b docs: add desktop pet feature design
Adds the full documentation set for the planned desktop pet feature under
docs/features/desktop-pet: README, PRD, user stories, logic design, UI
design, and technical design. The pet is scoped as a lightweight always-on
entry point for quick drafting, drag-and-drop staging, and task status at a
glance; it does not introduce a second Ask/Execute channel, task execution
path, or independent task-state store. Design only, no implementation yet.
2026-09-03 23:22:33 +08:00
mesalogo 4e60a4ca61 feat: customize model provider requests
Model connections can now save bounded JSON request headers and top-level body fields. Direct requests and Continue support both; local OpenCode and DeepSeek Harness use only native header support, while the managed remote OpenCode gateway applies both.

Runtime, protocol, and authentication fields keep precedence. Custom values are ordinary connection settings, not credential storage. This migrates Runtime settings from v20 to v21 and updates the DeepSeek control and managed Runtime ACP capabilities.

Release note: 模型连接详情新增自定义请求头和请求体;直连与 Continue 支持两者,本地 OpenCode 和 DeepSeek Harness 仅应用原生支持的请求头,受管远程 OpenCode 支持两者,界面会明确提示生效范围。
2026-09-03 22:34:01 +08:00
mesalogo 3765a937bd docs: clarify website product highlights
Reframe the feature section around the broader desktop product rather than only Agent Runtimes. Clarify official x64 and arm64 coverage, the separate LoongArch preview, and that compatibility claims are not vendor certification.
2026-09-03 22:31:49 +08:00
mesalogo feaebdf221 feat: share built-in browser across runtimes
The built-in browser was limited to direct-model tools, and its Agent switch also blocked manual Browser workbar actions. It is now a request-scoped GoodBuddy capability assignable to direct models, managed OpenCode, and Continue, while manual browser controls remain available when Agent access is off.

Existing browser enablement migrates to capability settings version 6. DeepSeek Harness remains unsupported.

Release note: 内置浏览器现在可分配给直连模型、OpenCode 和 Continue;关闭 Agent 浏览器能力不会影响用户在浏览器工作栏中的手动操作。
2026-09-03 19:36:06 +08:00
mesalogo 70ec2bf792 fix: restore rounded workbar tabs
The workbar tabs, close controls, and add button now use the shared rounded control styling again. The terminal panel and its toolbar remain square as requested.

Release note: 恢复工作栏页签、关闭按钮和加号入口的圆角外观;终端面板与终端工具栏继续保持直角。
2026-09-03 19:32:00 +08:00
mesalogo 8b9dbf55df fix: allow concurrent and unrestricted Runtime sessions
Local embedded OpenCode serialized otherwise independent conversations, and GoodBuddy could stop long Runtime tasks after fixed tool, activity, model-call, or accumulated display-output limits.

Different conversations can now run concurrently while requests in the same conversation stay ordered. Dynamic MCP tools remain isolated to the current request. OpenCode, Continue, managed ACP, and the Agent model gateway no longer stop native execution at GoodBuddy-defined call counts or prompt-wide output totals; display copies truncate and transport queues apply backpressure instead. Prompt-wide quota fields are removed from the current contracts.

Per-call model settings, provider request timeouts, cancellation, and same-conversation ordering remain unchanged.

Release note: 修复本机 OpenCode 不同会话互相等待的问题,并移除 GoodBuddy 对单次 Runtime 工具、活动和模型调用次数的人为上限;长输出现在只截断展示或施加传输背压,不再取消原生任务。
2026-09-03 17:17:09 +08:00
mesalogo b1e3101857 fix: restore terminal paste and square workbar chrome
Terminal paste previously relied on the Renderer browser Clipboard API, which could be denied by Electron and leave the toolbar action unusable. Text clipboard access now goes through the trusted Main/Preload bridge, and the workbar tabs plus terminal toolbar use straight corners for a continuous docked appearance.

Release note: 修复终端工具栏粘贴可能因剪贴板权限被拒绝而失败的问题,并将工作栏页签与终端工具栏调整为直角样式。
2026-09-03 17:12:27 +08:00
mesalogo 7369c13bcf docs: clarify remote runtime backpressure 2026-09-03 16:34:57 +08:00
mesalogo fba5e1ad30 docs: design device sharing network 2026-09-03 16:34:28 +08:00
mesalogo 468cf0483f docs: design external knowledge providers 2026-09-03 16:29:51 +08:00
mesalogo 6586192222 fix: enforce remote Ask at tool boundary
Managed SSH Ask previously required bubblewrap and applied a process-level filesystem sandbox, while Execute launched directly. Ask and Execute now share the same direct signed Runtime startup; Ask remains read-only through the Agent's existing tool permission mediation, which only permits native read requests and rejects other tool kinds.

Release note: 远程 Ask 与 Execute 现在都可直接启动签名 Runtime,无需 Host 安装 bubblewrap;Ask 仍在工具权限层仅允许读取操作。
2026-09-03 16:23:14 +08:00
mesalogo 987a08ad78 fix: allow remote Execute without bubblewrap
Remote Hosts without the Ask-only bubblewrap tool could report matching Agent and Runtime versions but fail project creation because the Agent hid the Runtime ACP capability entirely. The Agent now advertises a verified Runtime independently of bubblewrap, so Execute can start directly while Ask keeps its existing read-only bubblewrap boundary.

Release note: 修复远程 Host 缺少 bubblewrap 时 Execute 项目无法创建的问题;更新到 Agent 0.11.16 后,Execute 不再受 Ask 专用系统工具影响。
2026-09-03 15:59:05 +08:00
lofyerandGitHub 35a93e61cd Merge pull request #3 from mesalogo/jojocoding-patch-2
Update README.md
2026-09-03 14:25:23 +08:00
lofyerandGitHub 0cba3855ea Merge pull request #2 from mesalogo/jojocoding-patch-1
Update README.zh-CN.md
2026-09-03 14:24:28 +08:00
mesalogo 74c3ec19b4 fix: retry transient direct model failures
Direct model conversations previously stopped after the first transient network error or request timeout, including TLS setup ECONNRESET failures. Text requests now retry up to three times with cancellable backoff before visible output, while partial streamed responses are never replayed.

Release note: 直连模型遇到临时网络故障或请求超时时,会在尚未输出内容的情况下自动重试最多 3 次,并支持随时取消。
2026-09-03 14:24:09 +08:00
jojokeeandGitHub cdc8f6f57a Update README.md
更新英文README
2026-09-03 14:22:33 +08:00
jojokeeandGitHub b8c1d33e7b Update README.zh-CN.md
更新社群入群信息
2026-09-03 14:20:34 +08:00
mesalogo f18e4abb3c fix: simplify topbar Runtime status
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 topbar repeated a textual state after the Runtime or model name even
though it already displayed a compact status indicator. It now uses green,
yellow, and gray dots for ready, connecting, and unavailable states while
keeping detailed status available to assistive technology and tooltips.

Release note: 顶栏 Runtime 状态改用绿、黄、灰三色标记,不再重复显示“就绪”“连接中”或“不可用”文字。
2026-09-03 13:42:41 +08:00
157 changed files with 11202 additions and 5464 deletions
+9 -5
View File
@@ -220,6 +220,9 @@ package format v1 归档已经包含固定 Agent 和 Node,可直接用于 Host
GoodBuddy 管理的本机 OpenCode 启动后会轮询已认证的 loopback health 接口,最多等待
30 秒。该窗口用于兼容 Linux ARM 等较慢环境;不要改回依赖 stdout 文案或 10 秒固定上限。
聊天中的 Runtime 切换只执行路径和模型凭据就绪检查,不为状态显示启动临时 Server。
设置中的显式连接测试、原生能力清单读取和首次实际请求仍会启动 OpenCode,并继续使用
上述完整健康检查。
## GoodBuddy Agent 工件
@@ -305,10 +308,11 @@ integrity,以 `--ignore-scripts` 从官方 npm registry 获取归档;bundle
再次验证归档 SHA-512、包名、版本和 ELF 架构。
源码已经实现 Runtime bundle build/import/verify、Daemon 侧
manifest/Ed25519/payload/ELF/lock 校验、digest registry、ACP v3、直接进程 ownership、
Ask 的固定 `bwrap` 只读 profile 和 `runtime/model-bridge` v1。Agent 通过私有 Unix socket
按需 detached 启动,不依赖 systemd、D-Bus 或 Linger。Execute 直接以所选 SSH 账号权限
启动签名 Runtime;Ask 才使用只读 bubblewrap。
manifest/Ed25519/payload/ELF/lock 校验、digest registry、ACP v3、直接进程 ownership 和
`runtime/model-bridge` v1。Agent 通过私有 Unix socket 按需 detached 启动,不依赖
systemd、D-Bus、Linger 或 bubblewrap。Ask 与 Execute 都直接启动签名 Runtime;两者的
差异由 OpenCode Ask 权限配置和 Agent 工具权限分发边界共同执行,Execute 不注入该
权限覆盖。
OpenCode 通过已签名 Agent helper 和每次 Prompt 的私有 Unix socket 使用 Main-only 模型
网关;Provider URL、API Key 和真实 Provider 认证头不进入远端。helper 的 loopback
@@ -317,7 +321,7 @@ HTTP 入口使用每个进程随机生成的路径 capability,Anthropic/OpenAI
配置必须禁用 title Agent;工具循环只使用 build Agent 的模型轮次。
Ask 模式的 ACP 权限中介只可为原生 `read` 选择 `allow_once`;其他工具种类和
`allow_always` 请求必须拒绝,Workspace 的真正只读边界仍由 `bwrap` 执行。Unix 模型桥
`allow_always` 请求必须拒绝。Unix 模型桥
broker 必须按 socket 当前已缓冲字节增量读取长度帧,不能等待 `read(remaining)` 一次返回
完整大响应。Agent 传输或权限实现变化后,除了普通测试,还要在原生 Linux 上运行包含
至少 256 KiB 响应的模型桥回归。
+69 -34
View File
@@ -77,11 +77,20 @@ otherwise.
cleanup preserves complete Windows process-tree termination and terminates
POSIX process groups when a child uses an independent process group. Chat
status checks use a one-shot Runtime probe that is immediately cleaned up
rather than entering the model-and-project execution cache. Execution
Runtimes remain reusable per project, so different projects can run in
parallel. Interactive questions are answered only by foreground
conversations; scheduled tasks, remote channels, delegated work, and other
background runs fail immediately with guidance to rerun in the foreground
rather than entering the model-and-project execution cache. For managed
local OpenCode, this passive check validates the selected path and model
credential without starting a throwaway server; the explicit settings test,
native inventory, and first real use still perform full startup and health
checks. Execution Runtimes remain reusable per project. GoodBuddy-managed local OpenCode keeps
requests in the same conversation ordered, while different conversations in
the same project and different projects can run in parallel. Request-scoped
dynamic MCP tools remain isolated through default wildcard disablement and
explicit current-request enablement. OpenCode and Continue runs do not stop
at a fixed tool-call or activity count, and exceeding local display capture
capacity truncates the displayed copy without cancelling the native task.
Interactive questions are answered only by foreground conversations; scheduled tasks, remote channels,
delegated work, and other background runs fail immediately with guidance to
rerun in the foreground
instead of waiting indefinitely. Local OpenCode startup uses the selected
absolute path directly without checking the file or running `--version`
first; invalid paths fail at actual launch. On managed Linux ARM Hosts,
@@ -92,11 +101,13 @@ otherwise.
Features and disabled by default. Disabling it does not affect local
projects, ordinary desktop capabilities, or desktop releases. When enabled,
users can manage SSH Hosts with pinned Host Keys, browse bounded remote
directories, and create Ask or Execute projects. Ask keeps the Workspace
read-only at the Runtime boundary through bubblewrap; Execute has all
permissions of the selected SSH account. The Agent owns accepted Prompts,
directories, and create Ask or Execute projects. Both modes start the
signed Runtime directly without bubblewrap. Ask applies OpenCode's Ask
permission configuration and permits only native read approvals at the
Agent tool-dispatch boundary, while Execute receives no such override and
retains all permissions of the selected SSH account. The Agent owns accepted Prompts,
provider/tool rounds, Runtime processes, a stable model ledger, and a bounded
semantic transcript over a private Unix socket and ACP v4. Work continues on
semantic transcript over a private Unix socket and ACP v5. Work continues on
the Host after Desktop exit, network loss, or local-process termination.
Reconnection attaches only to the original controller, binding, and
operation, then atomically merges provenance into the original conversation
@@ -121,15 +132,18 @@ otherwise.
Workspace/Runtime is first used, then reused by other projects in the same
process. Managed SSH conversations expose only supported OpenCode choices.
Multiple projects and conversations can run concurrently on one Host;
Agent-owned prompts are not stopped at a fixed model-call count or
prompt-wide output-token total, and Runtime output uses transport
backpressure instead of cancellation when GoodBuddy buffers fill;
recovery separately reports network, Agent, Runtime, committed-event cursor,
completion, or failure/retry and blocks only the affected project. A real
Linux x64 Host has covered short and long detach, forced local harness exit,
concurrency, SSH relay loss, cancellation, definite and uncertain provider
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
database. Successful tool START/END events appear exactly once, with no
Prompt, provider, or tool replay observed. The current source lock and
Agent source lock is `0.11.15`, while the current Desktop release candidate
is `0.12.2`; formal publication status follows the separate Agent and Desktop
Prompt, provider, or tool replay observed. The current Agent source lock is
`0.11.17`, while the current Desktop release candidate is `0.12.3`; formal
publication status follows the separate Agent and Desktop
release channels.
- [x] **Manual SSH Host environment provisioning source path**: After Host Key,
authentication, and system probes succeed, GoodBuddy saves the Host and
@@ -203,6 +217,14 @@ otherwise.
a bounded real text or image-generation request and validates the generated
result instead of testing HTTP reachability alone, so it may incur a small
provider usage charge.
- [x] **Custom model request fields**: Each LLM connection accepts bounded
JSON objects for additional request headers and top-level body fields.
Direct requests and Continue support both; local OpenCode and DeepSeek
Harness use only their natively supported headers, while the managed SSH
OpenCode model gateway supports both. Runtime, protocol, and authentication
fields take precedence, and these ordinary connection settings must not be
used to store API keys or other secrets. See
[model request customization](./docs/features/model-connections/README.md).
- [x] **Context usage and automatic compaction**: Direct models update usage
from each successful provider call. Images and tool rounds use the same
accounting, with estimation only when the provider omits usage. The UI
@@ -300,13 +322,14 @@ otherwise.
install, SSL, pip, and venv probe before packaging. The immutable `v0.12.0`
attempt passed Windows and macOS but exposed Linux TAR case handling before
publication. The immutable `v0.12.1` attempt was cancelled before native
packaging while synchronizing the Agent release; the corrected `0.12.2`
candidate must pass all six targets. Real Skill/MCP execution, custom interpreter
execution, and coordination with affected running processes remain
packaging while synchronizing the Agent release. Published `v0.12.2`
subsequently passed all six native package jobs and their managed-Python
install probes. Real Skill/MCP execution, custom interpreter execution, and
coordination with affected running processes remain
acceptance work. Six-platform release acceptance must not be claimed before
the applicable checks pass.
- [x] **On-demand built-in MCP**: Knowledge, Magic Notes, and GoodBuddy
configuration MCP can be enabled independently and assigned to direct
- [x] **On-demand built-in MCP**: Knowledge, Magic Notes, GoodBuddy
configuration, and built-in browser MCP can be enabled independently and assigned to direct
models, GoodBuddy-managed OpenCode, and Continue. Settings explicitly marks
DeepSeek Harness unsupported. Built-in MCP uses short-lived local authority
for the current request, and user configuration cannot loosen Ask/Execute
@@ -325,6 +348,14 @@ otherwise.
explicitly does not support Resources.
- [x] **Local knowledge bases**: Supports file, directory, and web imports,
SQLite FTS5 retrieval, and source tracing.
- [ ] **External knowledge-base connections** (planned): Manage Dify, FastGPT,
and RAGFlow instances from the existing Knowledge page, then discover and
bind remote knowledge bases with provider-specific retrieval settings and
citations. External systems provide retrieval only: GoodBuddy does not use
their App, Chat, Workflow, or Agent APIs, and does not bulk-sync, locally
index, or modify remote content. Bounded cited snippets are retained locally
with their conversations. See the
[external knowledge-base PRD](./docs/features/knowledge-base/external-knowledge-prd.md).
- [x] **Knowledge graph**: Supports rule-based, model-based, and hybrid
extraction plus entity, relationship, alias, and evidence maintenance.
- [x] **Embedding configuration and retrieval**: Configures compatible
@@ -415,14 +446,15 @@ otherwise.
### Browser, communication, voice, and application maintenance
- [x] **Built-in browser for direct models**: Uses GoodBuddy's isolated
- [x] **Shared built-in browser for runtimes**: Uses GoodBuddy's isolated
Chromium and never controls a browser installed by the user. A separate
master switch decides whether Execute receives the capability, with no
per-use prompt after enablement. The Browser workbar and Agent share the same
Conversation-owned session and serialized operation path, with Back,
Refresh/Stop Loading, address entry, Go, Interaction, and Close actions.
Stop Loading does not close the session, and user navigation changes the
page the Agent sees next.
master switch decides whether assigned runtimes receive the capability in
Execute, with no per-use prompt after enablement. Turning Agent access off
does not disable manual browser workbar actions. The Browser workbar and
Agent share the same Conversation-owned session and serialized operation
path, with Back, Refresh/Stop Loading, address entry, Go, Interaction, and
Close actions. Stop Loading does not close the session, and user navigation
changes the page the Agent sees next.
- [x] **Client-computer control tools**: Managed separately from the built-in
browser with scope, cancellation, timeout, output, and activity boundaries.
- [x] **Remote messaging-channel projects**: WeChat ClawBot, WeCom, and
@@ -490,21 +522,24 @@ otherwise.
- [ ] **GoodBuddy Team Hub** (planned): An optional service for organizations,
RBAC, project sharing, remote Agents, policy distribution, and tenant audit.
- [ ] **SSH Host and remote execution-space release acceptance**: Host CRUD,
Host Key, encrypted credentials, Project UI, Workspace, OpenCode ACP v4,
Host Key, encrypted credentials, Project UI, Workspace, OpenCode ACP v5,
Agent-owned Prompt/gateway/transcript, read-only Ask, full-account Execute,
cancellation, exact detached-Agent reconnection, and release-only
dual-architecture resource verification are wired. Current Linux x64 source
has passed a real-model and tool matrix covering detach, process exit, relay
loss, concurrency, cancellation, provider failures, Agent restart, and
Desktop SQLite recovery. The final `0.11.14` test-signed package again
verified two delivered model rounds, one tool, one terminal state,
`latest=ACK`, and no owner/journal residue. The public signing-key registry is
provisioned. Direct control-plane source does not wait for a new
installer-bearing package, but the current formally signed Linux x64/arm64
artifact matrix still requires public verification, along with
GitHub/Beijing mirror, dual-architecture, and offline GoodBuddy transfer
release acceptance. Until these gates pass, this path must not be described
as published or as having passed formal release acceptance.
Desktop SQLite recovery. A current-source, test-signed Agent `0.11.17`
package on Linux x64 additionally passed two concurrent Ask reads, an
Execute write/read, and six real managed-gateway model requests with the
configured custom headers and body fields. No provider request was replayed,
and scanning the isolated Agent state found no API key or custom request
value. The public signing-key registry is provisioned. Direct control-plane
source does not wait for a new installer-bearing package, but the current
formally signed Linux x64/arm64 artifact matrix still requires public
verification, along with GitHub/Beijing mirror, dual-architecture, and
offline GoodBuddy transfer release acceptance. Until these gates pass, this
path must not be described as published or as having passed formal release
acceptance.
- [ ] **Multi-cloud remote sandbox Agents** (planned): Manages dedicated Linux
sandboxes through cloud-provider APIs and SSH Agents. Credentials remain in
Main, and high-risk control-plane operations receive separate confirmation.
+9 -6
View File
@@ -14,6 +14,7 @@
### 桌面基础、工作空间与上下文
- [x] **跨平台桌面应用**:支持 Windows、macOS、Linux,以及 `x64`、`arm64` 发布目标。
- [x] **信创软硬件覆盖**:正式 Linux 安装包覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境。龙芯 LoongArch 另行提供 `loong64` 实验预览版,不属于正式发布矩阵,也未纳入自动更新。这里的覆盖范围不等同于厂商或整机兼容认证,具体状态与限制见[龙芯预览版构建说明](./docs/development/loongarch-preview-build.md)。
- [x] **可配置全局快捷唤起**:在“平台功能 / 通用设置”中启停或录制 Electron accelerator;默认保留 `CommandOrControl+Shift+Space`,冲突或保存失败时继续使用上一组已注册快捷键,并显示可处理的状态。
- [x] **Projects、独立对话与会话分支**:按项目隔离上下文,管理会话、附件和 Git 工作区变更;本地会话可在稳定状态下复制当前聊天内容到独立分支,分支持续显示来源徽标且不复制 Task、队列或成果归属;项目选择器区分本地、托管 SSH 与远程消息通道项目,托管 SSH 项目按 Host 分组并在 Host 标题显示真实 Agent 连接状态,项目行只保留远端路径。
- [x] **文件、截图、窗口、剪贴板上下文**:用户明确选择后才加入模型上下文。
@@ -27,8 +28,8 @@
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;执行 Runtime 仍按项目复用,因此不同项目可并行。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v4 持有 accepted Prompt、Provider/工具轮次、Runtime 进程、稳定模型 ledger 与有界语义 transcript;Desktop 退出、网络中断或本机进程结束后 Host 继续执行,重启后只 attach 原 controller/binding/operation 并把 provenance 原子归并回原对话,不重发 Prompt。模型 profile 与 API Key 只在当前 accepted operation 生命周期内进入 Agent 内存,不写入 Renderer、SSH 参数、远端环境或磁盘。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.15`,Desktop 发布候选为 `0.12.2`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试、原生能力清单和首次实际使用仍执行完整启动及健康检查;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,本机展示副本达到容量时只截断后续展示,不取消原生任务。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;两种模式都直接启动签名 Runtime,不依赖 bubblewrap。Ask 同时使用 OpenCode Ask 权限配置,并在 Agent 工具分发边界只允许原生读取审批;Execute 不注入该权限覆盖,使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v5 持有 accepted Prompt、Provider/工具轮次、Runtime 进程、稳定模型 ledger 与有界语义 transcript;Desktop 退出、网络中断或本机进程结束后 Host 继续执行,重启后只 attach 原 controller/binding/operation 并把 provenance 原子归并回原对话,不重发 Prompt。模型 profile 与 API Key 只在当前 accepted operation 生命周期内进入 Agent 内存,不写入 Renderer、SSH 参数、远端环境或磁盘。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;Agent-owned Prompt 不因固定模型调用次数或 Prompt 累计输出 Token 数停止,GoodBuddy 输出缓冲区达到容量时使用传输背压而不取消 Runtime;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.17`,Desktop 发布候选为 `0.12.3`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
- [ ] **SSH Host 环境准备真实 Host 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
@@ -37,6 +38,7 @@
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。
- [x] **模型请求定制**:每个 LLM 连接可填写有界 JSON 自定义请求头和顶层请求体。直连与 Continue 支持两者;本地 OpenCode 和 DeepSeek Harness 只使用各自原生支持的请求头,受管 SSH OpenCode 模型网关支持两者。Runtime、协议和认证字段始终优先,这些普通连接设置不能用于保存 API Key 或其他密钥。详见[模型连接请求定制](./docs/features/model-connections/README.md)。
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
- [x] **持久凭据保护**:API Key 由 Main 使用系统安全存储加密且不暴露给 Renderer。托管 SSH accepted Prompt 只把当前 profile 与密钥放入 Agent 内存,不写入 SSH 参数、远端环境或磁盘;其他路径仍在 Main 内使用。密钥随对应模型连接保存,修改服务地址或临时切换为无需认证不会要求重新输入;只有用户显式清除凭据或删除连接时才移除。
- [x] **有界故障诊断**:Desktop 在用户数据目录中轮转保存启动、Runtime 和远程连接的固定阶段失败,最多 4 个 256 KiB 文件;GoodBuddy Agent 在各 installation 的私有 state 目录中轮转保存 daemon、连接、恢复和 Runtime 生命周期,最多 3 个 64 KiB 文件,并可通过固定 `diagnostics --installation-id` 命令读取。两端都只记录白名单阶段、稳定错误码/类型和固定短消息,不保存 Prompt、凭据、文件内容、路径、环境、SSH 参数或 Provider 原始响应;诊断写入失败不改变正常运行。
@@ -50,11 +52,12 @@
- [x] **Skills 按需接入**:可分配给直连模型、OpenCode、Continue 和 DeepSeek Harness,并使用有界资源和受控 Runtime 边界。
- [x] **本机工具执行环境源码链路**:在“能力与工具 > 工具执行环境”中为本机 Skills 与 stdio MCP 选择 GoodBuddy 托管 Node.js、按需安装的托管 Python,或经过真实验证的自定义解释器;提供独立的原生地址/OSS 镜像选择、诊断、安装进度、取消和删除。新的本机 Runtime 与 stdio MCP 获得不可变 PATH 快照,不修改普通终端、系统环境或远程 Host。Windows x64 托管 Node 与原生地址 Python 已通过真实安装验证;托管 Python 归档按目标文件系统验证,Linux 保留大小写不同的合法路径,所有目标仍拒绝完全重复路径和不安全条目。
- [ ] **本机工具执行环境发布验收**:六个平台/架构的 OSS 镜像对象已完成字节、大小和 SHA-256 公开回读验证。托管 Python 保持按需下载,不向 Desktop 发行包额外带入许可证文件。每个标准打包任务使用目标架构原生 Runner,并在打包前真实安装托管 Python,验证 SSL、pip 与 venv。不可变的 `v0.12.0` 尝试在发布前通过 Windows 与 macOS,但暴露了 Linux TAR 大小写处理问题;`v0.12.1` 在同步 Agent 发布时于原生打包前取消。修正后的 `0.12.2` 候选仍须通过全部六个目标。真实 Skill/MCP、自定义解释器和运行中进程协调仍是待完成验收;完成适用检查前不宣称六平台发布验收通过。
- [x] **内置 MCP 按需接入**:知识库、魔法笔记与 GoodBuddy 配置 MCP 可分别启停,并可分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue;DeepSeek Harness 在设置中明确显示为暂不支持。内置 MCP 仅通过当前请求的短期本机权限提供,Ask / Execute 读写边界不受用户配置放宽。
- [ ] **本机工具执行环境发布验收**:六个平台/架构的 OSS 镜像对象已完成字节、大小和 SHA-256 公开回读验证。托管 Python 保持按需下载,不向 Desktop 发行包额外带入许可证文件。每个标准打包任务使用目标架构原生 Runner,并在打包前真实安装托管 Python,验证 SSL、pip 与 venv。不可变的 `v0.12.0` 尝试在发布前通过 Windows 与 macOS,但暴露了 Linux TAR 大小写处理问题;`v0.12.1` 在同步 Agent 发布时于原生打包前取消。已发布的 `v0.12.2` 随后通过全部六个原生打包任务及其托管 Python 安装探针。真实 Skill/MCP、自定义解释器和运行中进程协调仍是待完成验收。
- [x] **内置 MCP 按需接入**:知识库、魔法笔记、GoodBuddy 配置与内置浏览器 MCP 可分别启停,并可分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue;DeepSeek Harness 在设置中明确显示为暂不支持。内置 MCP 仅通过当前请求的短期本机权限提供,Ask / Execute 读写边界不受用户配置放宽。
- [x] **MCP Tools**:显式启用的自定义 MCP 可按 Runtime 分配给直连模型、GoodBuddy 管理的 OpenCode、Continue Agent Execute 和 DeepSeek Harness,并仅在 Execute 加载;Agent 子进程只获得按请求签发的本机回环权限,MCP 地址、命令和凭据保留在 Main,动态工具仍会重新发现并经过现有执行记录与权限边界。
- [x] **MCP Prompts 与 Resources 元数据**:MCP 测试仅在 Server 声明对应能力时发现有界的 Prompt、参数与 Resource 元数据,不读取 Resource 内容;Runtime 支持的 Prompt 可填入聊天草稿后继续编辑。OpenCode 可报告实验性 Resource 清单,Continue 当前版本明确不支持 Resources。
- [x] **本地知识库**:支持文件、目录和网页导入、SQLite FTS5 检索及来源追溯。
- [ ] **外部知识库接入**(规划中):在现有知识库页面管理 Dify、FastGPT 和 RAGFlow 实例;新建知识库时可读取并绑定远端知识库,保留 Provider 专属检索配置和引用。外部系统只负责知识检索,不接入其 App、Chat、Workflow 或 Agent,也不批量同步、本地索引或修改远端内容。被回答引用的有界检索片段随会话保存在本机。详见[外部知识库接入 PRD](./docs/features/knowledge-base/external-knowledge-prd.md)。
- [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。
- [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。
- [x] **向量诊断与索引任务**:提供真实向量生成诊断、按文档重建进度、取消、失败状态与重启后结果恢复;每篇成功文档立即可用于检索。
@@ -80,7 +83,7 @@
### 浏览器、通信、语音与应用维护
- [x] **直连模型内置浏览器**:使用 GoodBuddy 内置的隔离 Chromium,不控制客户端已安装的浏览器;用户通过独立总开关决定是否提供给 Execute,开启后不逐次询问。浏览器工作栏与 Agent 共用同一条按 Conversation 归属的会话和串行操作路径,支持返回、刷新/停止加载、地址输入、前往、交互和关闭;停止加载不会关闭会话,用户导航会改变 Agent 下一步看到的页面。
- [x] **Runtime 共享内置浏览器**:使用 GoodBuddy 内置的隔离 Chromium,不控制客户端已安装的浏览器;用户通过内置 MCP 总开关和 Runtime 分配决定是否提供给 Execute,开启后不逐次询问。关闭 Agent 浏览器能力不影响用户继续在浏览器工作栏中手动前往、返回、刷新、停止加载、交互或关闭浏览器。直连模型、GoodBuddy 管理的 OpenCode 和 Continue 通过请求级权限共享按 Conversation 归属的会话和串行操作路径,用户导航会改变 Agent 下一步看到的页面。
- [x] **客户端电脑控制工具**:与内置浏览器分开管理,并保留范围、取消、超时、输出边界和执行记录。
- [x] **远程消息通道项目**:微信 ClawBot、企业微信和钉钉分别拥有系统管理的项目、独立远程会话、工作目录、处理后端、默认 Ask/Execute 模式及任务活动归属;完整回复交由各通道按平台能力控制长度与分段,不再由公共服务统一截断。
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
@@ -104,7 +107,7 @@
- [x] **远程任务委派**:仅在用户显式配置端点和令牌后启用,按全局内网兼容模式使用 HTTP(S),结果进入持久化发件箱。
- [ ] **Headless Runtime API**(规划中):提供本机优先的任务、事件、状态和成果 API,以及有范围、有效期、限流和撤销能力的令牌。
- [ ] **GoodBuddy Team Hub**(规划中):以可选服务提供组织、RBAC、项目共享、远程 Agent、策略下发和租户审计。
- [ ] **SSH 主机与远程执行空间发布验收**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v4、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验已经接线。Linux x64 当前源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;最终 `0.11.14` 测试签名包再次验证两个已交付模型轮次、一个工具、一个终态、`latest=ACK` 与零 owner/journal 残留。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包,但仍需公开核对当前 Linux x64/arm64 正式签名工件矩阵,并完成 GitHub/北京镜像、双架构和离线 GoodBuddy 传输的发布验收;门槛未满足时不宣称该路径已发布或通过正式发布验收。
- [ ] **SSH 主机与远程执行空间发布验收**:主机 CRUD、Host Key、加密凭据、Project UI、Workspace、OpenCode ACP v5、Agent-owned Prompt/gateway/transcript、Ask 只读、Execute 完整账号权限、取消、detached Agent 精确重连和 release-only 双架构资源校验已经接线。Linux x64 当前源码已用真实模型与工具通过 detach、进程结束、relay 丢失、并发、取消、Provider 异常、Agent 重启和 Desktop SQLite 恢复矩阵;当前源码测试签名 Agent `0.11.17` 又通过两个并发 Ask 读取、一次 Execute 写入并读回,以及 6 次带自定义 Header 和 Body 的真实受管网关模型请求,未发现 Provider 请求重放,隔离 Agent state 中也没有 API Key 或自定义请求值。公开 signing key registry 已供应。控制面直连源码不等待新 installer-bearing 包,但仍需公开核对当前 Linux x64/arm64 正式签名工件矩阵,并完成 GitHub/北京镜像、双架构和离线 GoodBuddy 传输的发布验收;门槛未满足时不宣称该路径已发布或通过正式发布验收。
- [ ] **多云远程沙盒 Agent**(规划中):通过云厂商 API 和 SSH Agent 管理专用 Linux 沙盒;凭据留在 Main 进程,高风险控制面操作单独确认。
## 规划原则
+34 -1
View File
@@ -26,6 +26,28 @@ retrieval, notes, and scheduled tasks. No GoodBuddy account is required.
Workspace data is stored locally by default. Model endpoints can run on the
same device, on a private network, or at a provider selected by the user.
## Highlights
- **Local-first workspace**: No GoodBuddy account is required. Workspace data
stays local by default, and users choose where model services run.
- **Unified Agent Runtimes**: Use direct models, OpenCode, Continue, and the
preview DeepSeek Harness from one desktop interface.
- **Explicit execution boundaries**: `Ask` remains read-only, while `Execute`
uses the current account's permissions. Tool activity, cancellation,
timeouts, and token usage are recorded.
- **Broad platform coverage**: Official Linux releases cover `x64` and `arm64`,
including compatible distributions used in China's domestic computing
ecosystem. A separate
experimental `loong64` preview is available for LoongArch systems.
- **Knowledge and long-running work**: Knowledge bases, graphs, Magic Notes,
tasks, and Smart Heartbeat share one desktop workspace.
Platform coverage describes operating-system, instruction-set, and package
compatibility, not certification for every hardware vendor or distribution
release. LoongArch is outside the official release matrix; see the
[LoongArch preview notes](./docs/development/loongarch-preview-build.md) for its
current limitations.
## Capabilities
| Area | Available in GoodBuddy |
@@ -33,7 +55,7 @@ same device, on a private network, or at a provider selected by the user.
| Agent Runtimes | Direct model connections, OpenCode, Continue, and the preview DeepSeek Harness |
| Data and execution | Local SQLite storage, read-only `Ask`, full-account `Execute`, and recorded tool activity |
| Knowledge | File, folder, and web imports with full-text, Chinese phrase, vector, and knowledge graph retrieval |
| Desktop releases | Windows, macOS, and Linux builds for `x64` and `arm64` |
| Desktop releases | Official Windows, macOS, and Linux builds for `x64` and `arm64`, plus an experimental LoongArch preview |
| Integrations | Local or hosted model endpoints, custom MCP servers, WeChat ClawBot, WeCom, and DingTalk |
## Product tour
@@ -73,6 +95,9 @@ Download a build from
| macOS | `x64`, `arm64` | DMG, ZIP |
| Linux | `x64`, `arm64` | AppImage, DEB, RPM |
The experimental LoongArch `loong64` build is distributed through a separate
preview channel on the [product website](https://mesalogo.github.io/goodbuddy/#download).
Desktop installers do not contain remote Agent payloads. Local projects do not
need an Agent package; managed SSH users download or import one explicitly in
Settings.
@@ -127,6 +152,14 @@ npm run typecheck
npm run lint
```
## Community communication
Scan the QR code to join the GoodBuddy WeChat group:
<img width="1250" height="1194" alt="9627ce196b0de42f123c33dd9dea1568" src="https://github.com/user-attachments/assets/e806543f-6d8a-4c5c-ba44-19e74dac9a78" />
## License
Original GoodBuddy code is released under the [0BSD License](./LICENSE). You may
+12 -1
View File
@@ -23,6 +23,16 @@
GoodBuddy 是用于模型对话、编程 Agent、知识检索、笔记和定时任务的桌面应用。使用 GoodBuddy 无需注册账号,工作数据默认保存在本机。模型服务可以运行在本机、组织内网或用户选择的云端服务。
## 产品亮点
- **本地优先**:无需注册 GoodBuddy 账号,工作数据默认保存在本机,模型服务由用户选择。
- **统一 Agent Runtime**:在一个桌面界面中使用直连模型、OpenCode、Continue 和预览版 DeepSeek Harness。
- **执行边界明确**:`Ask` 保持只读,`Execute` 使用当前账号权限,工具调用、取消、超时和 Token 用量均有记录。
- **支持信创软硬件**:正式 Linux 版本覆盖国产 `x64` 与 `arm64` 架构,可用于统信 UOS、银河麒麟及海光、兆芯、鲲鹏、飞腾等兼容环境;龙芯 LoongArch 提供独立 `loong64` 实验预览版。
- **知识与长期工作一体化**:知识库、知识图谱、魔法笔记、任务和智能心跳共享同一个桌面工作空间。
信创支持按操作系统、指令集和安装包兼容性描述,不代表已完成所有厂商、整机和系统版本的兼容认证。龙芯版本尚未进入正式发布矩阵,具体限制见[龙芯预览版说明](./docs/development/loongarch-preview-build.md)。
## 主要能力
| 范围 | GoodBuddy 提供的能力 |
@@ -118,7 +128,8 @@ npm run lint
扫描二维码加入 GoodBuddy 微信群:
<img width="360" alt="GoodBuddy 微信社区群二维码" src="https://github.com/user-attachments/assets/10bb7ac2-0bc1-41a9-978d-c2862923fcd6" />
<img width="1250" height="1194" alt="9627ce196b0de42f123c33dd9dea1568" src="https://github.com/user-attachments/assets/ff87dfac-d72f-47b7-ac9a-c88b0b4c96d9" />
## 开源许可
+6 -2
View File
@@ -336,6 +336,8 @@
- 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少
两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或
`focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。
- 工作栏 Tab、Tab 关闭按钮和“+”入口沿用共享控件圆角;终端面板外框及终端顶部工具栏按钮
使用直角,形成紧凑的停靠式终端界面。
- 选择“终端”后直接绑定当前项目,由项目执行空间自动决定本机或托管 SSH;不再显示第二层
目标选择器。终端面板内部不重复提供关闭会话按钮,关闭统一由 Tab 入口完成。
- 下一次请求使用的附件和知识库由会话输入区展示与管理,不在工作栏重复提供“上下文”栏目;
@@ -383,7 +385,9 @@
对齐。该栏不重复放置新建入口。
- “新建任务”只位于工作栏“任务索引”标题后方;任务状态筛选占满工作栏可用宽度,四个选项
等分,不能保留内容宽度的短控件。
- Runtime 状态、同步状态等短标签使用 `--font-caption`,不得放大为正文标题。
- Runtime 状态、同步状态等短标签使用 `--font-caption`,不得放大为正文标题。顶栏 Runtime
状态由状态点和 Runtime/模型名称紧凑表达:就绪使用绿色,连接中使用黄色,不可用使用灰色,
不再重复显示状态文字;完整状态详情继续通过可访问说明和工具提示提供。
- 浅色与深色切换属于持续可用的窗口级操作,直接显示太阳或月亮图标,并通过可访问名称说明将切换到的主题。选择必须持久化,切换不得改变布局。
- 顶栏只直接显示当前任务所需的高频操作。已有侧栏账户设置入口时,不再重复显示 Runtime/设置入口;使用帮助优先放在相关操作附近,而不是为单个帮助项创建“更多”菜单。
- 助手工作栏开关固定在上下文栏最右侧并与窗口关闭按钮纵向对齐;展开后该开关保持原位并
@@ -693,7 +697,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- Agent Runtime 页面在低层程序与配置覆盖之外提供“能力与默认配置”区域。能力清单使用共享 `PageTabs`,按 Agents、Tools、Commands、Skills、MCP、Rules、Prompts、Resources、LSP、Formatters 和上下文 11 类单行滚动展示,一次只呈现当前分类的 `tabpanel`;清单只显示 Runtime 自有能力,不混入 GoodBuddy 分配的 Skills、临时 MCP 或 Continue 预设。Tools 必须独立于 Commands、LSP 和 Formatters,显示工具类型、来源及 Ask/Execute 可用性;清单状态必须区分完整、部分、不可用、仅连接和不支持,不能用进程连通性冒充清单可读。
- “能力与默认配置”只显示一个模块标题,刷新入口位于该标题右侧,能力状态压缩为一行并排在默认 Agent 或 Continue 预设编辑器之前;不得再复制“Runtime 原生能力”等同义标题、说明或状态结论。刷新只更新能力快照,不覆盖未保存的原生定制草稿。
- OpenCode 的默认 Agent 使用原生下拉选择;Continue 预设编辑器允许管理名称、说明、启用的 Rules 以及 Prompt 名称、说明和正文,并可展开查看原生 Rules 与启用预设 Rules 的最终合并顺序。持久启停仍使用共享 Switch,添加与删除使用明确按钮和可访问名称。
- MCP 设置按“内置 MCP / 直连模型 / 自定义 MCP / 电脑控制”四个同级 `PageTabs` 组织。直连模型中的联网搜索与内置浏览器使用一致的折叠卡片和独立总开关;内置浏览器必须明确说明其操作 GoodBuddy 隔离浏览器,不控制客户端已安装的浏览器,开启后可由 Execute 直接使用,不逐次询问。尚未生效的命名浏览器配置不得显示在界面中,“电脑控制”只显示实际操作客户端电脑的能力。内置 MCP 卡片与 Skills 一样提供持久启停和 Runtime 分配;直连模型、GoodBuddy 管理的 OpenCode 与 Continue 默认选中且可调整,DeepSeek Harness 必须以置灰、未选择和“暂不支持”文案持续显示,不能呈现为可保存的分配。魔法笔记 MCP 的自身启停与平台功能依赖分别显示,依赖未开启时保留用户配置并说明当前不会加载。
- MCP 设置按“内置 MCP / 直连模型 / 自定义 MCP / 电脑控制”四个同级 `PageTabs` 组织。联网搜索保留在直连模型;内置浏览器归入内置 MCP,提供持久启停和 Runtime 分配,并明确说明其操作 GoodBuddy 隔离浏览器、不控制客户端已安装的浏览器,开启后可由已分配 Runtime 的 Execute 直接使用,不逐次询问。该开关只控制 Agent 工具访问,关闭时不得禁用浏览器工作栏中的用户手动导航、返回、刷新、停止加载、交互或关闭操作。尚未生效的命名浏览器配置不得显示在界面中,“电脑控制”只显示实际操作客户端电脑的能力。内置 MCP 卡片与 Skills 一样提供持久启停和 Runtime 分配;直连模型、GoodBuddy 管理的 OpenCode 与 Continue 默认选中且可调整,DeepSeek Harness 必须以置灰、未选择和“暂不支持”文案持续显示,不能呈现为可保存的分配。魔法笔记 MCP 的自身启停与平台功能依赖分别显示,依赖未开启时保留用户配置并说明当前不会加载。
- MCP Server 测试结果在同一展开卡片中分组显示 Tools、Prompts 和 Resources 的支持状态、数量与有界元数据;Prompt 参数标明必填项,Resource 只显示 URI、名称、类型和说明,不读取或渲染 Resource 内容。
### 13.9 文档解析设置
+1 -1
View File
@@ -1,6 +1,6 @@
{
"formatVersion": 1,
"agentVersion": "0.11.15",
"agentVersion": "0.11.17",
"protocol": {
"major": 2,
"minor": 0
+2 -1
View File
@@ -17,7 +17,7 @@ const {
const { app, utilityProcess } = require('electron/main')
const protocol = 'goodbuddy.deepseek-harness.control'
const controlVersion = 2
const controlVersion = 3
const byteProtocol = 'goodbuddy.deepseek-harness.byte-stream'
const byteProtocolVersion = 1
const configuredHostPath =
@@ -183,6 +183,7 @@ async function run() {
provider: 'goodbuddy',
model: 'qwen-plus',
supportsImageInput: false,
requestHeaders: {},
harnessVersion: '0.1.0-rc.8',
credentialRefs: ['GOODBUDDY_HARNESS_MODEL_API_KEY'],
skillPackages: [],
@@ -0,0 +1,450 @@
# GoodBuddy 设备共享与 ShareServer 联邦网络总体设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 适用范围 | GoodBuddy 桌面端、ShareServer、网关间联邦协议 |
| 桌面端设计 | [设备发现与共享](../features/device-sharing/README.md) |
| 服务端设计 | [ShareServer](../features/share-server/README.md) |
## 1. 文档职责
本文是设备共享网络的跨端权威设计,负责定义拓扑、术语、身份、能力清单、授权计算、任务
路由、能力分发、协议边界和共同验收标准。桌面端的产品与界面行为由“设备发现与共享”功能
文档负责,独立 Web 服务由 ShareServer 功能文档负责。
本文描述完整目标系统,不以交付阶段删减领域模型,也不把尚未实现的设计表述为当前能力。
## 2. 核心决策
### 2.1 两个独立产品面
1. **GoodBuddy Desktop** 在设置中提供“设备发现与共享”,默认使用局域网发现与直连,也可
由用户额外连接一个或多个 ShareServer。
2. **ShareServer** 是独立部署、独立升级的 Web 服务,提供组织、设备、能力、权限、更新、
联邦、中继和审计管理。它不运行在 Electron Main 中,也不由桌面端代为启动。
### 2.2 能力优先,来源可见
用户日常选择的是能力,不是网络连接。设备、ShareServer 和组织作为能力来源与诊断入口。
任何调用都必须持续显示提供者、执行位置和数据去向,不能为了统一体验隐藏跨设备或跨组织
边界。
### 2.3 局域网是默认连接方式,网关是显式附加连接
- 桌面端没有网关配置时,局域网发现、配对、调用和本机共享仍可完整工作。
- 添加、删除或断开 ShareServer 不改变局域网配对关系。
- ShareServer 不接管本机局域网发现,也不能静默把局域网设备注册到组织。
- 同一桌面端可以连接多个 ShareServer;能力来源和授权按连接分别计算。
### 2.4 发现、被发现和开放能力是三个独立动作
- **发现设备**:浏览同一局域网中主动广播的 GoodBuddy 服务。
- **允许被发现**:广播本设备存在及配对入口。
- **开放能力**:向指定设备、用户组或组织发布可查看、调用或拉取的能力。
开启其中一项不能推导另外两项已开启。新安装默认不开放能力。
### 2.5 ShareServer 优先作为控制面
ShareServer 负责身份、目录、策略、任务路由、更新和审计。数据面优先在两个已认证端点之间
直接建立;网络条件不允许时,可按组织策略使用 ShareServer 中继。是否允许中继、正文是否
端到端加密、是否允许跨组织,由本次任务冻结的策略共同决定。
## 3. 术语
| 术语 | 定义 |
| --- | --- |
| `Device` | 安装 GoodBuddy 并拥有稳定设备身份的终端 |
| `Peer` | 与当前设备存在局域网配对关系的另一台设备 |
| `ShareServer` | 提供组织控制面和可选数据中继的独立服务 |
| `Organization` | ShareServer 中隔离成员、设备、能力与策略的管理范围 |
| `GatewayConnection` | 某台设备与一个 ShareServer 上特定组织身份的连接 |
| `Capability` | 可被查看、调用或分发的稳定能力标识 |
| `CapabilityProvider` | 实际持有并执行某项能力的设备或服务节点 |
| `Publication` | 能力提供者向某一范围发布的可见性和使用声明 |
| `Grant` | 主体对能力执行某类动作的明确授权 |
| `Task` | 一次远程能力调用及其输入、执行、输出和终态 |
| `Package` | 可下载到本机安装的签名能力工件 |
| `FederationTrust` | 两个组织网关之间经管理员确认的双边信任关系 |
“网关”是 ShareServer 的网络角色;产品名称和代码目录统一使用 `ShareServer` / `shareserver`。
## 4. 系统拓扑
```text
局域网:
GoodBuddy Desktop A <-- mDNS 发现 + 配对后 TLS 直连 --> GoodBuddy Desktop B
单组织:
GoodBuddy Desktop A -- 出站连接 --> ShareServer <-- 出站连接 -- Provider Device B
跨组织:
Desktop A --> ShareServer A <== 联邦信任 ==> ShareServer B <-- Provider Device B
```
局域网和网关连接共享能力、授权和任务的领域语义,但不共享发现方式:
| 维度 | 局域网 | ShareServer |
| --- | --- | --- |
| 发现来源 | mDNS/DNS-SD 主动广播 | 已配置 URL 或邀请链接 |
| 初始身份 | 双端验证码确认设备指纹 | 用户登录、设备注册和组织成员身份 |
| 能力目录 | 已配对设备直接返回 | 服务端按组织策略过滤 |
| 连接方向 | 配对后直连 | 设备主动建立出站连接 |
| 管理范围 | 单个设备间授权 | 用户组、组织和联邦授权 |
| 审计权威 | 两端各自本地记录 | 组织网关记录元数据,两端保留本地记录 |
## 5. 组件边界
### 5.1 GoodBuddy Desktop
- 浏览和广播局域网服务。
- 创建、确认和撤销点对点配对。
- 连接 ShareServer,保存脱敏连接信息和本机受保护凭据。
- 发布用户明确选择的本机能力。
- 展示经当前身份过滤的能力目录。
- 在调用前冻结提供者、输入范围、授权和路由。
- 执行本机提供的任务,或消费远端任务结果。
- 校验并安装用户明确选择的签名能力包。
- 保存本机活动记录和可撤销授权。
### 5.2 ShareServer
- 认证用户、设备、服务节点和其他 ShareServer。
- 管理组织、成员、用户组和设备目录。
- 接收能力发布并生成权限过滤后的目录。
- 计算组织强制策略,签发短期任务授权。
- 路由任务、维护在线状态,并在允许时中继数据。
- 管理签名包、发布通道、更新策略和撤销。
- 建立双边联邦关系并限制跨组织可见性与调用。
- 保存有界审计元数据、用量和管理操作。
### 5.3 能力提供者
- 只执行已发布且本次授权允许的能力。
- 在执行边界重新验证任务令牌、输入 schema、大小、期限和调用方。
- 使用自身 Runtime、账号、工作区和数据权限。
- 返回有界进度、结果、成果或确定终态。
- 不因收到任务令牌而获得其他能力或任意系统接口。
## 6. 身份与信任
### 6.1 设备身份
每次 GoodBuddy 安装生成稳定设备密钥和自签设备证书,私钥保存在系统安全存储中。对外只
使用随机设备 ID、公钥指纹、用户设置的设备名称和必要协议版本。操作系统用户名、绝对路径、
软件资产和能力详情不进入未认证发现广播。
重装或主动重置设备身份会产生新设备。旧局域网配对和 ShareServer 设备注册不会自动迁移。
### 6.2 局域网配对
1. 发起端选择一台主动广播的设备。
2. 双方建立临时加密通道并计算共同短验证码。
3. 两端同时显示设备名、系统类型、公钥指纹和验证码。
4. 双方用户确认后保存对端公钥及配对 ID。
5. 后续使用双方设备证书认证;任一端可以撤销配对。
IP 地址不作为设备身份,也不单独构成授权依据。
### 6.3 ShareServer 注册
用户通过浏览器/OIDC 或服务端支持的登录方式认证后,为当前设备申请一次性注册挑战。设备
使用自身私钥签名挑战,ShareServer 建立 `user + organization + device` 绑定并签发可轮换的
设备凭据。设备凭据不等同于用户 Web 会话,不可用于管理控制台登录。
### 6.4 联邦身份
两个 ShareServer 由双方管理员交换组织标识、网关公钥、公开端点和指纹,并分别确认信任。
联邦关系只有在双方状态均为 `active` 时生效。单方暂停或撤销后立即停止新目录交换和新任务,
已执行任务保留审计终态。
## 7. 能力模型
### 7.1 能力清单
```json
{
"schemaVersion": "1.0",
"capabilityId": "document.convert.docx",
"name": "文档转 Word",
"version": "1.4.0",
"modes": ["remote", "package"],
"inputSchema": {},
"outputSchema": {},
"permissions": ["receive_text", "receive_files", "return_files"],
"limits": {
"maxInputBytes": 10485760,
"maxOutputBytes": 52428800,
"maxDurationSeconds": 900,
"maxConcurrentTasks": 2
},
"dataPolicy": {
"retention": "task_lifetime",
"networkAccess": false,
"delegation": "none"
}
}
```
清单只描述能力事实,不授予任何主体访问权。`inputSchema` 和 `outputSchema` 使用受限 JSON
Schema 子集;实现必须限制 schema 深度、属性数、枚举数和总字节,不能把清单当作可执行代码。
### 7.2 发布声明
`Publication` 将一个能力版本发布到以下一种范围:
- 指定局域网 Peer。
- 所有已配对 Peer。
- ShareServer 内指定用户、用户组或设备组。
- 指定联邦组织中的用户组。
发布声明同时定义允许动作:`discover`、`inspect`、`invoke`、`download`。`inspect` 允许查看
详细清单,`discover` 只允许看到名称和可用状态。
### 7.3 可调用与可分发
- `remote`:代码与受控数据留在提供方,调用方只发送输入并接收结果。
- `package`:签名工件下载到调用方,经校验和用户/组织策略批准后安装。
同一能力可以同时支持两种模式,但两种模式分别授权。远程调用授权不能推导下载权,下载权
也不能推导安装后自动启用。
## 8. 授权模型
### 8.1 授权输入
一次动作的最终决定取以下约束交集:
```text
调用方用户权限
∩ 调用方设备状态
∩ 调用方组织强制策略
∩ 发布范围
∩ 提供方授权
∩ 提供方组织强制策略
∩ 能力输入与数据策略
∩ 联邦关系限制(跨组织时)
= allow | require_approval | deny
```
`deny` 优先于 `require_approval`,`require_approval` 优先于 `allow`。客户端偏好只能在组织强制
策略允许的范围内收紧权限,不能放宽权限。
### 8.2 Grant
Grant 至少绑定:
- 主体:用户、用户组、设备、设备组或外部组织。
- 能力 ID 和允许的版本范围。
- 动作:查看、调用、下载、安装或发布。
- 输入:文本、文件、数据分类、单项和累计大小。
- 提供者和执行位置限制。
- 次数、并发、预算、时间段和有效期。
- 是否需要每次批准。
- 是否允许委托及明确的下游组织。
“始终允许”只创建与当前能力、提供者、输入类型和范围匹配的 Grant,不创建整台设备的任意
访问权。
### 8.3 转委托
默认 `delegation=none`。允许委托时,任务授权中必须冻结完整执行链和最大跳数。任一新组织、
新能力或新数据接收方加入执行链,都需要重新计算授权,不能沿用原任务令牌。
## 9. 任务协议
### 9.1 任务快照
创建任务时冻结:
- 调用方用户、设备和组织。
- 能力 ID、版本和提供者。
- 输入 schema 版本、数据类型、数据分类和字节数。
- 路由:局域网直连、网关直连、网关中继或联邦路径。
- 授权决定、批准记录和策略版本。
- 委托链、预算、期限和幂等键。
设置或策略后续变化不追改已接受任务;紧急撤销可以阻止尚未开始的任务,并按策略取消活动
任务。
### 9.2 状态
```text
created -> awaiting_approval -> queued -> connecting -> running
running -> completed | failed | cancelled | outcome_unknown
created/awaiting_approval/queued/connecting -> rejected | expired | cancelled
```
- `completed` 只在提供者提交完整终态且调用方确认接收后成立。
- 网络中断后无法确认副作用结果时使用 `outcome_unknown`,不得自动重放。
- 重试创建新 attempt,并复用仅在能力声明支持时才有效的幂等键。
- 用户取消必须传播到实际提供者;未确认取消时不能声称任务已停止。
### 9.3 数据传输
- 小型 JSON 输入使用任务通道传输。
- 文件使用内容 SHA-256、声明大小和一次性任务对象令牌传输。
- 接收端边读边校验大小和摘要,拒绝路径、符号链接和调用方文件名决定的落盘位置。
- 任务正文、文件和输出不进入普通网关审计日志。
- 中继模式下是否端到端加密由策略快照明确记录;界面必须显示网关能否读取正文。
## 10. 能力包与更新
每个 Package 必须提供不可变版本、目标平台和架构、内容大小、SHA-256、发布者签名、权限
声明、依赖和兼容范围。安装流程统一为:
```text
取得签名目录 -> 选择不可变目标 -> 下载暂存 -> 校验大小/摘要/签名
-> 展示权限差异 -> 批准 -> 安装到 GoodBuddy-owned 目录 -> 验证 -> 启用
```
要求:
- 下载开始后冻结来源,不在失败时静默切换 ShareServer 或镜像。
- 包不得包含凭据;配置只携带非敏感结构和密钥引用。
- 新版本扩大文件、进程、网络、数据或委托权限时,旧 Grant 不自动继承。
- 紧急撤销阻止新安装和新调用;已安装包的停用或删除遵循组织策略并向用户说明。
- 复用现有 GoodBuddy 签名工件原则,但不同用途使用独立签名域。
## 11. 协议分层
### 11.1 局域网发现层
- DNS-SD 服务类型:`_goodbuddy-share._tcp.local`。
- 广播仅包含协议 major、随机实例 ID、端口和配对状态。
- 设备名称、证书和详细版本通过临时加密握手取得。
- 不扫描任意端口,不枚举未主动广播的机器。
### 11.2 ShareServer 控制 API
使用 HTTPS 提供登录、设备注册、目录、管理和审计 API;使用 WebSocket 建立设备出站控制
通道,承载心跳、任务声明、取消和短期数据通道协商。普通管理 API 与设备通道使用不同凭据
和受众。
### 11.3 数据通道
数据通道可以是配对 TLS 直连、ShareServer WebSocket 中继或经网关协商的端到端加密流。
协议只保证有界帧、顺序、取消和终态,不自行实现另一套拥塞控制。
### 11.4 版本协商
- 每层独立声明 `major.minor`。
- major 不同拒绝连接;minor 使用双方共同支持的最低能力集合。
- 未识别字段在声明允许扩展的位置忽略,安全与授权枚举中的未知值一律拒绝。
- 联邦只交换双方共同支持的能力和策略表达式,不降低任一方策略。
## 12. 数据与审计
### 12.1 最小审计事件
- 操作者、调用设备和组织。
- 动作、能力、版本和提供者。
- 路由与是否跨组织。
- 输入数据类别、分类、文件数和字节数。
- 授权结果、策略 ID 和批准者。
- 任务状态、耗时、输出大小和资源用量。
- 包下载、安装、更新、撤销和发布变更。
默认不保存提示正文、文件内容、模型输出、密钥、Cookie、认证头或完整私有路径。需要内容
审计的组织必须单独定义保留期限和访问角色,并在调用前向用户显示。
### 12.2 数据权威
- 桌面端是本地配对、本机共享偏好和本地活动记录的权威。
- ShareServer 是组织成员、设备注册、组织策略、发布目录和组织审计的权威。
- 能力提供者是运行中任务及结果是否产生的权威。
- 联邦两端各自保存本组织审计,不复制对方完整成员目录或内部策略。
## 13. 安全边界
ShareServer 可以部署到公网,因此公网连接、外部组织和服务端多租户是本设计中的实际信任
边界。共同要求如下:
1. 所有远端清单、名称、schema、错误、文件和模型输出都作为不可信数据处理。
2. Renderer 不接触设备私钥、网关令牌、任意 socket 或原始文件传输接口。
3. Main/Provider 在执行边界重新验证任务,不信任 Renderer 或 ShareServer 代替本机授权。
4. ShareServer 按组织隔离查询,搜索和计数接口不能泄露无权查看的设备或能力。
5. 设备只建立主动配置的网关出站连接,不接受 ShareServer 发起任意系统调用。
6. 局域网发现不等于信任,组织成员身份也不等于所有能力授权。
7. 远程能力不能扩大本机 Ask/Execute、Workspace、Runtime、Skill 或 MCP 现有权限。
8. 密钥只进入对应安全存储,诊断和审计使用固定字段与脱敏错误码。
9. 限制握手、清单、帧、文件、并发、速率、任务时长和审计查询大小。
10. 撤销设备、用户、Grant、Publication、Package 或 FederationTrust 后停止签发新令牌。
## 14. 建议领域实体
```text
DeviceIdentity
PeerPairing
GatewayConnection
Organization
Membership
Group
RegisteredDevice
Capability
CapabilityVersion
Publication
Grant
Policy
Approval
Task
TaskAttempt
ArtifactTransfer
Package
PackageRelease
FederationTrust
AuditEvent
Revocation
```
共享协议只定义跨端 DTO 和状态,不规定桌面 SQLite 与 ShareServer 数据库使用相同表结构。
## 15. 仓库边界
目标目录结构:
```text
docs/
architecture/share-network-architecture.md
features/device-sharing/
features/share-server/
packages/
share-protocol/ # 与 Electron、数据库和 Web 框架无关的 schema/类型
shareserver/ # 独立 Web 服务及管理前端
src/ # GoodBuddy Desktop
```
当前文档先固定边界,不创建空服务脚手架。实现时 `shareserver` 不反向依赖桌面端 `src/shared`;
双方依赖独立 `share-protocol`。协议包不得包含数据库模型、Electron API、服务实现或凭据。
## 16. 共同验收标准
- [ ] 不配置 ShareServer 时,两台桌面端可通过局域网发现、双端配对、查看获准能力并调用。
- [ ] 关闭“允许被发现”不会关闭主动发现;关闭主动发现不会撤销既有配对。
- [ ] 本机没有明确 Publication 时,任何 Peer 或组织成员都不能查看或调用本机能力。
- [ ] 桌面端可以连接多个 ShareServer,任一连接故障不影响局域网和其他连接。
- [ ] ShareServer 只展示当前用户、设备和组织获准查看的目录。
- [ ] 每次远程调用都能说明提供者、执行位置、发送数据、路由、授权依据和委托链。
- [ ] 网络中断且副作用结果不确定时进入 `outcome_unknown`,不会自动重放。
- [ ] 能力包经大小、SHA-256、签名、兼容和权限差异校验后才能安装。
- [ ] 跨组织调用同时满足两端策略;联邦撤销后不能创建新任务。
- [ ] 审计默认只保存必要元数据,不保存正文、文件、密钥或认证信息。
- [ ] ShareServer 可独立部署和升级,不由 Electron 启动,也不影响桌面端离线使用。
## 17. 相关设计
- [设备发现与共享 PRD](../features/device-sharing/prd.md)
- [设备发现与共享功能逻辑](../features/device-sharing/logic-design.md)
- [设备发现与共享 UI](../features/device-sharing/ui-design.md)
- [设备发现与共享技术设计](../features/device-sharing/technical-design.md)
- [ShareServer PRD](../features/share-server/prd.md)
- [ShareServer 功能逻辑](../features/share-server/logic-design.md)
- [ShareServer 管理界面](../features/share-server/ui-design.md)
- [ShareServer 技术设计](../features/share-server/technical-design.md)
- [SSH 远程主机与 Agent](../features/remote-host/README.md)
- [GoodBuddy 统一界面设计系统](../../UI-DESIGN.md)
@@ -147,6 +147,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
- 终端 Tab 默认使用递增标题,例如“终端 1”“终端 2”;状态栏持续显示项目、Host、
工作目录和 Shell。用户可以重命名终端。
- 当前 Tab 同时使用连续内容表面、选中边框、顶部强调线、图标和较高字重,不能只靠文字颜色。
- Tab、关闭按钮和“+”入口沿用共享控件圆角;终端面板外框和终端顶部工具栏按钮使用直角,
保持紧凑的停靠式终端界面。
- Tab 关闭按钮默认不争夺视觉注意力,仅在鼠标悬停或键盘焦点进入对应 Tab 时出现;
粗指针或无悬停设备始终显示,出现与隐藏不得引发布局位移。
@@ -237,7 +239,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或
- 真实 TTY 输入与 ANSI 输出。
- 中文输入法、组合字符和常规键盘快捷键。
- 复制、粘贴、全选、搜索、清屏和滚动。
- 复制、粘贴、全选、搜索、清屏和滚动。复制与粘贴通过桌面 Main 进程访问系统文本剪贴板,
不依赖 Renderer 的浏览器剪贴板权限。
- 工作栏宽度或窗口尺寸变化后的 PTY resize。
- 终端重命名。
- 显式关闭、退出后重新打开和远程中断后重新连接。
+29 -15
View File
@@ -6,8 +6,8 @@
| --- | --- |
| 文档类型 | 实施进度 |
| 状态 | 持续更新 |
| 版本 | 0.3 |
| 日期 | 2026-08-18 |
| 版本 | 0.4 |
| 日期 | 2026-09-03 |
| 适用能力 | 内置浏览器与客户端电脑控制 |
## 范围
@@ -17,13 +17,14 @@ Linux x64 和 Linux arm64。
## 已完成
### 直连模型与浏览器
### Runtime 与浏览器
- 已实现宿主进程拥有的隔离浏览器服务。
- 使用 Electron 自带 Chromium 和 CDP,不下载额外浏览器。
- 已实现导航、可访问性快照、点击、输入、选择、返回和有界截图工具。
- 浏览器工具只在直连模型的 Execute 模式中提供;用户选择 Execute 即授权
本次交互运行,不再逐个弹出 GoodBuddy 工具审批。
- 浏览器工具作为 GoodBuddy 内置能力,可分别分配给直连模型、GoodBuddy 管理的
OpenCode 和 Continue;只在 Execute 模式中提供。用户选择 Execute 即授权本次
运行,不再逐个弹出 GoodBuddy 工具审批。
- 支持当前设备可连接的 HTTP、HTTPS、公网、内网、本机和元数据地址,
导航及重定向仍经过 DNS 解析和目标地址固定。
- 已实现回环过滤代理、下载和文件选择器阻止、权限拒绝、会话取消、
@@ -59,11 +60,14 @@ Linux x64 和 Linux arm64。
### 设置与安全边界
- 能力存储已迁移到版本 2。
- 内置浏览器已归入“直连模型”工具,与联网搜索并列,并明确说明不会控制
客户端已安装的浏览器;原有开关和配置 ID 保持不变,用户状态无需迁移。
- 用户通过内置浏览器总开关决定是否向直连模型提供工具;开启后可在 Execute
模式直接使用,不逐次询问。诊断位于同一分类。
- 能力存储已迁移到版本 6。版本 2 至 5 中原有的浏览器启停状态会迁移到
`builtin-browser`,已有启用状态不丢失;默认分配给直连模型、GoodBuddy 管理的
OpenCode 和 Continue。
- 内置浏览器位于“内置 MCP”,与知识库、笔记和 GoodBuddy 配置一样提供独立启停、
Runtime 分配和工具清单;DeepSeek Harness 持续显示为不支持。
- 浏览器开关只控制 Agent 工具访问。关闭后,任何 Runtime 都不会获得浏览器工具,
但用户仍可在浏览器工作栏中手动前往、返回、刷新、停止加载、交互和关闭浏览器。
开启后,已分配 Runtime 可在 Execute 模式直接使用,不逐次询问。
- 尚未实际参与浏览器执行的命名配置已从界面隐藏;底层旧数据和兼容接口继续
保留,不删除用户已有记录。
- “电脑控制”只显示实际操作客户端电脑的能力。能力启用、配置变更和本地
@@ -110,9 +114,9 @@ Linux x64 和 Linux arm64。
“停止加载”中断。停止加载只取消页面加载,不关闭浏览器,也不释放 Runtime 上下文;原“停止
浏览器”会话生命周期操作已统一标为“关闭浏览器”,并在视觉和文案上与停止加载清楚区分。
活动 Conversation 尚无浏览器会话时,可直接从地址栏输入 URL 并“前往”,通过现有浏览器
能力检查和同一 URL 策略创建会话。实现复用现有每 Conversation 串行队列和有界状态,不引入
恢复、版本化或第二套会话框架。
活动 Conversation 尚无浏览器会话时,可直接从地址栏输入 URL 并“前往”,通过浏览器组件
可用性检查和同一 URL 策略创建会话。该手动路径不读取 Agent 浏览器开关。实现复用现有每
Conversation 串行队列和有界状态,不引入恢复、版本化或第二套会话框架。
### P0:百度输入时元素引用失效,已修复
@@ -161,16 +165,26 @@ Linux x64 和 Linux arm64。
## 下一步顺序
1. 在便携版真实模型流程中继续验证返回、取消和右侧画面刷新。
1. 在便携版真实模型流程中继续验证 OpenCode、Continue 的导航、返回、取消和右侧画面刷新。
2. 再设计命名隔离 Profile 的持久登录态、引用和清理策略。
3. 分别实现并真机验证 Windows UIA、macOS AX 和 Linux 原生适配器。
## 最近验证
- `npm test`:591 项通过,6 项跳过。
- `npm test`:3496 项通过,53 项跳过。
- `npm run typecheck`:通过。
- `npm run lint`:通过。
- `npm run build`:通过。
- `GOODBUDDY_RUN_RUNTIME_E2E=1 ... -t "calls the assigned built-in browser
through bundled OpenCode"`:通过,真实模型上报 2 次模型调用,OpenCode 经请求级
GoodBuddy MCP 调用 `browser_navigate` 并保留正确 Conversation 归属。
- `GOODBUDDY_RUN_RUNTIME_E2E=1 ... -t "calls the assigned built-in browser
through bundled Continue"`:通过,Continue 经同一路径调用 `browser_navigate` 并
返回预期结果;当前 Continue Host 为该完整工具轮次上报 1 条聚合模型用量事件。
- 聚焦的能力迁移、网关、IPC、设置界面测试共 234 项通过。其中覆盖 Agent 浏览器
开关关闭时用户手动浏览器操作仍可用。
以上自动化结果覆盖协议、安全边界和模拟驱动,不替代真实 Electron
BrowserWindow/WebContentsView、目标网站和目标 Linux 桌面环境的集成验证。
本轮曾尝试用隔离用户目录启动第二个 Electron 实例,但当前已运行的正式 GoodBuddy
单实例锁阻止了该实例继续运行;未关闭或附着用户正在使用的正式实例。
@@ -254,7 +254,9 @@ Store 是否已经提交为准,确定性完成新目录或恢复旧目录,
| `goodbuddy/native/snapshot` | Main → Control Plane | 从无 Agent scope 的 Host Registry 读取有界的原生 Tool/Skill 元数据,排除 GoodBuddy 分配项与请求级代理 |
| `goodbuddy/shutdown` | Main → Control Plane | 停止接收新请求并有序清理 |
Utility 启动控制协议使用版本 2,严格携带 `supportsImageInput` 与固定 8 MiB 帧上限;版本 1 或缺少该字段的启动消息失败关闭,不能让 Host 自行猜测模型能力。扩展版本独立于 ACP 版本。握手响应至少包含:
Utility 启动控制协议使用版本 3,严格携带 `supportsImageInput`、模型连接的有界自定义
Header 与固定 8 MiB 帧上限;旧版本或缺少必需字段的启动消息失败关闭,不能让 Host
自行猜测模型能力。扩展版本独立于 ACP 版本。握手响应至少包含:
```ts
type GoodBuddyHarnessCapabilities = {
@@ -431,6 +433,9 @@ DeepSeek Harness 首版只使用符合下列边界的 GoodBuddy 模型连接:
- 模型名称不限制为 DeepSeek 品牌,由所选 OpenAI 兼容服务决定。
- 模型名称和服务地址由 Main 传入受控 Host。
- 图片能力只读取所选 GoodBuddy 模型连接的 `supportsImageInput`;Main、Utility 启动配置、ACP 能力和 Pi-AI 模型输入模态必须使用同一个布尔值。
- 模型连接的自定义 Header 通过 Utility 启动配置交给 Pi-AI Provider;Pi-AI 没有受支持的
任意 Body 配置入口,因此 DeepSeek Harness 不接收自定义 Body。完整支持矩阵和合并规则
见[模型连接请求定制](../model-connections/technical-design.md)。
- API Key 继续保存在 GoodBuddy 加密设置中。
- 启动环境提供的部署连接只由 Main 自动解析,不在 Renderer 中显示为可选来源。
- 未显式指定 Harness 模型且没有可用的管理员预置连接时,Main 优先使用当前默认的兼容 GoodBuddy 模型连接;默认连接不兼容时使用首个兼容连接。只有不存在任何兼容连接时才提示用户前往模型或 Runtime 设置。
+50
View File
@@ -0,0 +1,50 @@
# 桌面宠物
桌面宠物是 GoodBuddy 桌面端一个可选的常驻悬浮入口,用于随时发起提问、暂存文件或文字,
并直观查看当前任务状态。桌面宠物不建立新的对话、任务或执行体系:它只是现有 Conversation、
Task 和 Runtime 能力的一个轻量触发面和状态镜面。
## 核心边界
- 桌面宠物**不**新增 Ask/Execute 执行通道,也不在自身窗口内直接展示模型回复。快速发起
和拖放暂存的内容始终交给主窗口现有输入区确认后发送,从不自动发送或自动执行。
- 桌面宠物**不**持续读取屏幕内容、剪贴板或全局选中文本。
- 桌面宠物显示的任务状态、通知和历史记录均来自现有 Task/Conversation 数据,不建立第二套
存储或索引。
## 文档导航
| 文档 | 权威职责 |
| --- | --- |
| [PRD](./prd.md) | 用户问题、范围、功能需求和产品验收 |
| [User Stories](./user-stories.md) | 首次启用、正常使用、失败恢复、兼容和边界场景 |
| [功能逻辑](./logic-design.md) | 不变量、状态维度、决策表和失败结果 |
| [UI 设计](./ui-design.md) | 窗口交互、设置入口、状态呈现、无障碍和响应式行为 |
| [技术设计](./technical-design.md) | 窗口与进程边界、IPC 边界、持久化、生命周期和验证策略 |
功能尚未实施,因此不创建 `progress.md`;实施开始后按 [`docs/features/AGENTS.md`](../AGENTS.md)
的证据要求补充。
## 术语
- **桌面宠物窗口**:应用启用该功能后创建的常驻、无边框、可置顶悬浮窗口。
- **快速发起**:点击宠物打开的小型输入框,用于输入文字或附加单个文件,提交后转入主窗口。
- **暂存**:快速发起或拖放产生的文字和附件在提交前的中间状态,不代表已发送或已执行。
- **状态镜面**:宠物窗口对当前任务状态(等待处理、执行中、已完成、失败、空闲)的展示,
数据权威来自 Task 与 Conversation,不是独立状态源。
- **安静模式**:抑制宠物完成/失败气泡提示的设置,可访问的状态查询不受影响。
## 相关功能
- [Task 与 Job 文档集](../task-and-job/README.md):任务状态、状态优先级和 Task Center
导航的权威定义,宠物的状态镜面必须与其保持一致,不重新定义任务生命周期。
- [助手工作栏](../assistant-workbar/README.md):主窗口执行空间和输入区的权威定义,宠物的
快速发起和拖放暂存最终落地到该输入区,不复制其行为。
- [直连模型 Agent 能力](../direct-model-agent/README.md)、[远程主机与远程执行](../remote-host/README.md):
Ask/Execute 授权边界的权威定义,宠物不扩大或绕过该边界。
- [文档处理](../document-processing/README.md):拖放文件解析和格式支持的权威定义,宠物拖放
暂存复用同一处理管线,不重新定义支持类型。
- 根 [`UI-DESIGN.md`](../../../UI-DESIGN.md):全局视觉、通知视口、Switch/Checkbox 语义和
无障碍规则的权威定义。
- [`docs/development/release-runbook.md`](../../development/release-runbook.md):跨平台打包
矩阵和交付物的权威定义。
+203
View File
@@ -0,0 +1,203 @@
# 桌面宠物功能逻辑设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [桌面宠物 PRD](./prd.md) |
| User Stories | [桌面宠物 User Stories](./user-stories.md) |
| 任务模型 | [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) |
本文负责桌面宠物自身的状态、不变量和决策规则,不重新定义 Task、Conversation、Ask/
Execute 或附件的既有语义;这些语义以各自权威文档为准。
## 1. 核心不变量
### INV-1 宠物不是第二条执行通道
快速起草和拖放暂存只产生主窗口会话输入区的草稿数据(文本、附件引用),不产生消息、
不产生 Task、不触发模型调用。发送、模式选择和 Runtime 选择继续完全由主窗口既有流程
拥有。
### INV-2 状态镜面是既有状态的投影
宠物状态镜面的取值只能从当前 Task 集合的既有状态推导,不允许引入宠物专属的任务状态
字段或独立于 Task Center 的状态存储。宠物状态与 Task Center 展示的状态出现分歧即视为
缺陷。
### INV-3 总开关是唯一的存在性开关
“启用桌面宠物”关闭时,宠物窗口、气泡和托盘条目文案必须整体消失;除该开关外,任何
子设置(外观、位置、安静模式)的变化都不得单独创建或销毁宠物窗口。
### INV-4 暂时隐藏不影响启用状态
“暂时隐藏”只影响窗口可见性,不修改总开关持久化值,也不清除位置、外观或安静模式设置。
### INV-5 提示与原生通知互斥
同一个任务终态事件(完成或失败)只能产生宠物气泡或现有原生系统通知中的一种,不产生
两种同时可见的独立提示。
### INV-6 位置始终落在可用显示器内
宠物窗口的持久化位置在应用于当前显示器拓扑之前必须被校验;不能得出宠物窗口完全位于
任何当前可见显示器之外的最终坐标。
### INV-7 拖放与起草共享暂存规则
拖放产生的文本和文件必须满足与快速起草附件相同的类型、大小、数量限制;两者不允许存在
两套互不一致的限制值。
## 2. 状态维度
### 2.1 总开关与可见性
```text
disabled -> enabled_visible
enabled_visible <-> enabled_hidden (暂时隐藏 / 重新显示)
enabled_visible|enabled_hidden -> disabled (关闭总开关)
```
`disabled` 是唯一没有宠物窗口存在的状态;`enabled_hidden` 窗口不可见但设置全部保留。
### 2.2 快速起草弹出框
```text
closed -> drafting -> submitted | discarded
drafting -> closed (Escape / 点击外部 / 关闭按钮)
```
`submitted` 是终态转换动作,不是持续状态:提交后弹出框立即回到 `closed`,草稿数据已经
转交给主窗口。
### 2.3 拖放交互
```text
idle -> drag_over -> dropped_accepted | dropped_rejected | drag_cancelled
```
`drag_over` 只影响视觉反馈,不产生任何持久数据;只有 `dropped_accepted` 才产生暂存
数据。`dropped_rejected` 用于文件夹或全部文件超限的场景,不产生暂存也不打开主窗口。
### 2.4 状态镜面
```text
unavailable | idle | completed_recent | running | waiting_approval | failed_recent
```
取值由当前活跃与最近终态 Task 集合计算得出,属于派生状态,不单独持久化。
### 2.5 提示气泡
```text
none -> pending -> shown -> dismissed
```
`pending` 表示事件已发生但由于主窗口可见或安静模式而被抑制,不进入 `shown`。
## 3. 状态镜面优先级
宠物在任意时刻只呈现一个状态镜面取值。存在多个活跃 Task 时按以下优先级取最高项:
| 优先级 | 状态 | 条件 |
| --- | --- | --- |
| 1 | `unavailable` | 用于确认状态的 Runtime 或网络当前不可达 |
| 2 | `waiting_approval` | 至少一个 Task 处于等待批准 |
| 3 | `failed_recent` | 至少一个 Task 在最近的“最近失败”窗口内失败,且用户尚未查看 |
| 4 | `running` | 至少一个 Task 处于执行中 |
| 5 | `completed_recent` | 至少一个 Task 在最近的“最近完成”窗口内完成,且用户尚未查看 |
| 6 | `idle` | 以上条件都不满足 |
“最近失败”“最近完成”窗口的具体时长和“已查看”判定属于技术选择,由
[技术设计](./technical-design.md)基于既有 Task 已读/未读语义确定,不在本文固定具体
数值。
## 4. 提示归并决策表
| 事件 | 主窗口状态 | 安静模式 | 结果 |
| --- | --- | --- | --- |
| 任务完成 | 不可见或未聚焦 | 关闭 | 显示宠物气泡;不触发原生通知 |
| 任务完成 | 不可见或未聚焦 | 开启 | 不显示气泡;不触发原生通知;仅更新状态镜面 |
| 任务完成 | 可见并聚焦 | 任意 | 不显示气泡;反馈通过主窗口既有机制呈现 |
| 任务失败 | 不可见或未聚焦 | 关闭 | 显示宠物气泡并保持可见直到用户查看或关闭 |
| 任务失败 | 不可见或未聚焦 | 开启 | 不显示气泡;仅更新状态镜面为 `failed_recent` |
| 任务失败 | 可见并聚焦 | 任意 | 不显示气泡;反馈通过主窗口既有机制呈现 |
“主窗口可见并聚焦”与现有 [`desktop-notification.ts`](../../../src/main/desktop-notification.ts)
判定原生通知是否触发所使用的条件保持一致,避免宠物和原生通知各自使用不同的可见性
判定导致同一事件在两处呈现或同时缺失。
## 5. 快速起草与拖放的目标会话决策表
| 场景 | 目标会话 |
| --- | --- |
| 主窗口存在当前活跃会话,用户未在弹出框内更改目标 | 该活跃会话 |
| 用户在弹出框内选择“新建对话” | 提交时新建并激活一个会话 |
| 主窗口不存在任何会话 | 提交时新建并激活一个会话 |
| 目标会话在提交前被用户在主窗口中删除 | 视为“不存在任何会话”,回退到新建会话 |
拖放没有目标切换界面,始终采用与快速起草相同的默认目标会话规则。
## 6. 附件与文本限制
快速起草和拖放暂存复用现有聊天附件规则,不定义新的限制数值:
- 单条草稿的附件数量上限、单个附件大小上限、支持的文本与图片类型,与主窗口聊天附件
的现有限制一致。
- 拖放同时包含多个文件时,逐个按上述规则校验;不合规文件被拒绝,不影响合规文件继续
暂存。
- 拖放文件夹被整体拒绝,不做递归展开或部分接受。
## 7. 位置与显示器校验
1. 加载持久化位置后,检查该坐标是否位于当前任一可见显示器的可用区域内。
2. 位于范围内:直接使用。
3. 不在范围内(例如记忆的显示器已断开):改用主显示器上一个默认可见位置,并在下一次
用户拖动宠物后覆盖持久化位置。
4. 开启“贴边”时,拖动释放位置与最近边缘的距离小于吸附阈值即吸附到该边缘;阈值和默认
位置的具体数值属于技术选择。
## 8. 失败与恢复
| 场景 | 行为 |
| --- | --- |
| 主窗口进程尚未创建或已被销毁 | 提交快速起草或拖放时先创建/恢复主窗口,再写入草稿 |
| 写入草稿时主窗口窗口对象不可用 | 提交失败,弹出框保留草稿内容供用户重试,不静默丢弃 |
| 状态查询所需的 Runtime/网络不可达 | 状态镜面显示 `unavailable`,不回退显示 `idle` |
| 显示器拓扑变化导致位置越界 | 按第 7 节回退到默认可见位置,不隐藏窗口 |
| 用户在气泡展示期间关闭总开关 | 气泡与宠物窗口一并立即消失 |
| 应用启动时总开关为开启但窗口创建失败 | 记录失败原因,不阻塞主窗口正常启动,托盘菜单提供重试入口 |
## 9. 需求追踪
| 需求 | 主要逻辑 |
| --- | --- |
| `FR-1` | `INV-3`、总开关与可见性状态 |
| `FR-2` | 位置与显示器校验、`INV-6` |
| `FR-3` | `INV-1`、快速起草状态、目标会话决策表 |
| `FR-4` | `INV-1`、`INV-7`、拖放交互状态、附件与文本限制 |
| `FR-5` | `INV-2`、状态镜面优先级 |
| `FR-6` | `INV-5`、提示归并决策表 |
| `FR-7` | 提示归并决策表中安静模式列 |
| `FR-8` | 位置与显示器校验之外的技术选择,逻辑仅约束持久化立即生效 |
| `FR-9` | `INV-4`、总开关与可见性状态 |
| `FR-10` | 快速起草状态中的键盘转换,具体按键映射见 UI 设计 |
| `FR-11` | `INV-1`、`INV-2`、`INV-7` |
## 10. 逻辑完整性评估
本设计覆盖了启用/关闭、暂时隐藏、快速起草、拖放(含超限和文件夹)、状态镜面优先级、
提示与原生通知的归并、位置越界恢复、以及主窗口不可用时的失败路径。
未在本文固定、留给技术设计选择的实现细节:
- “最近完成/最近失败”窗口的具体时长和“已查看”判定的确切事件来源。
- 贴边吸附阈值和默认回退位置的具体像素值。
- 状态查询的具体轮询或事件驱动机制。
这些是技术选择而非未解决的产品决策:本文的优先级和决策表已经足以在任何取值下保持
行为一致。
+158
View File
@@ -0,0 +1,158 @@
# 桌面宠物 PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 适用产品 | GoodBuddy Desktop |
| 关联文档 | [功能逻辑](./logic-design.md)、[UI 设计](./ui-design.md)、[技术设计](./technical-design.md) |
## 1. 背景
GoodBuddy 当前只能通过主窗口发起提问、查看任务和管理执行空间。用户想要快速提问、丢一个
文件进去,或者只是看一眼任务有没有跑完时,仍必须先唤出并聚焦整个主窗口。这在多任务、多
显示器场景下打断当前工作流。
桌面宠物提供一个常驻、体积小、不抢焦点的悬浮入口,承担三类高频、轻量的动作:快速起草
一个问题、拖放文件或文字暂存、以及查看当前任务状态。它不是新的执行通道,只是现有
Conversation、Task 和 Runtime 能力上的一层轻量触发面和状态镜面。
## 2. 产品目标
1. 用户无需先打开或聚焦主窗口即可起草一条消息或暂存一个文件。
2. 用户可以在不打开 Task Center 的情况下,一眼看到当前是否有任务在跑、等待处理或失败。
3. 桌面宠物默认不产生新的干扰:不主动弹窗抢焦点,不重复现有原生通知。
4. 桌面宠物的开关、位置、外观和提示强度完全由用户控制,且可随时完全关闭并恢复到没有该
功能之前的界面状态。
5. 桌面宠物不新增或绕过 Ask/Execute 授权边界,也不建立第二条消息发送或任务执行路径。
## 3. 功能范围
### 3.1 包含
- 设置中的“桌面宠物”一级分类,包含总开关、外观、位置和提示设置。
- 常驻悬浮窗口:可拖动、跨显示器记忆位置、贴边、置顶开关。
- 点击宠物打开的“快速起草”弹出框:输入文字、附加至多一个本机文件,提交后转入主窗口
对应会话的输入区草稿,不自动发送。
- 拖放文字或文件到宠物窗口,效果与快速起草的附件路径一致。
- 状态镜面:显示当前活跃任务的汇总状态,点击后打开 Task Center 中对应任务。
- 完成/失败气泡提示,及独立于原生通知的安静模式开关。
- 从有限的内置角色/外观中选择,调整尺寸、透明度和动画强度。
- 全局显示/隐藏开关,托盘菜单入口,键盘可达的等效操作路径。
### 3.2 不包含
- 在宠物窗口内直接展示模型回复、执行工具调用或建立第二条 Ask/Execute 通道。
- 未经用户确认自动发送消息或自动执行任务。
- 持续读取屏幕内容、剪贴板或系统级选中文本。
- 用户上传任意自定义头像图片、养成机制、货币或每日签到。
- 多个宠物实例、宠物之间互动或联网社交功能。
- 管理员远程强制启用、配置或监控用户的桌面宠物。
- 替代 Task Center、助手工作栏或现有通知系统的权威数据来源。
## 4. 功能需求
### FR-1 总开关与默认状态
- 设置新增“桌面宠物”一级分类,包含“启用桌面宠物”总开关。
- 全新安装和现有用户升级后默认关闭,不在未经用户操作时出现在桌面上。
- 关闭总开关立即隐藏宠物窗口,保留用户此前的位置、外观和提示设置以便重新开启。
### FR-2 常驻悬浮窗口
- 启用后创建一个无边框、默认置顶、不出现在任务栏的小型窗口。
- 用户可拖动到任意位置;位置按显示器分别记忆,显示器断开重连后回退到最近的可用显示器。
- 提供“贴边”选项,拖动到屏幕边缘附近时自动吸附。
- 提供独立的“始终置顶”开关;关闭后宠物仍可见,但可被其他窗口遮挡。
- 宠物窗口不接受系统级最大化、最小化或调整大小操作。
### FR-3 快速起草
- 点击宠物打开一个紧邻宠物的小型弹出框,包含文本输入和“附加文件”按钮。
- 文本输入支持多行、粘贴图片;附加文件复用现有附件类型、数量和大小限制。
- 弹出框显示将要进入的目标:当前活跃会话,或“新建对话”。用户可在弹出框内切换目标,
但不能在弹出框内选择模型、Runtime 或 Ask/Execute 模式。
- 提交后主窗口被显示或聚焦,目标会话被激活,文字和附件作为草稿填入该会话的输入区,
输入焦点落在该输入区。提交动作本身不发送消息、不创建任务。
- 取消或关闭弹出框放弃当前草稿,不影响主窗口任何已有草稿。
### FR-4 拖放暂存
- 用户可以把本机文件或选中文字拖到宠物窗口上。
- 拖入时宠物给出可放置的视觉反馈;释放后遵循与快速起草提交相同的行为:显示主窗口、
激活目标会话、把内容放入该会话输入区草稿。
- 拖放文件的类型、数量、大小限制与现有聊天附件限制一致;超出限制时明确提示被拒绝的
文件,其余合法文件仍正常暂存。
- 拖放不支持文件夹;文件夹被拖入时提示不支持,不递归展开。
### FR-5 状态镜面
- 宠物在空闲、执行中、等待处理、已完成、失败之间切换外观,反映当前所有活跃 Task 的
汇总状态;多个状态并存时按“等待处理 > 失败 > 执行中 > 已完成 > 空闲”的优先级显示。
- 点击状态镜面打开或聚焦主窗口的 Task Center,并定位到触发当前状态的任务。
- Runtime 或网络不可用导致任务状态无法确认时,状态镜面显示“状态不可用”而不是回退成
“空闲”。
### FR-6 完成与失败提示
- 任务从执行中或等待处理转为已完成或失败,且主窗口不可见或未聚焦时,宠物旁显示一条
气泡,说明任务标题和结果。
- 气泡在用户不操作时于一段时间后自动消失;失败气泡保持可见直到用户查看或手动关闭。
- 点击气泡等价于点击状态镜面,打开对应任务详情。
- 同一事件只能触发桌面宠物气泡或现有原生系统通知中的一种,不同时出现两条提示;具体
归并规则由[功能逻辑](./logic-design.md)定义。
### FR-7 安静模式
- 独立于总开关的“安静模式”开关,开启后不再显示完成/失败气泡。
- 安静模式不影响状态镜面本身的外观切换,也不影响原生系统通知的现有行为。
### FR-8 外观与个性化
- 提供数量有限的内置角色供选择,不支持上传自定义图片。
- 提供尺寸、透明度和动画强度调整;调整立即生效并持久化。
- 提供“恢复默认外观”操作。
### FR-9 显示、隐藏与退出
- 提供独立于总开关的“暂时隐藏”动作:隐藏宠物窗口但保留启用状态,可通过托盘菜单或
设置重新显示。
- 托盘菜单新增“显示/隐藏桌面宠物”入口,随当前可见性变化文案。
- 应用退出时宠物窗口一并关闭,不残留独立进程。
### FR-10 键盘可达性
- 宠物窗口本身可通过系统级窗口切换(如 Alt+Tab)取得焦点,取得焦点后可用键盘打开快速
起草弹出框、在状态镜面和提示之间移动,并可用 Escape 关闭弹出框。
- 所有交互提供可访问名称和状态说明,不仅依赖动画或颜色表达状态。
### FR-11 与现有能力的边界
- 快速起草和拖放暂存最终使用主窗口现有的会话、附件、模型和 Ask/Execute 选择逻辑,不
新增第二套发送或授权路径。
- 状态镜面和气泡的任务状态数据来自现有 Task 与 Conversation 记录,不建立独立存储、不
与 Task Center 的状态定义产生分歧。
- 桌面宠物不改变现有全局快捷键、托盘菜单既有条目或主窗口默认行为。
## 5. 产品验收
- 全新安装和已升级用户默认看不到桌面宠物;显式启用后立即出现在预期位置。
- 关闭总开关后桌面上不再有任何宠物窗口或残留提示,重新开启后恢复此前的位置和外观。
- 通过快速起草或拖放提交的内容,在主窗口对应会话的输入区中可见为未发送草稿,用户需要
自行确认发送;关闭 GoodBuddy 或取消草稿不会产生任何任务或消息记录。
- 状态镜面呈现的状态与 Task Center 中对应任务的状态一致;两者出现分歧即视为缺陷。
- 同一任务完成或失败事件不会同时产生宠物气泡和原生系统通知。
- 安静模式开启时不出现气泡,但状态镜面外观仍随任务状态变化。
- 键盘用户无需鼠标即可打开快速起草、查看状态并关闭弹出框。
- 中英文文案、深浅主题、多显示器和 DPI 缩放下宠物窗口位置和内容均正确显示。
## 6. 明确限制
- 桌面宠物不提供在其自身窗口内完成整段对话或查看历史消息的能力;查看完整回复必须进入
主窗口。
- 桌面宠物不支持多实例、多角色同屏或角色之间的模拟互动。
- 透明背景效果依赖操作系统合成器;在不支持透明合成的 Linux 桌面环境下,宠物窗口可能
退化为不透明的小型窗口,具体行为见[技术设计](./technical-design.md)。
@@ -0,0 +1,335 @@
# 桌面宠物技术设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [桌面宠物 PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| UI 设计 | [UI 设计](./ui-design.md) |
## 1. 进程与窗口边界
```text
Main
-> createMainWindow() (既有,src/main/window.ts)
-> createPetWindow() (新增)
-> 独立 BrowserWindow:无边框、透明、置顶、不入任务栏
-> 独立 preload:src/preload/pet.ts(新增,窄 API)
-> 加载独立渲染入口:src/renderer/pet.html + src/renderer/src/pet/
Renderer(主窗口,既有)
-> window.goodbuddy(既有 DesktopApi,不变)
Renderer(宠物窗口,新增)
-> window.goodbuddyPet(新增窄 API,与既有 window.goodbuddy 完全隔离)
```
宠物窗口是与主窗口平级的第二个 `BrowserWindow`,不是主窗口的子窗口、不是 `WebContentsView`
叠加层。理由:
- 主窗口现有 `setWindowOpenHandler` 拒绝一切子窗口(`src/main/window.ts`),沿用该策略
比引入例外更简单。
- 宠物窗口需要独立于主窗口的显示/隐藏、位置和置顶状态,作为子窗口会继承父窗口的显示
状态,不满足“主窗口隐藏时宠物仍可见”的产品要求。
不复用主窗口的 preload 和渲染入口:主窗口 preload 暴露的 `DesktopApi` 面积很大(对话、
知识库、设备共享等)。给宠物窗口加载同一 preload 会让一个新增的、长期运行、暴露给
远程可控网页内容风险更低但攻击面更广的窗口拥有不必要的能力。新增窄 preload 遵循仓库
现有的“只暴露显式方法”约束。
## 2. 模块职责
| 模块 | 位置 | 职责 |
| --- | --- | --- |
| `PetWindowService` | `src/main/pet/pet-window-service.ts`(新增) | 创建/销毁宠物窗口、位置持久化与越界校验、置顶与贴边、显示/隐藏 |
| `PetSettingsStore` | `src/main/pet/pet-settings-store.ts`(新增) | 总开关、外观、位置、安静模式的持久化,遵循既有 settings-file-utils 模式 |
| `PetStatusProjector` | `src/main/pet/pet-status-projector.ts`(新增) | 从既有 Task 查询结果计算状态镜面取值,不引入新状态源 |
| `PetDraftBridge` | `src/main/pet/pet-draft-bridge.ts`(新增) | 把宠物提交的文本/附件转交主窗口渲染进程,驱动会话激活与草稿写入 |
| `PetNotificationCoordinator` | `src/main/pet/pet-notification-coordinator.ts`(新增) | 与既有 `desktop-notification.ts` 协商同一事件只走一条提示路径 |
| 既有 `context-manager.ts` | 不变 | 宠物附件校验复用其 `selectFiles`/校验规则,不新增第二套限制 |
| 既有 `assistant-database.ts` / Task 查询 | 不变 | 状态镜面查询的唯一数据来源 |
`PetStatusProjector` 只读取既有 Task 查询结果并按
[功能逻辑第 3 节](./logic-design.md#3-状态镜面优先级)的优先级归并,不写入、不缓存跨进程
重启的状态;重启后状态镜面从当前 Task 集合重新计算。
## 3. 宠物窗口创建
```ts
// src/main/pet/pet-window-service.ts(示意)
function createPetWindow(): BrowserWindow {
return new BrowserWindow({
width: PET_SIZE.width,
height: PET_SIZE.height,
show: false,
frame: false,
transparent: true,
resizable: false,
skipTaskbar: true,
alwaysOnTop: true, // 由“始终置顶”设置决定实际值
hasShadow: false,
backgroundColor: '#00000000',
webPreferences: {
preload: join(currentDirectory, '../preload/pet.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
}
```
- `transparent: true` 依赖操作系统合成器。按 PRD 6 节明确限制,在不支持透明合成的
Linux 桌面环境(例如未运行合成器的部分窗口管理器)下,Electron/Chromium 会把窗口
渲染为不透明;`PetWindowService` 不检测合成器能力,只在渲染进程内把背景色设置为
当前主题的画布色作为不透明回退,保证内容仍然可读,不尝试探测或声明支持特定合成器。
- `alwaysOnTop` 的实际值由“始终置顶”Switch 驱动,通过 `window.setAlwaysOnTop(bool)`
在设置变更时更新,不需要重建窗口。
- 复用既有 `resolveWindowIcon`/任务栏排除逻辑作为参考,但宠物窗口 `skipTaskbar: true`
固定为真,不提供用户设置。
- 宠物窗口不设置 `parent`,与主窗口是同级独立窗口;应用单实例锁和 `second-instance`
处理(`src/main/index.ts`)不需要改动,因为宠物窗口的存在与否由 `PetWindowService`
内部状态决定,而不是第二个 Electron 实例。
## 4. 拖放实现
Electron 渲染进程原生支持 HTML5 拖放;宠物渲染进程监听 `dragenter`/`dragover`/`drop`
即可获得视觉反馈和释放事件,不需要 Main 参与拖放事件本身。文件路径解析复用既有
`webUtils.getPathForFile`(已用于 `src/preload/index.ts` 的知识库拖放导入):
```ts
// src/preload/pet.ts(示意)
import { contextBridge, ipcRenderer, webUtils } from 'electron'
contextBridge.exposeInMainWorld('goodbuddyPet', {
submitDraft: (input: PetDraftSubmission) =>
ipcRenderer.invoke(petIpcChannels.submitDraft, input),
resolveDroppedPaths: (files: File[]) =>
files.map((file) => webUtils.getPathForFile(file)).filter(Boolean),
// ...状态订阅、设置读写等方法
})
```
拖入的文本(非文件)通过 `dataTransfer.getData('text/plain')` 直接读取,随普通文本
字段一起提交,不经过路径解析。
宠物渲染进程把解析出的路径和文本打包为 `PetDraftSubmission`,通过 IPC 交给 Main;Main
侧的路径校验、类型判断、大小限制和读取,全部复用既有 `ContextManager` 的规则(`src/main/context-manager.ts`
中的 `supportedExtensions`、`supportedImageExtensions`、`supportedDocumentExtensions`、
`maximumContextBytes`、`maximumAttachmentsPerMessage`),不新增校验逻辑。`ContextManager`
目前的公开入口是围绕原生对话框(`selectFiles`)设计的;实现时需要新增一个接受任意
canonical 路径数组的方法(例如 `stageFromPaths`),内部复用现有扩展名/大小/文档解析
分支,而不是复制一份判断逻辑。文件夹路径(`fs.stat` 判定为目录)在该方法入口即被拒绝,
不递归遍历。
## 5. 快速起草提交与主窗口联动
```text
宠物渲染进程 submitDraft(input)
-> Main: PetDraftBridge.handleSubmit(input)
-> ContextManager.stageFromPaths(input.filePaths) (复用既有校验)
-> 若主窗口不存在:createMainWindow() + loadMainWindow()(复用既有函数)
-> showWindow(mainWindow)(既有函数)
-> mainWindow.webContents.send(petIpcChannels.draftReceived, {
targetConversationId, text, stagedContextIds
})
-> 主窗口渲染进程(App.tsx 新增一个事件监听,与既有 conversation:new 监听模式一致)
-> 若 targetConversationId 无效或已删除:回退为新建会话(既有新建会话路径)
-> 激活目标会话、把 text 写入该会话输入框状态、把 stagedContextIds 追加到该
会话的附件状态(既有 App.tsx 附件状态更新路径)
-> 聚焦输入框
```
- 草稿只在主窗口渲染进程内变成“输入框状态”和“待发送附件列表”,与用户手动打字/手动
点击附件按钮产生的状态是同一份 React state,不建立并行的草稿存储。这直接满足
`INV-1`:宠物不产生消息、不产生 Task。
- `stagedContextIds` 复用现有 `context:*` IPC 产生的 Context ID;主窗口把这些 ID 加入
附件列表的方式,与用户通过“附加文件”按钮选择文件后的状态更新完全一致。
- 若提交发生时主窗口曾被销毁(用户从未打开过,或者提前研究表明当前架构不销毁主窗口
只隐藏),`PetDraftBridge` 调用既有 `createMainWindow`/`loadMainWindow`,等待
`ready-to-show` 完成后再发送 `draftReceived`,避免竞态。
## 6. IPC 边界
新增独立 IPC 通道命名空间 `pet:*`,与既有 `ipcChannels` 并列定义在
`src/shared/ipc-channels.ts`:
```text
pet:get-snapshot (设置 + 当前状态镜面取值 + 可见性)
pet:update-settings (总开关、外观、贴边、置顶、安静模式)
pet:submit-draft (快速起草/拖放提交)
pet:dismiss-bubble (关闭完成/失败气泡)
pet:open-task (点击状态镜面/气泡,打开 Task Center 对应任务)
pet:toggle-visibility (托盘“隐藏/显示桌面宠物”)
```
- 所有输入使用 `src/shared/pet-contracts.ts`(新增)中的 Zod schema 校验,与既有约定
一致。
- `trusted-ipc-sender.ts` 的 `assertTrustedSender` 按窗口实例参数化使用;宠物窗口的
IPC handler 校验 `event.sender === petWindow.webContents`,不能用主窗口的 trusted
sender 校验函数验证宠物窗口的请求,也不能反过来。
- 宠物 preload 不暴露任何既有 `DesktopApi` 方法;需要的能力(提交草稿、读取/写入宠物
设置、订阅状态镜面)都通过 `pet:*` 通道单独暴露为 `window.goodbuddyPet` 的具体方法。
- Main 侧广播状态镜面变化时,只向宠物窗口发送派生后的枚举值(第 3 节的六种状态之一)
和最小必要的任务标题/ID,不发送完整 Task 或 Conversation 对象。
## 7. 持久化
新增设置文件,遵循既有 `settings-file-utils.ts` 的原子写入、版本校验和损坏隔离模式,
不复用 `ApplicationSettingsStore` 或 `ShortcutSettingsStore` 的 schema:
```text
pet-settings.json
{
version: 1,
enabled: boolean,
quietMode: boolean,
alwaysOnTop: boolean,
snapToEdge: boolean,
appearance: { character: string, size: 'small'|'medium'|'large',
opacity: number, motion: 'off'|'reduced'|'full' },
position: { displayId: number, x: number, y: number } | null
}
```
- `position` 为 `null` 表示尚未被用户拖动过,使用默认位置计算规则(第 8 节)。
- `displayId` 使用 Electron `screen.getAllDisplays()` 返回的 `id`;加载时按第 8 节校验,
不假设跨重启保持稳定的显示器 id 一定仍然存在。
- 独立 schema 版本号(从 1 开始),与主设置版本号互不影响;未来变更只需要迁移这一个
文件。
- 是否需要独立数据库表:不需要。宠物没有需要查询、筛选或跨设备同步的历史数据,一个
JSON 文件足够,符合仓库 KISS/YAGNI 约束。
## 8. 位置与多显示器校验
```ts
function resolvePetPosition(
saved: PetPosition | null,
displays: Display[]
): { x: number; y: number } {
if (saved) {
const display = displays.find((d) => d.id === saved.displayId)
if (display && pointWithinWorkArea(saved, display.workArea)) {
return saved
}
}
const primary = screen.getPrimaryDisplay()
return defaultPositionWithin(primary.workArea) // 例如右下角,留出安全边距
}
```
- 该函数在宠物窗口创建时以及 `display-added`/`display-removed`/`display-metrics-changed`
事件触发时调用;越界立即调用 `window.setPosition` 校正,不等待用户下一次拖动。
- 用户拖动结束(`moved` 事件)时把当前 `getBounds()` 和 `screen.getDisplayNearestPoint`
得到的 `displayId` 写入 `PetSettingsStore`,去抖后原子写入,避免拖动过程中高频写盘。
- 贴边吸附在渲染进程拖动过程中做视觉预判(不移动实际窗口,只显示吸附提示),在
`moved` 事件的最终位置上由 Main 一次性计算是否落入吸附阈值并调用 `setPosition`
吸附到边缘,阈值和默认位置的具体像素值作为实现细节调优,不在此固定。
## 9. 状态镜面查询
`PetStatusProjector` 不新增事件源,而是订阅既有 Task 变化通知(主窗口渲染进程已经
用于更新 Task Center 和会话内任务卡片的同一套 Main → Renderer 事件)在 Main 侧的
对应版本,或者在 Main 侧直接查询 `assistant-database.ts` 暴露的 Task 读取方法。具体
选择两者之一属于实现阶段的技术选择,约束条件是:
- 不引入宠物专属的任务状态表或缓存;查询/订阅的结果必须与 Task Center 在同一时刻
展示的状态一致。
- 查询开销必须是有界的(只统计活跃与最近终态 Task 的数量和状态,不拉取消息正文)。
- Runtime/网络不可达导致查询失败时返回 `unavailable`,不静默返回 `idle`。
“最近完成”“最近失败”窗口时长和“已查看”判定:复用 Task 已有的已读/查看时间戳字段
(若既有模型已提供);若当前模型没有这类字段,实现时以“该 Task 终态发生后,用户是否
已经点击过对应的宠物气泡或状态镜面,或已经在 Task Center 中打开过该 Task”作为已查看
判定,窗口时长建议与现有原生通知的展示逻辑保持同量级(分钟级),具体数值在实现时
根据既有 Task 事件粒度确定。
## 10. 提示与原生通知协商
`PetNotificationCoordinator` 与 `desktop-notification.ts` 的 `showDesktopNotificationWhenUnfocused`
共享同一个“主窗口是否可见并聚焦”判定:
```ts
function shouldSuppressBecauseMainWindowVisible(mainWindow: BrowserWindow): boolean {
return !mainWindow.isDestroyed() && mainWindow.isFocused()
}
```
- 现有 `showDesktopNotificationWhenUnfocused` 已经使用等价条件(`window.isFocused()`)
决定是否弹出原生通知;`PetNotificationCoordinator` 复用同一判定函数(如需要,提取为
共享工具),保证两者的“可见性”定义永远一致,不出现原生通知判定为可见但宠物判定为
不可见(或反之)导致同一事件同时出现两条提示或完全没有提示的情况。
- 实现层面:调用触发通知的代码路径在决定“原生通知 vs 宠物气泡”时二选一,而不是
两者分别独立判断后都可能触发;例如由现有触发点先判断安静模式和宠物启用状态,
为真则调用 `PetNotificationCoordinator`,否则调用现有 `showDesktopNotificationWhenUnfocused`。
- 安静模式开启时,两者都不触发;状态镜面仍照常更新(状态镜面更新走独立的
`pet:get-snapshot`/推送通道,不经过通知协商)。
## 11. 与既有模块集成
- 快速起草和拖放最终只调用主窗口渲染进程已有的“设置输入框文本”“追加附件”“激活
会话”“创建新会话”状态更新函数,不新建与 Ask/Execute、模型选择、Runtime 选择相关
的任何路径。
- 点击状态镜面/气泡打开 Task Center 复用现有 Task Center 导航(既有 IPC/路由,与托盘
“新建对话”发送 `conversation:new` 事件属于同一类模式:Main 发送一个事件,主窗口
渲染进程消费并完成导航)。
- 托盘菜单新增项复用现有 `Menu.buildFromTemplate` 结构(`src/main/index.ts` 的
`buildTray`),插入一个受当前可见性状态驱动的条目,不重写整个菜单构建函数。
- 全局快捷键(`src/shared/shortcut.ts`、`shortcut-settings-service.ts`)不受影响,
桌面宠物不注册新的全局快捷键,因为激活方式是点击窗口本身或系统级窗口切换,不需要
额外抢占全局按键空间。
## 12. 生命周期
- 应用启动读取 `PetSettingsStore`;`enabled: true` 时在既有服务和 IPC 注册完成后创建
宠物窗口(与主窗口创建顺序一致,不阻塞主窗口 `ready-to-show`)。
- 宠物窗口创建失败(例如极端情况下操作系统拒绝创建透明窗口):记录原因,不阻塞主窗口
启动,托盘“显示桌面宠物”下次点击时重试创建。
- 应用退出(`before-quit`/既有 `isQuitting` 标记)时,宠物窗口与主窗口一并销毁,不需要
独立的关闭确认或延迟清理,因为宠物窗口没有正在进行的网络连接或未保存状态:草稿已经
转交主窗口的那一刻起就属于主窗口生命周期。
- “暂时隐藏”只调用 `window.hide()`,不销毁窗口实例,重新显示直接 `showWindow` 复用
既有实例,避免频繁创建/销毁透明窗口带来的闪烁。
## 13. 打包
- 宠物渲染入口(HTML、内置角色素材)加入 `electron.vite.config.ts` 的 renderer 构建
输入(现有配置已有 `input` 多入口结构,参考现有主渲染入口的声明方式新增一项)。
- 内置角色素材(数量有限的静态图片/精灵图)作为打包资源加入 `package.json` 的 `files`/
`extraResources`,不通过运行时网络下载,符合仓库“不新增远程资源请求”的字体/资源
约束的同一精神。
- 新增 preload 产物(`pet.cjs`)需要加入 `electron.vite.config.ts` 的 main/preload 构建
`entryFileNames` 规则,与现有主 preload 产物并列。
- 六个标准打包目标(Windows/macOS/Linux × x64/arm64)均需验证宠物窗口可创建、透明或
回退不透明背景符合第 3 节预期;LoongArch 预览目标不单独验证,遵循仓库现有策略。
## 14. 验证策略
### 14.1 自动化
- `PetSettingsStore` 的默认值、原子写入、损坏隔离和版本校验(沿用
`settings-file-utils.ts` 现有测试模式)。
- `resolvePetPosition` 在显示器存在/不存在/坐标越界三种输入下的返回值。
- `PetStatusProjector` 按第 3 节优先级表在各种 Task 组合输入下的输出。
- `PetNotificationCoordinator` 与 `desktop-notification.ts` 共享判定函数在“可见并聚焦”
“不可见”“安静模式开启”组合下只触发一条路径或都不触发。
- `ContextManager.stageFromPaths`(新增方法)对合法文件、超限文件、不支持类型、文件夹
路径的分支覆盖,复用现有 `context-manager` 测试基础设施。
- `pet:*` IPC handler 的 trusted sender、schema 校验和错误码。
- 快速起草提交后主窗口渲染进程状态更新的单元测试:目标会话激活、文本写入、附件追加、
草稿被丢弃时不产生 Task 或消息记录。
### 14.2 真实环境
- Windows、macOS、Linux 至少各一次验证宠物窗口创建、拖动、贴边吸附、透明或回退背景的
实际渲染效果。
- 真实拖放一个合法文件、一个超限文件、一个文件夹到宠物窗口,确认 UI 设计第 4.3 节的
反馈和最终主窗口状态符合预期。
- 真实触发一次任务完成和一次任务失败,在“主窗口隐藏”“主窗口可见”“安静模式开启”
三种组合下确认气泡和原生通知的出现符合第 10 节的互斥规则。
- 多显示器插拔场景下确认位置回退逻辑(断开记忆显示器后宠物仍可见)。
- 打包后的应用在六个标准目标上完成启用、拖动、快速起草、退出流程的一次冒烟验证。
设计文档完成不代表功能实施或上述验证通过。
+207
View File
@@ -0,0 +1,207 @@
# 桌面宠物 UI 设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [桌面宠物 PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| 全局规范 | [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md) |
本文定义设置中的“桌面宠物”页面和独立宠物窗口的界面。所有颜色、间距、字体、通知、
Switch/Checkbox、焦点和响应式规则以全局设计系统为准;宠物窗口是应用内唯一不使用标准
`PageShell` 的持久界面,其例外范围在第 4 节明确限定。
## 1. 设置页面信息架构
设置中心新增“桌面宠物”一级分类,使用 `standard` PageShell,不使用页签,按以下顺序
纵向排列:
```text
桌面宠物
在桌面上保留一个轻量入口,随时起草消息、拖入文件或查看任务状态
启用桌面宠物 [Switch 关]
关闭后不在桌面上显示任何宠物窗口或提示
—— 以下设置仅在启用后可编辑,保持可见但降低强调度 ——
外观
角色 [分段选择:三到四个内置角色缩略图]
尺寸 [滑块 小 · 中 · 大]
透明度 [滑块]
动画强度 [滑块 关闭 · 轻量 · 完整]
[恢复默认外观]
位置与窗口
始终置顶 [Switch 开]
贴边吸附 [Switch 开]
提示
安静模式(不显示完成 / 失败气泡) [Switch 关]
```
- 总开关是本页唯一决定宠物窗口存在与否的控件,符合 `INV-3`;其余控件在总开关关闭时
保持可见但不可交互,说明文字保持“关闭后不在桌面上显示任何宠物窗口或提示”。
- 二元持久状态一律使用共享 Switch;角色选择使用共享 `SegmentedControl`(3-4 个内置
角色,符合数量上限);尺寸、透明度和动画强度使用标准滑块控件并显示当前取值。
- “恢复默认外观”只重置本区域三项外观设置,不改变总开关、位置或安静模式,与
[功能逻辑](./logic-design.md) 中“恢复默认外观”的范围一致。
- 页面不提供“立即显示 / 隐藏”按钮:暂时隐藏的入口固定放在托盘菜单,避免在设置页和
托盘中出现两个含义相近但状态不同步的控件。
## 2. 首次启用与关闭反馈
- 开启总开关后宠物窗口在默认位置出现,不产生任何应用通知或原生通知,因为这是用户刚
完成的显式操作,结果已经可见。
- 关闭总开关后宠物窗口和任何气泡立即消失;设置页本身不产生额外通知。
- 外观、位置相关 Switch 和滑块的变更立即生效并持久化,失败时通过应用通知报告,不静默
丢弃用户输入。
## 3. 托盘入口
- 托盘菜单在“新建对话”与“设置”之间新增一项,文案随当前可见性变化:
- 已启用且可见:“隐藏桌面宠物”。
- 已启用但隐藏:“显示桌面宠物”。
- 未启用:“显示桌面宠物”,点击后等同于在设置中开启总开关并显示窗口。
- 该菜单项不复制设置页的开关语义,只切换可见性;未启用状态下点击才会连带开启总开关,
行为需要在可访问名称或菜单说明中明确,避免用户误以为它只是显示/隐藏。
## 4. 宠物窗口
宠物窗口是无边框、透明背景、不可缩放的持久悬浮窗口,不使用 `PageShell`、不属于标准
一级页面,因此以下规则专属于该窗口:
```text
┌───────────┐
│ 角色 │ ← 状态镜面:角色外观随任务状态切换
│ (idle) │
└───────────┘
```
- 窗口本身没有标题栏、边框或系统窗口控件;关闭宠物只能通过设置总开关、托盘“隐藏桌面
宠物”或退出应用完成,窗口自身不提供关闭按钮,避免与“暂时隐藏”“关闭功能”两种含义
混淆。
- 窗口整体可拖动;拖动过程中显示当前吸附意图(开启贴边时,接近边缘会有轻微吸附反馈)。
- 窗口右键菜单提供“快速起草”“暂时隐藏”“打开设置”“退出 GoodBuddy”,与托盘菜单保持
同一套文案。
### 4.1 状态镜面外观
宠物角色在以下六种取值间切换,取值定义见[功能逻辑](./logic-design.md) 第 3 节:
| 状态 | 视觉表达 | 可访问名称 |
| --- | --- | --- |
| `unavailable` | 角色显示为暗淡的问号叠加 | “任务状态当前不可用” |
| `idle` | 角色静止或轻微待机动作 | “没有进行中的任务” |
| `running` | 角色显示持续的工作动作 | “有任务正在执行” |
| `waiting_approval` | 角色举牌或举手动作,带醒目但非警示色的强调 | “有任务等待你的确认” |
| `completed_recent` | 角色短暂展示完成动作后回到待机 | “有任务刚刚完成” |
| `failed_recent` | 角色显示明显区别于完成的姿态,并叠加警示色边缘 | “有任务失败,需要查看” |
- 状态表达必须同时使用动作/姿态和可访问名称两种信号,不能只依赖颜色区分
`completed_recent` 与 `failed_recent`。
- `prefers-reduced-motion` 或用户将动画强度设为“关闭”时,状态切换只改变静态姿态和颜色
强调,不播放位移或缩放动画。
- 点击状态镜面区域(角色本体)触发 US-D4:显示并聚焦主窗口,Task Center 定位到触发
当前状态的任务;`idle` 和 `unavailable` 状态下点击等同于显示并聚焦主窗口,不做定位。
### 4.2 快速起草弹出框
点击宠物打开的弹出框锚定在宠物窗口旁(自动避开屏幕边缘),结构如下:
```text
发送到:[当前会话名称 v]
[多行文本输入,占位文案“输入消息,或拖入文件…”]
[📎 附加文件] [已附加:contract.docx ×]
[取消] [提交]
```
- “发送到”是一个富信息单选菜单,选项为当前活跃会话与“新建对话”,遵循全局设计系统
第 6.11 节的单选选择器规则;默认选中当前活跃会话。
- 文本输入支持多行、`Ctrl+V` 粘贴图片和文字,与主窗口聊天输入框的粘贴行为一致。
- 附加文件按钮打开原生文件选择;已附加文件以行内标签形式展示,可单独移除。
- 拒绝的文件(超限或不支持)在附件区下方以就地错误文字展示,不使用应用级通知,因为
这是与当前操作直接绑定的字段级反馈。
- “提交”把文本和附件转交目标会话的输入区草稿,随后关闭弹出框;提交过程中禁用重复
点击并显示进行中状态,成功后不额外弹出通知(主窗口的显现和草稿本身就是反馈)。
- “取消”或点击弹出框外部或按 Escape 关闭弹出框并丢弃草稿,焦点返回宠物窗口。
- 弹出框打开时焦点进入文本输入;Tab 顺序为文本输入 → 附加文件 → 已附加文件的移除
按钮 → 取消 → 提交;关闭后焦点回到宠物窗口本体。
### 4.3 拖放反馈
- 文件或文字拖入宠物窗口范围时,窗口整体出现可放置的高亮边框和轻微放大反馈。
- 释放后立即按第 4.2 节“提交”的相同路径处理,不额外弹出确认框;这保持“拖动=起草并
发送到会话输入区(而非立即发送消息)”的一致语义。
- 拖入文件夹或全部文件超限时,宠物短暂显示拒绝反馈(边框变为警示色并伴随简短文字
提示,例如“不支持文件夹”),不打开主窗口、不产生暂存。
- 部分文件超限、部分合法时,合法文件正常处理,超限文件的名称通过一条会在数秒后自动
消失的就地提示列出,不使用应用级通知(拖放是即时操作,反馈应贴近操作发生的位置)。
### 4.4 完成 / 失败气泡
```text
┌─────────────────────────────┐
│ ✓ 已完成 │
│ 合同风险检查 │
│ [×] │
└─────────────────────────────┘
▲
(角色)
```
- 气泡锚定在宠物窗口旁,方向随宠物在屏幕上的位置自动避开边缘。
- 完成气泡在约 4.5 秒后自动消失,与全局应用通知的成功/信息时长一致;失败气泡保持可见
直到用户点击查看或点击关闭按钮。
- 气泡本体可点击,等同于点击状态镜面:显示并聚焦主窗口并定位到对应任务。
- 关闭按钮 `[×]` 有独立可访问名称(“关闭提示”),不与气泡主体的点击目标冲突:点击
关闭按钮只关闭气泡,不导航到主窗口。
- 气泡使用 `status`/polite 实时区域播报完成,失败气泡使用 `alert`/assertive,与全局
设计系统的通知语义保持一致,即使气泡本身不是应用通知视口的一部分。
## 5. 与应用通知视口的关系
- 宠物气泡是应用通知视口的窗口外等价物,只在主窗口不可见/未聚焦且安静模式关闭时出现;
同一事件不会同时进入应用通知视口和宠物气泡,具体归并规则见
[功能逻辑](./logic-design.md) 第 4 节。
- 快速起草和拖放产生的字段级错误(附件超限、不支持类型)始终就地展示在宠物窗口或弹出
框内,不进入应用通知视口,保持“就地错误”与“全局通知”分工一致。
## 6. 无障碍
- 状态镜面、气泡和弹出框内的所有交互元素都有持久可访问名称,不依赖占位文案代替标签。
- 宠物窗口可通过系统级窗口切换获得焦点;获得焦点后:
- 预期的激活键(例如 Enter 或 Space)打开快速起草弹出框。
- 弹出框内 Tab 顺序遵循第 4.2 节。
- Escape 关闭当前打开的弹出框或气泡,焦点回到宠物窗口本体。
- 状态镜面的状态变化通过 `status`/polite 实时区域播报简短说明,避免因动画本身无法被
屏幕阅读器感知而丢失信息;不对每次动画帧重复播报。
- 焦点环、危险色使用和触控目标尺寸遵循全局设计系统第 11 节的通用要求。
## 7. 响应式与多显示器
- 宠物窗口尺寸不随主窗口大小变化;用户通过设置中的“尺寸”滑块单独控制。
- 宠物窗口位置按显示器分别记忆;显示器不可用时的回退规则见
[功能逻辑](./logic-design.md) 第 7 节,界面表现为宠物出现在主显示器的默认可见位置,
不出现在屏幕之外或不可见状态。
- 弹出框和气泡的锚定方向根据宠物当前位置与屏幕边缘的距离自动选择展开方向(上/下/左/
右),确保始终完整可见,不被裁切到屏幕外。
## 8. 深浅主题
- 弹出框、气泡和设置页控件复用全局语义令牌,不新增页面专属颜色。
- 宠物角色素材本身分别提供适配浅色和深色桌面背景的版本,避免透明背景下角色轮廓在深色
桌面壁纸上难以辨认;具体资源交付方式属于技术设计范畴。
## 9. 固定词汇
使用“桌面宠物”“启用桌面宠物”“暂时隐藏”“快速起草”“安静模式”“状态镜面”,定义见
[README](./README.md) 术语表。不使用“挂件”“桌宠助手”或其他未在术语表中定义的别名,
保持中文文案统一。
+255
View File
@@ -0,0 +1,255 @@
# 桌面宠物 User Stories
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [桌面宠物 PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
## 角色
- **日常用户**:在多任务、多显示器场景下使用 GoodBuddy 处理日常工作。
- **首次体验用户**:刚升级或安装 GoodBuddy,尚未接触过桌面宠物功能。
- **键盘用户**:主要通过键盘操作,可能同时使用屏幕阅读器。
- **多显示器用户**:使用两个或更多显示器,可能随时插拔外接显示器。
## US-A 首次启用
### US-A1 默认不出现
**Given** 用户全新安装或刚升级 GoodBuddy
**When** 用户尚未进入“桌面宠物”设置
**Then** 桌面上不出现任何宠物窗口,托盘菜单显示“显示桌面宠物”而非“隐藏”
### US-A2 首次启用
**Given** 用户在设置中打开“启用桌面宠物”总开关
**When** 开关切换为开启
**Then** 宠物窗口在默认位置出现,使用默认外观,且不产生任何应用级或原生通知
### US-A3 关闭后完全消失
**Given** 桌面宠物已启用并可见
**When** 用户关闭“启用桌面宠物”总开关
**Then** 宠物窗口立即消失,未完成的气泡提示一并消失,托盘菜单条目恢复为“显示桌面宠物”,
且用户之前设置的位置、外观和提示偏好被保留供下次开启时使用
## US-B 快速起草
### US-B1 起草文字并转入主窗口
**Given** 桌面宠物已启用,主窗口当前隐藏
**When** 用户点击宠物并输入一段文字后点击提交
**Then** 主窗口被显示并聚焦,目标会话的输入区出现刚输入的文字作为草稿,输入焦点落在
该输入区,且没有任何消息被发送、没有任何任务被创建
### US-B2 起草时附加文件
**Given** 快速起草弹出框已打开
**When** 用户点击“附加文件”并选择一个受支持类型和大小内的文件
**Then** 该文件在弹出框内显示为待提交附件;提交后该附件出现在主窗口对应会话输入区的
待发送附件列表中
### US-B3 附加不受支持的文件
**Given** 快速起草弹出框已打开
**When** 用户选择一个超出大小限制或类型不受支持的文件
**Then** 弹出框就地显示明确的拒绝原因,不添加该文件,且不影响已经添加的其他文字或附件
### US-B4 取消起草
**Given** 用户已在快速起草弹出框中输入文字或添加附件
**When** 用户点击取消或按 Escape 关闭弹出框
**Then** 弹出框关闭且草稿被丢弃,主窗口任何会话的现有草稿不受影响,且不创建任何草稿或
任务记录
### US-B5 选择目标会话
**Given** 主窗口当前存在多个会话,且用户在快速起草弹出框中看到默认目标会话
**When** 用户在弹出框内切换目标为“新建对话”
**Then** 提交后主窗口创建并激活一个新会话,草稿内容进入该新会话的输入区,不影响原有
会话的草稿
## US-C 拖放暂存
### US-C1 拖放单个文件
**Given** 桌面宠物已启用并可见
**When** 用户从文件管理器拖动一个受支持类型的文件到宠物窗口上并释放
**Then** 宠物在拖入过程中显示可放置的视觉反馈,释放后主窗口被显示并聚焦,该文件出现在
目标会话输入区的待发送附件列表中
### US-C2 拖放多个文件,部分超限
**Given** 用户同时拖动数量或大小超出限制的多个文件到宠物窗口
**When** 用户释放拖放
**Then** 合法文件正常进入目标会话的待发送附件列表,超限或不支持的文件被单独列出拒绝
原因,不阻止合法文件被暂存
### US-C3 拖放文件夹
**Given** 用户拖动一个文件夹到宠物窗口
**When** 用户释放拖放
**Then** 宠物提示不支持文件夹,不递归展开文件夹内容,也不产生任何暂存
### US-C4 拖放选中文字
**Given** 用户在其他应用中选中一段文字并拖动到宠物窗口
**When** 用户释放拖放
**Then** 该文字进入目标会话输入区的草稿文本,行为与快速起草输入文字一致
## US-D 状态镜面
### US-D1 查看空闲状态
**Given** 当前没有任何活跃 Task
**When** 用户查看宠物外观
**Then** 宠物显示空闲状态,且可访问名称明确说明当前没有进行中的任务
### US-D2 查看执行中状态
**Given** 有一个 Task 处于执行中
**When** 用户查看宠物外观
**Then** 宠物切换为执行中状态,且该状态与 Task Center 中该任务的状态一致
### US-D3 多任务状态优先级
**Given** 同时存在一个等待处理的任务和一个执行中的任务
**When** 用户查看宠物外观
**Then** 宠物按照“等待处理优先于执行中”的规则显示等待处理状态
### US-D4 点击状态镜面打开任务详情
**Given** 宠物当前显示执行中或失败状态
**When** 用户点击宠物的状态镜面区域
**Then** 主窗口被显示并聚焦,Task Center 定位到触发当前状态的具体任务
### US-D5 状态不可用
**Given** 用于确认任务状态的 Runtime 或网络当前不可用
**When** 用户查看宠物外观
**Then** 宠物显示“状态不可用”,不回退显示为空闲或执行中
## US-E 完成与失败提示
### US-E1 主窗口不可见时收到完成提示
**Given** 主窗口当前隐藏或未聚焦,安静模式未开启
**When** 一个任务从执行中变为已完成
**Then** 宠物旁出现说明任务标题和结果的气泡,且该事件不重复触发原生系统通知
### US-E2 失败提示保持可见
**Given** 主窗口当前隐藏,安静模式未开启
**When** 一个任务失败
**Then** 宠物旁出现失败气泡并保持可见,直到用户点击查看或手动关闭,不自动消失
### US-E3 主窗口可见时不重复提示
**Given** 主窗口当前可见并聚焦
**When** 一个任务完成或失败
**Then** 不出现宠物气泡;反馈通过主窗口现有机制呈现,避免同一事件重复出现在两个位置
### US-E4 开启安静模式
**Given** 用户在设置中开启“安静模式”
**When** 之后任意任务完成或失败,且主窗口不可见
**Then** 不出现任何宠物气泡,但状态镜面外观仍随任务状态正常变化
## US-F 位置、外观与多显示器
### US-F1 记忆位置
**Given** 用户把宠物拖动到某个显示器的特定位置
**When** 用户重启 GoodBuddy
**Then** 宠物出现在同一显示器的同一位置
### US-F2 显示器断开后回退
**Given** 宠物位置记忆在一个已插入的外接显示器上
**When** 该外接显示器被移除后重新启动 GoodBuddy
**Then** 宠物出现在当前可用的主显示器上一个可见、不越界的位置,而不是不可见或位于屏幕
之外
### US-F3 贴边吸附
**Given** 用户开启“贴边”选项
**When** 用户把宠物拖动到接近屏幕边缘的位置并释放
**Then** 宠物自动吸附到屏幕边缘,且吸附后仍可被再次拖动离开
### US-F4 调整外观
**Given** 用户在设置中调整宠物尺寸、透明度或动画强度
**When** 用户改变任意一项取值
**Then** 宠物外观立即按新取值更新,且设置持久化,重启后保持
### US-F5 恢复默认外观
**Given** 用户已经修改过角色、尺寸、透明度或动画强度
**When** 用户点击“恢复默认外观”
**Then** 上述设置全部恢复为默认值,宠物位置和总开关状态不受影响
## US-G 暂时隐藏与托盘
### US-G1 暂时隐藏
**Given** 桌面宠物已启用并可见
**When** 用户从托盘菜单选择“隐藏桌面宠物”
**Then** 宠物窗口消失,总开关状态和其他设置不变,托盘菜单条目变为“显示桌面宠物”
### US-G2 重新显示
**Given** 桌面宠物已启用但当前隐藏
**When** 用户从托盘菜单选择“显示桌面宠物”
**Then** 宠物窗口在此前记忆的位置重新出现
### US-G3 应用退出
**Given** 桌面宠物已启用并可见
**When** 用户退出 GoodBuddy
**Then** 宠物窗口一并关闭,不留下任何独立运行的进程或残留窗口
## US-H 键盘与无障碍
### US-H1 键盘打开快速起草
**Given** 桌面宠物窗口通过系统级窗口切换取得焦点
**When** 键盘用户按下预期的激活键
**Then** 快速起草弹出框打开,焦点进入输入框
### US-H2 键盘关闭弹出框
**Given** 快速起草弹出框已打开且有键盘焦点
**When** 用户按下 Escape
**Then** 弹出框关闭,焦点回到宠物窗口本身
### US-H3 屏幕阅读器状态说明
**Given** 用户使用屏幕阅读器
**When** 焦点移动到宠物的状态镜面
**Then** 屏幕阅读器播报当前状态的文字说明,不仅依赖颜色或动画表达状态
## US-I 兼容与边界
### US-I1 升级用户保持现状
**Given** 已在使用 GoodBuddy 且从未接触过桌面宠物功能的用户升级到包含该功能的版本
**When** 用户正常启动应用
**Then** 桌面宠物保持关闭,界面和行为与升级前一致
### US-I2 不支持透明合成的环境
**Given** 用户的操作系统或桌面环境不支持窗口透明合成
**When** 用户启用桌面宠物
**Then** 宠物窗口退化为不透明的小型窗口而不是渲染异常或无法显示,具体规则见
[技术设计](./technical-design.md)
### US-I3 拖放到非目标区域
**Given** 用户尝试把文件拖到宠物窗口之外的桌面区域
**When** 用户释放拖放
**Then** GoodBuddy 不接收该拖放,不产生任何暂存或错误提示
+28
View File
@@ -0,0 +1,28 @@
# 设备发现与共享
本功能是 GoodBuddy 桌面端管理附近设备、本机共享和 ShareServer 连接的唯一入口。默认连接
方式是局域网;网关由用户额外添加。功能不包含 ShareServer 的组织管理 Web 界面。
## 文档导航
| 文档 | 权威职责 |
| --- | --- |
| [PRD](./prd.md) | 用户问题、范围、功能需求和产品验收 |
| [功能逻辑](./logic-design.md) | 不变量、状态、授权优先级和失败结果 |
| [UI 设计](./ui-design.md) | 设置入口、四个页签、配对与调用交互 |
| [技术设计](./technical-design.md) | Desktop 进程边界、持久化、IPC、网络和验证 |
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
当前没有单独的 User Stories:正常、失败、撤销、权限和兼容场景已直接进入 PRD 验收与逻辑
决策表。功能尚未实施,因此不创建 `progress.md`。
## 术语
- **附近设备**:同一局域网内主动广播 GoodBuddy 共享服务的设备。
- **已配对设备**:双方确认设备指纹后建立点对点信任的设备。
- **网关连接**:本机与某个 ShareServer 中特定组织身份的连接。
- **本机共享**:由本机作为提供者发布给 Peer 或组织的能力。
- **远端能力**:由其他设备或组织提供、本机可以查看或调用的能力。
SSH 远程项目是用户明确配置 Host 后操作远端工作区的独立功能,不自动进入设备发现与共享,
详见[远程主机与远程执行](../remote-host/README.md)。
@@ -0,0 +1,202 @@
# 设备发现与共享功能逻辑设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
| 共享规则 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
本文负责桌面端状态、不变量和决策规则,不重复跨端协议定义。
## 1. 核心不变量
### INV-1 连接方式独立
局域网、每个 GatewayConnection 和本机能力互不拥有生命周期。启停、失败或删除其中一项不
改写其他项。
### INV-2 三项操作独立
`discovering`、`advertising` 和 `publishing` 是三个独立状态。任何组合均合法,界面不得用
一个“设备共享”总开关代替。
### INV-3 默认不共享
新安装没有 Publication。允许被发现只公开配对入口,不公开能力清单。
### INV-4 身份先于能力
未配对局域网设备不能取得详细能力;未注册网关设备不能取得组织目录。缓存不能绕过当前
身份和撤销状态继续调用。
### INV-5 提供者在调用前冻结
自动选择只生成候选决定。用户确认或无需确认的请求创建后,provider、route 和授权快照不可
改变。失败后切换提供者属于新的请求。
### INV-6 最小发送上下文
只发送能力 schema 要求且用户当前选择的输入。当前 Conversation、Project 和 Workspace 不
因能力被调用而整体成为输入。
### INV-7 结果未知不重放
已经被提供者接受但终态无法确认的副作用任务进入 `outcome_unknown`。重新执行必须由用户
发起,并显示可能重复产生副作用。
### INV-8 撤销只影响未来授权
撤销配对、网关连接、Grant 或 Publication 后不再创建新任务。历史记录保留;活动任务按
冻结策略取消或完成,不能追改为从未发生。
### INV-9 设置导航无业务副作用
四个 PageTabs 只切换视图,不自动开始广播、连接网关、发布能力或保存草稿。
## 2. 状态维度
### 2.1 局域网服务
```text
discover: off | starting | active | unavailable | error
advertise: off | starting | active | unavailable | error
```
操作系统或网络不支持时使用 `unavailable`;运行失败使用 `error`。两者分别重试。
### 2.2 附近设备
```text
visible_unpaired -> pairing -> paired_online
pairing -> rejected | expired | failed
paired_online <-> paired_offline
paired_* -> identity_changed | revoked
```
`identity_changed` 不自动接受新证书。用户必须撤销旧配对后重新配对。
### 2.3 网关连接
```text
draft -> authenticating -> connecting -> connected
authenticating -> cancelled | auth_failed
connecting/connected -> offline | auth_expired | policy_blocked
offline -> connecting
任意已保存状态 -> removed
```
`offline` 保留连接和缓存目录供只读说明,但不能创建新任务;`auth_expired` 要求重新登录;
`policy_blocked` 显示服务端给出的稳定原因和管理员联系路径。
### 2.4 本机发布
```text
not_published -> draft -> validating -> published
validating -> invalid
published -> paused -> published
published/paused -> removed
```
草稿和无效发布不对外可见。能力实现消失时发布进入 `paused`,不删除用户配置。
### 2.5 远端任务
采用共享架构定义的状态。本机 UI 归并为:
| 协议状态 | 用户状态 |
| --- | --- |
| `created/awaiting_approval` | 等待确认 |
| `queued/connecting` | 正在连接 |
| `running` | 正在执行 |
| `completed` | 已完成 |
| `failed/rejected/expired` | 未完成,并显示具体原因 |
| `cancelled` | 已取消 |
| `outcome_unknown` | 结果未知 |
## 3. 默认状态
| 设置 | 全新安装默认值 |
| --- | --- |
| 发现附近设备 | 开启 |
| 允许其他设备发现本机 | 关闭 |
| 自动接受配对 | 不支持 |
| 本机共享能力 | 无 |
| 网关连接 | 无 |
| 永久调用 Grant | 无 |
主动发现默认开启只浏览 GoodBuddy 主动广播,不执行端口扫描。操作系统权限尚未授予时,首次
进入页面再触发对应系统说明和授权,不在应用启动时抢占焦点。
## 4. 提供者选择
自动选择按以下顺序比较满足当前输入和授权的在线候选:
1. 本机提供者。
2. 已配对局域网提供者。
3. 当前用户所属组织提供者。
4. 联邦外部组织提供者。
5. 同级中按服务端预计排队时间和稳定能力版本排序。
自动选择不跨越 `require_approval` 与 `allow`:若排名第一的候选需要批准,界面显示该候选及
批准要求,不静默换成权限更宽但来源更远的候选。用户可以明确选择其他候选。
## 5. 调用决策表
| 身份与能力状态 | 授权结果 | 行为 |
| --- | --- | --- |
| 身份有效、输入匹配 | allow | 创建任务并展示执行位置 |
| 身份有效、输入匹配 | require_approval | 显示完整确认后创建 |
| 身份有效 | deny | 不创建任务,显示策略原因 |
| 提供者离线 | 任意 | 不创建任务,允许重新选提供者 |
| 清单过期或版本变化 | 任意 | 刷新后重新计算,不沿用确认 |
| 文件超过能力或策略上限 | 任意 | 就地拒绝并显示上限 |
| 委托链与确认不一致 | 任意 | 拒绝并要求以新执行链重新确认 |
## 6. 撤销与删除
| 操作 | 删除内容 | 保留内容 |
| --- | --- | --- |
| 撤销 Peer | 配对凭据、该 Peer 的本地 Grant 和目录缓存 | 活动记录、已安装包 |
| 移除网关 | 本机设备凭据、连接配置、目录缓存 | 服务端账号、活动记录、已安装包 |
| 停止发布 | 对外 Publication | 能力实现、发布草稿、历史任务 |
| 删除发布 | Publication 和本地发布设置 | 能力实现、历史任务 |
| 删除已安装包 | GoodBuddy-owned 安装内容和分配 | 来源审计与任务历史 |
存在活动任务时,撤销操作明确询问是否同时发送取消;无论取消是否确认,立即禁止新任务。
## 7. 失败与恢复
- mDNS 不可用:显示手动地址配对,不关闭已配对直连。
- 广播失败:保留用户开关意图,状态为错误并允许重试。
- 网关离线:指数退避重连,用户手动“重新连接”可立即尝试一次。
- 登录取消:不保存连接。
- 目录刷新失败:保留最后缓存并标记时间,不允许基于过期授权启动新任务。
- 调用前连接失败:任务失败,可安全重新执行。
- 提供者接受后断线:恢复查询同一任务;仍无法确认时进入结果未知。
- 下载或签名失败:删除候选暂存,保留现有安装,不切换来源。
## 8. 需求追踪
| 需求 | 主要逻辑 |
| --- | --- |
| `FR-1`、`FR-2` | `INV-1`、`INV-2`、`INV-9` 和默认状态 |
| `FR-3`、`FR-4` | 身份不变量和附近设备状态机 |
| `FR-5` | 连接独立不变量、网关连接状态和删除规则 |
| `FR-6` | provider 冻结不变量和提供者选择顺序 |
| `FR-7` | 默认不共享不变量、本机发布状态和撤销规则 |
| `FR-8` | 最小发送上下文和调用决策表 |
| `FR-9` | 远端任务状态、结果未知不重放和失败恢复 |
| `FR-10` | 撤销与删除、下载或签名失败规则;包协议由共享架构定义 |
| `FR-11` | 撤销后历史保留和任务状态;展示行为由 UI 设计定义 |
| `FR-12`、`FR-13` | 独立状态维度和失败恢复 |
| `FR-14` | 身份先于能力、最小发送上下文和调用决策表 |
## 9. 逻辑完整性
本设计已覆盖发现、配对、连接、发布、调用、取消、撤销、离线、身份变化、权限变化和能力
更新。实现前仍需在共享协议中固定受限 JSON Schema 子集、端到端中继加密套件和桌面端可
注册为共享能力的首批实际类型;这些是技术选择,不改变本文状态和产品规则。
+160
View File
@@ -0,0 +1,160 @@
# 设备发现与共享 PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 适用产品 | GoodBuddy Desktop |
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
| 关联文档 | [功能逻辑](./logic-design.md)、[UI 设计](./ui-design.md)、[技术设计](./technical-design.md) |
## 1. 背景
GoodBuddy 的能力当前主要在本机执行,SSH 远程项目则要求用户预先配置具体 Host。个人和组织
需要在不复制完整环境的情况下使用附近工作站、其他成员设备或组织节点上的模型、知识、硬件
和工具,也需要在经过确认后把可分发能力安装到本机。
该能力不能以“扫描局域网机器”的方式实现。产品只发现主动开放 GoodBuddy 服务的设备,
配对后才查看详细能力,授权后才发送数据或执行任务。
## 2. 产品目标
1. 用户无需配置服务器即可发现并配对同一局域网中的 GoodBuddy 设备。
2. 用户可以额外连接一个或多个 ShareServer,使用组织或合作组织提供的能力。
3. 用户能够逐项决定本机向哪些 Peer、用户组或组织开放什么能力。
4. 远程调用前、中、后都能确认提供者、执行位置、数据去向和授权依据。
5. 局域网、各网关连接和本机能力彼此独立,单点故障不阻塞其他来源。
6. 可分发能力必须在明确校验和批准后安装,不迁移对方凭据或机器配置。
## 3. 功能范围
### 3.1 包含
- 设置中的“设备发现与共享”一级分类。
- 局域网主动发现、选择、双端配对、重命名和撤销。
- 本机被发现开关和配对请求处理。
- ShareServer 添加、登录、连接状态、组织身份和移除。
- 远端能力目录、提供者选择、调用确认、进度、取消和结果。
- 本机能力逐项发布、范围、允许动作和确认策略。
- 签名能力包查看、下载、校验、安装和权限变化确认。
- 局域网与网关活动记录。
### 3.2 不包含
- 扫描未主动广播 GoodBuddy 服务的机器或端口。
- 在桌面端管理 ShareServer 成员、组织策略、联邦或服务器运维。
- 自动公开本机 Skill、MCP、知识库、模型、文件或 SSH Host。
- 同步 API Key、Cookie、系统环境变量、绝对路径或登录状态。
- 把现有 SSH Host 自动转换为共享设备。
- 无界远程 Shell、桌面控制或任意文件浏览接口。
- 在网关断开时把组织授权自动降级为局域网授权。
## 4. 功能需求
### FR-1 设置入口
- 设置中心增加“设备发现与共享”一级分类。
- 页面提供“局域网”“网关”“本机共享”“活动记录”四个同级页签。
- 普通入口默认打开“局域网”;定向入口可打开指定页签。
### FR-2 独立开关
- “发现附近设备”和“允许其他设备发现本机”使用两个独立 Switch。
- 任一开关变化不撤销既有配对、不改变网关连接、不开放能力。
- 新安装默认允许主动发现;默认不允许本机被发现,默认无共享能力。
### FR-3 附近设备
- 只展示当前收到有效主动广播的 GoodBuddy 设备。
- 未配对设备只展示配对所需的有限信息,不展示能力和资产详情。
- 列表区分附近未配对、已配对在线、已配对离线和身份变化。
- 用户可手动刷新,但持续发现不得因刷新造成列表闪烁或重复项。
### FR-4 双端配对
- 双方显示相同验证码、设备名称、系统类型和证书指纹。
- 只有双方在时限内确认才建立配对。
- 任一端拒绝、超时、身份变化或连接中断均不保存半配对关系。
- 任一端可以撤销;撤销不删除历史活动记录。
### FR-5 网关连接
- 用户可输入 HTTPS ShareServer 地址或打开受支持的邀请链接。
- 登录在系统浏览器中完成,设备凭据回到 Main 的安全存储。
- 连接条目显示服务名、组织、当前成员身份、状态和最近策略同步时间。
- 同一设备可连接多个网关;移除连接只删除本机凭据和缓存,不删除服务端账号。
### FR-6 能力目录
- 已配对 Peer 和已连接网关分别返回当前身份获准查看的能力。
- 同一能力有多个提供者时合并展示,并允许“自动选择”或明确选择提供者。
- 自动选择仅比较满足授权和输入要求的在线候选,依次选择本机、已配对局域网 Peer、
当前用户所属组织和联邦外部组织;同级候选再按预计等待时间和稳定能力版本排序。
- 选择结果必须在执行前冻结并展示,不能执行中静默更换提供者。
### FR-7 本机共享
- 只有显式注册为可共享的能力才能出现在本机共享列表。
- 每项能力分别设置目标范围、`discover/inspect/invoke/download` 动作、输入类型、大小、
并发、有效期和是否逐次确认。
- 关闭能力或目标范围后停止接受新任务,不伪装为删除能力本身。
- 知识库原文、工作区写入、进程执行和硬件控制需要独立的明确能力声明。
### FR-8 调用确认
- 首次或策略要求时,确认界面显示提供者、执行位置、接收方、输入内容类别、文件名和大小、
路由、中继可读性及下游委托。
- 用户可取消、仅本次允许,或在组织策略允许时按当前范围保存授权。
- 调用上下文不隐式包含其他会话消息、整个工作区或未选择的知识库内容。
### FR-9 执行状态
- 会话或任务活动持续显示远端能力名称、来源、状态、发送字节和停止入口。
- 支持排队、连接、执行、完成、失败、取消、过期和结果未知。
- 网络中断且副作用结果无法确认时显示“结果未知”,不自动重试。
### FR-10 能力包
- 展示来源、版本、发布者、平台、大小、签名、权限和依赖。
- 下载和安装经过共享架构规定的校验流程。
- 新版本扩大权限时必须重新批准。
- 安装到本机后按现有 Skill/MCP/Runtime 分配规则启用,不因来自可信组织自动取得全部 Runtime。
### FR-11 活动记录
- 统一记录本机发出、远端发入、局域网、网关和跨组织活动。
- 支持按方向、来源、能力、结果和时间筛选。
- 详情显示授权依据、执行链、数据类别、大小、版本和终态,不默认保存正文。
### FR-12 错误隔离
- 局域网发现失败不影响网关和本机能力。
- 一个网关认证或连接失败不影响其他网关。
- 一个提供者离线时不自动改发给另一个提供者;用户重新执行时再选择。
- 页面保留可修复配置和准确错误,不以空状态代替失败状态。
### FR-13 平台与网络
- 支持 Windows、macOS、Linux 的当前桌面目标架构。
- mDNS 被网络阻断时允许通过已知局域网地址发起配对,但仍执行相同身份确认。
- 所有公网 ShareServer 使用 HTTPS;不提供“忽略证书错误”。
### FR-14 与现有能力的边界
- 远端调用不能扩大 Ask/Execute、Workspace、MCP、Skill 和知识库现有权限。
- 本机提供任务时使用能力声明对应的 Runtime 边界。
- SSH 远程项目、本机工具环境和模型配置默认不向 Peer 或网关传播。
## 5. 产品验收
- 两台新设备能完成发现、双端配对、授权、调用、取消和撤销。
- 三个概念“主动发现、允许被发现、开放能力”在状态和界面上互不混淆。
- 不配置网关时局域网工作完整;任一网关故障不阻塞其他来源。
- 未配对或未授权主体无法查看详细能力。
- 远端调用前能准确列出实际发送的数据,执行中和历史中可追溯提供者。
- 结果未知不会被显示为失败后自动重试或成功。
- 权限扩大的能力更新不会沿用旧授权静默安装。
- 中英文、键盘、焦点、窄窗口和深浅主题符合统一设计系统。
@@ -0,0 +1,213 @@
# 设备发现与共享技术设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
## 1. 进程边界
```text
Renderer
-> 窄 Preload API
Main
-> DeviceSharingService
-> LanDiscoveryService
-> PeerPairingService
-> GatewayConnectionService
-> CapabilityCatalogService
-> CapabilityPublicationService
-> RemoteTaskService
-> PackageAcquisitionService
-> SharingAuditStore
-> 既有 Capability / Runtime / Skill / MCP 服务
```
Renderer 只取得脱敏快照和调用显式动作。设备私钥、Peer 证书、网关凭据、网络 socket、原始
任务令牌、任意文件读取和包缓存路径只存在于 Main。
## 2. 模块职责
| 模块 | 职责 |
| --- | --- |
| `LanDiscoveryService` | mDNS browse/advertise、手动地址握手、接口变化 |
| `PeerPairingService` | 临时握手、验证码、双端确认、配对凭据与撤销 |
| `GatewayConnectionService` | 浏览器登录、设备注册、WebSocket、重连和策略同步 |
| `CapabilityCatalogService` | 合并本机、Peer 和网关目录,保留来源身份 |
| `CapabilityPublicationService` | 校验本机可共享能力并向目标范围发布 |
| `RemoteTaskService` | 授权、确认、provider 冻结、传输、取消和终态 |
| `PackageAcquisitionService` | 固定来源下载、校验、安装协调和权限差异 |
| `SharingAuditStore` | 本机有界审计查询、筛选和清理 |
目录合并只生成 UI 视图,不能把不同 provider 的授权、版本和执行位置压成一个身份。
## 3. 共享契约
实现前建立独立 `packages/share-protocol`。Desktop 只从该包导入 Zod schema 和 TypeScript
类型。协议输入在网络边界和 IPC 边界分别验证,不能因已经过网络校验而跳过 Renderer 输入
校验。
建议导出:
```text
DeviceSummary
PairingChallenge
GatewayDescriptor
CapabilityManifest
Publication
GrantConstraint
AuthorizationDecision
RemoteTaskSnapshot
RemoteTaskEvent
PackageDescriptor
AuditEventSummary
ProtocolError
```
错误只向 Renderer 返回稳定 code、可本地化参数和可恢复动作,不转发远端堆栈、响应正文或
内部地址。
## 4. 持久化
建议在现有 Desktop SQLite 中增加:
```text
device_identity_metadata
peer_pairings
gateway_connections
capability_publications
local_grants
remote_tasks
sharing_audit_events
installed_shared_packages
```
- 私钥和 refresh credential 存系统安全存储;SQLite 只存引用和脱敏元数据。
- 附近广播、在线状态、配对验证码、短期任务令牌和目录候选只存在于内存。
- 网关能力目录可有界缓存,用于离线说明,不作为离线调用授权。
- 审计正文不落库,字段和保留上限在实现前固定。
- 数据库迁移只覆盖已发布 schema;未发布设计不预建兼容读取器。
## 5. 局域网网络实现
- 使用系统支持的 mDNS/DNS-SD 库 browse `_goodbuddy-share._tcp.local`。
- 广播绑定用户允许的网络接口;接口切换时重建监听,不保留旧地址为身份。
- 服务端口由 Main 持有,使用设备证书建立 TLS。
- 未配对握手仅暴露配对所需字段,并限制并发、频率、消息大小和有效时间。
- 手动地址只替代发现,不替代证书指纹与双端确认。
- 停止广播时关闭未完成配对监听;已配对活动连接按任务生命周期处理。
## 6. 网关连接
1. Main 校验 HTTPS URL,并取得服务公开描述。
2. 生成带 PKCE、随机 state 和设备挑战的系统浏览器登录请求。
3. 本机 loopback 或已注册应用链接只接收一次性回调 code。
4. Main 交换设备凭据,展示组织身份,用户确认后持久化。
5. Main 建立带设备身份的 WebSocket 出站连接。
6. 心跳更新在线状态;断线按有界指数退避重连。
服务器返回的跳转 URL 必须符合已取得服务描述和 HTTPS 限制。Renderer 不能提交认证头、设备
证书、WebSocket URL 或任意回调地址。
## 7. 本机能力适配
不是所有现有 Capability 都自动可共享。每种可共享类型需要显式 adapter:
```ts
interface ShareableCapabilityAdapter {
describe(): CapabilityManifest
validatePublication(input: PublicationDraft): ValidationResult
invoke(input: unknown, context: ProviderTaskContext): AsyncIterable<TaskEvent>
cancel(taskId: string): Promise<CancelResult>
}
```
adapter 负责把共享输入映射到既有生产服务,并保留原权限边界。首批类型在实现前根据真实
产品需求确定;未有 adapter 的 Skill、MCP、知识库和 Runtime 不出现在共享列表。
## 8. 远端调用生命周期
```text
Renderer 选择能力和输入
-> Main 解析候选并取得当前授权
-> 必要时返回确认描述
-> 用户批准
-> Main 冻结 task/provider/route/policy
-> 建立数据通道并流式发送
-> Provider 再校验并接受
-> Main 转发有界进度和结果
-> 终态与审计事务保存
```
每次任务使用稳定 `taskId` 和 attempt ID。Provider 未接受前的连接失败可以明确失败;已接受
后的断线先查询同一任务,不新建 attempt。无法恢复权威终态时保存 `outcome_unknown`。
附件从 Main 已授权的用户选择结果读取,按声明大小流式发送,不把任意本机路径交给远端。
接收成果先写 GoodBuddy-owned 暂存并校验,再通过现有 Artifact 边界进入产品。
## 9. IPC
Preload 只暴露按产品动作命名的方法,例如:
```text
sharing.getSnapshot
sharing.updateLanSettings
sharing.startPairing / confirmPairing / cancelPairing
sharing.revokePeer
sharing.addGateway / completeGatewayLogin / removeGateway
sharing.listCapabilities
sharing.savePublication / pausePublication / deletePublication
sharing.prepareInvocation / confirmInvocation / cancelTask
sharing.listAuditEvents / getAuditEvent
```
所有变更方法校验 trusted sender、严格 schema、对象当前 revision 和归属关系。不得暴露通用
HTTP、WebSocket、mDNS、证书、文件传输或数据库查询 IPC。
## 10. 生命周期与资源边界
- 应用启动按保存设置启动主动发现;不因保存了网关而阻塞窗口创建。
- 广播只有在用户开启后启动。
- 网关连接在后台恢复,认证过期只影响对应连接。
- 应用退出停止接收新任务,取消未接受任务,并有界通知活动 Provider 后关闭连接。
- 单个 Peer/网关/任务设置连接数、帧、字节、并发和重连上限。
- 大文件全程流式处理,不进入单个 Main `Buffer` 或 Renderer state。
## 11. 与现有模块集成
- `capability-service` 提供本机能力事实;共享 adapter 明确选择可公开子集。
- 已安装共享 Skill/MCP 继续使用现有配置、启停和 Runtime 分配,不建立第二套执行器。
- SSH Host 保持独立;除非未来提供专门 adapter 和用户发布,否则不进入目录。
- 远端 Task 的公开活动复用现有工具/任务展示语义,但保留 provider 和 route 字段。
- 应用更新源、模型下载源、Agent 下载源和工具下载源不因添加 ShareServer 自动改变。
## 12. 验证策略
### 12.1 自动化
- schema 边界、未知枚举和协议版本协商。
- 三个独立开关及默认值。
- mDNS 去重、接口变化、广播停止和手动地址。
- 配对双方确认、拒绝、超时、身份变化和撤销。
- 多网关隔离、认证过期、重连和移除。
- provider 排序、冻结、授权变化和失败不切换。
- 调用确认最小输入、附件上限、取消和结果未知。
- Publication 校验、暂停、撤销和 adapter 消失。
- 包签名、权限差异、来源冻结和失败保留旧安装。
- IPC sender、revision、归属和无通用网络/文件接口。
### 12.2 真实环境
- Windows、macOS、Linux 至少两种组合完成同网段发现和配对。
- mDNS 可用、被防火墙阻断、网络接口切换和设备离线场景。
- 真实 ShareServer 登录、设备出站连接、目录、调用和认证过期。
- 小型 JSON、文件流、取消、Provider 进程失败和网络中断。
- 确认日志、Renderer、诊断和审计中没有凭据或文件正文。
设计文档完成不代表功能实施或上述验证通过。
+211
View File
@@ -0,0 +1,211 @@
# 设备发现与共享 UI 设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [设备发现与共享 PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| 全局规范 | [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md) |
本文只定义桌面端界面。所有颜色、间距、字体、通知、Switch、PageTabs、焦点和响应式行为以
全局设计系统为准。
## 1. 信息架构
设置中心新增一级分类“设备发现与共享”,使用 `standard` PageShell:
```text
设备发现与共享
发现附近的 GoodBuddy,或连接组织网关使用和开放能力
[ 局域网 ] [ 网关 ] [ 本机共享 ] [ 活动记录 ]
```
四项使用共享 `PageTabs` 的 `segmented` 视觉变体。普通入口默认“局域网”;来自能力来源、
连接错误或审计入口的定向导航打开对应页签。页签选择不持久化为产品设置。
## 2. 局域网页签
页面顺序固定为本机发现状态、已配对设备、附近设备。
```text
局域网
发现附近设备 [Switch 开]
查看同一网络中主动开放的 GoodBuddy 设备
允许其他设备发现本机 [Switch 关]
设备名称:Jiang-PC [编辑]
已配对设备 [刷新]
名称 状态 能力 最近连接 操作
GPU-Workstation 在线 4 项 刚刚 ...
附近设备
Design-Mac 等待配对 [配对]
```
- 两个 Switch 必须独立成行,不能使用一个总开关。
- “允许被发现”开启后只显示广播状态和设备名,不暗示已经共享能力。
- 设备行采用紧凑列表,不使用大卡片墙。
- 已配对设备行可展开简要能力;完整信息进入设备详情抽屉。
- 离线设备保留在“已配对设备”,附近广播消失不删除配对。
### 2.1 配对对话框
```text
与 Design-Mac 配对
验证码 824 193
设备 Design-Mac · macOS
设备指纹 7A:31:...:D9
有效时间 01:42
请确认另一台设备显示相同信息。
[取消] [信息一致,确认配对]
```
双方尚未确认时显示独立进度。拒绝、超时和连接失败保留设备行并给出重新配对,不显示成功
通知。成功使用应用通知,焦点回到对应设备行。
### 2.2 设备详情
详情使用右侧抽屉,包含“概览”“能力”“授权”“活动”四个内部页签。未配对设备不能打开
能力和授权。撤销配对放在抽屉底部 danger zone,并说明活动任务和历史记录影响。
## 3. 网关页签
```text
网关 [添加网关]
研发组织网关
研发中心 · 成员
已连接 · 策略同步于 14:32
可用能力 18 项 · 本机发布 2 项 [...]
```
空状态说明网关是附加连接,局域网无需网关即可使用。主操作为“添加网关”。
### 3.1 添加网关
对话框只要求 HTTPS 地址,也可由邀请链接预填:
```text
网关地址
[https://share.example.com]
[取消] [继续登录]
```
地址通过 Main 验证后打开系统浏览器。应用内显示“等待浏览器登录”和取消入口,不嵌入第三方
登录页。回调成功后显示服务名、组织和账号,用户确认才保存连接。
网关详情抽屉包含连接、组织身份、可用能力、本机发布和诊断。移除连接明确说明只移除本机
连接,不删除组织账号或服务端设备记录。
## 4. 本机共享页签
页首状态摘要不能替代逐项开关:
```text
本机共享
已开放 2 项能力 · 局域网 1 项 · 网关 2 项
搜索能力 [范围筛选] [状态筛选]
文档转 Word [Switch 开]
范围:研发组织、GPU-Workstation
允许:查看、远程调用
确认:每次调用 [编辑]
```
能力关闭时保留配置摘要并标为“已暂停”。点击“编辑”打开共享设置对话框:
- 目标范围使用分组多选,不混合 Peer、网关组织和用户组。
- 允许动作使用 Checkbox。
- “每次确认”使用 Switch。
- 输入类型、大小、并发、时间段和有效期使用对应表单控件。
- 权限较高的能力在提交区显示具体影响,不用泛化风险等级。
- 提交按钮附近再次列出目标范围和允许动作。
能力尚未注册为可共享时不展示虚假的禁用行。列表为空时说明当前没有可共享能力,并提供前往
“能力与工具”的次操作。
## 5. 活动记录页签
使用 `dashboard` 所需的表格能力,但保持在设置 PageShell 内横向滚动:
```text
方向 能力 对端/来源 状态 时间
发出 图像生成 研发组织网关 已完成 14:36
收到 文档转 Word GPU-Workstation 正在执行 14:31
```
工具栏提供方向、来源、状态、时间和搜索。详情抽屉显示任务 ID、能力版本、授权依据、路由、
数据类型和大小、执行链、耗时、结果及取消/撤销入口。正文和文件内容默认不展示。
## 6. 能力选择与调用确认
远端能力进入现有能力目录时,在名称旁持续显示来源标签。多个提供者使用菜单选择:
```text
图像生成
提供者 [自动选择:GPU-Workstation v]
```
调用需要确认时使用对话框:
```text
调用“合同风险检查”
提供者 法务服务商
执行位置 外部组织
将发送 当前输入;contract.docx(1.8 MB)
连接方式 ShareServer 中继
网关可见正文 否
下游调用 无
[取消] [仅本次允许] [按此范围始终允许]
```
组织策略不允许保存 Grant 时不显示“始终允许”。按钮文字必须表达作用域,不能使用“确定”。
## 7. 执行活动
会话和任务中的远端工具活动至少显示:
```text
合同风险检查 · 法务服务商
正在执行 · 已发送 1 个文件(1.8 MB) [停止]
```
展开后显示路由、能力版本和执行链。跨组织来源使用文字“外部组织”,不能只靠颜色。进入结果
未知时显示持久警告和“查看活动详情”,不提供普通的自动重试按钮。
## 8. 状态与错误
- 首次为空、筛选无结果、加载失败、无权限和离线分别使用不同状态。
- 缓存目录必须显示“上次更新于”,且离线时不能显示为当前可用。
- 身份变化使用阻塞状态,唯一修复路径是撤销后重新配对。
- 网关认证过期在对应网关行提供“重新登录”。
- 发布字段错误就地显示;异步连接错误显示在对象行或详情中。
- 配对成功、网关保存和共享发布成功使用应用通知,不重复显示页内成功横幅。
## 9. 无障碍与响应式
- 四个页签使用 `tablist/tab/tabpanel`、方向键、Home、End 和可见焦点。
- 持久二元状态使用 `role="switch"`;范围和动作多选使用 Checkbox。
- 表格在窄窗口切换为保留同样字段的列表,不隐藏提供者、状态或执行位置。
- 指纹和长网关地址允许换行并提供复制按钮,不依靠水平裁切。
- 对话框关闭后焦点回到触发控件;浏览器登录返回后恢复到等待登录区域。
- 状态包含文字和图标,不以颜色作为唯一信号。
## 10. 固定词汇
使用“设备发现与共享”“局域网”“网关”“本机共享”“附近设备”“已配对设备”“允许其他
设备发现本机”“结果未知”“外部组织”。不使用“扫描机器”“公网共享”“永久信任整台设备”
或把 ShareServer 简称为“云端”。
@@ -33,6 +33,8 @@ Task、Job、Subjob、Run 和 Subagent 的对象关系以
## 功能边界
- `process_execute` 和 `subagent_delegate` 是 GoodBuddy 直连模型内置工具,不是外部 MCP。
- 直连文本模型的普通问答、工具轮次和上下文摘要会在尚无可见输出时,对瞬时网络错误或
单次请求超时最多自动重试 3 次。
- OpenCode、Continue 和 DeepSeek Harness 继续使用各自原生执行与委派能力。
- Ask 不允许进程执行;Subagent 若在 Ask 中使用,只能继承 Ask 的只读能力。
- Execute 表示用户授权当前执行空间账号的完整能力,不增加第二套工具审批或权限档位。
@@ -42,6 +44,7 @@ Task、Job、Subjob、Run 和 Subagent 的对象关系以
## 相关功能
- [模型连接请求定制](../model-connections/README.md):定义连接级 Header/Body、合并优先级和 Runtime 支持矩阵。
- [本机工具环境](../local-tool-environment/README.md):为新进程提供 Node、Python 和 PATH。
- [助手工作栏与执行空间](../assistant-workbar/prd.md):定义本机/SSH 执行位置和活动归属。
- [远程主机与远程执行](../remote-host/README.md):定义当前托管 SSH Runtime 边界。
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 已实施 |
| 版本 | 0.2 |
| 日期 | 2026-09-01 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 关联 PRD | [PRD](./prd.md) |
| 关联故事 | [User Stories](./user-stories.md) |
@@ -22,6 +22,8 @@
8. 子任务是父请求内部 Job/Subjob,不创建顶层 Task 或独立 Conversation。
9. 父请求取消、Runtime 释放或应用退出会传播到全部活动子级和进程。
10. OpenCode、Continue 和 DeepSeek Harness 的原生能力不被本功能替换或包装。
11. 直连模型只在尚未显示文本或推理内容时自动重试瞬时网络错误或请求超时,最多重试
3 次;用户取消和已显示的部分输出禁止重放。
## 2. 状态维度
@@ -33,6 +35,7 @@
| 本机 Shell | `available`、`unavailable` |
| Subagent 深度 | `0`、`1` |
| 父请求 | `active`、`cancelling`、`terminal` |
| 模型请求重试 | `initial`、`retry 1..3`、`terminal` |
## 3. 工具可见性决策表
@@ -158,7 +161,26 @@ type SubagentDelegateResult = {
已经提交的正常终态不被稍后到达的取消覆盖。父请求进入终态后,迟到的子级文本和工具事件
只用于资源清理,不再写入会话。
## 11. 逻辑完整性评估
## 11. 模型请求重试
```text
attempt
├─ success → completed
├─ cancel → cancelled
├─ non-retryable failure → failed
├─ transient failure after visible output → failed_with_partial_output
└─ transient failure before visible output
├─ retry count < 3 → backoff → attempt
└─ retry count = 3 → failed
```
- 瞬时失败包括常见连接重置、连接/网络不可达、DNS 临时失败、连接管道中断和请求超时。
- 三次重试分别等待 500 ms、1 s 和 2 s;每次尝试重新建立请求并使用独立请求超时。
- 普通问答、工具模型轮次和上下文摘要使用同一规则。
- 重试不重复已经执行的工具;工具只会在模型完整返回一轮工具调用后开始执行。
- 退避等待绑定父请求取消信号,取消优先于启动下一次尝试。
## 12. 逻辑完整性评估
| 领域 | 结论 |
| --- | --- |
@@ -167,6 +189,7 @@ type SubagentDelegateResult = {
| 本机三平台 | 完整,统一契约和平台 Shell 决策已定义 |
| Subagent 递归 | 完整,深度固定为 1 |
| 取消与失败 | 完整,父子和进程传播及优先级已定义 |
| 模型网络恢复 | 完整,有限重试、输出重放和取消边界已定义 |
| 产品对象 | 完整,遵循 Task/Job 权威模型 |
| SSH 远端执行 | 明确不在首版;未来必须增加 Agent 后端后才能开放 |
+12 -2
View File
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 已实施,跨平台 CI 待验证 |
| 版本 | 0.2 |
| 日期 | 2026-09-01 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 适用产品 | GoodBuddy 桌面端 |
| 目标平台 | Windows、macOS、Linux,x64 与 arm64 |
| 关联领域 | [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) |
@@ -169,6 +169,14 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
- 不改变现有 Agent Runtime 的工具清单或权限语义。
- 不修改系统 PATH、Shell Profile 或用户终端会话。
### FR-11 模型请求重试
- 直连文本模型的普通问答、工具轮次和上下文摘要遇到瞬时网络错误或单次请求超时时,
在尚未显示文本或推理内容的前提下最多自动重试 3 次。
- 重试使用递增等待时间并显示当前次数;用户取消必须立即终止等待和后续请求。
- 一旦本次模型调用已经显示文本或推理内容,不自动重放该调用,避免重复回答。
- 非网络类 Provider 错误、协议错误和用户取消不进入自动重试。
## 6. 产品验收
- [ ] Windows 直连模型 Execute 使用 PowerShell 创建文件、运行测试并读取退出码。
@@ -182,4 +190,6 @@ Ask 禁止命令和写入。Execute 是用户对当前执行空间账号的完
- [ ] 取消父请求和退出应用后没有残留 GoodBuddy 命令进程。
- [ ] 本机工具环境选择对新命令生效,Main 持有的模型凭据不进入子进程。
- [ ] 子任务不进入 Task Center,不创建独立可导航 Conversation。
- [ ] TLS 建连重置和请求超时在未输出内容时最多自动重试 3 次,成功后继续原请求。
- [ ] 重试等待可取消,已显示部分内容的失败不自动重放。
- [ ] 全量测试、类型检查、Lint 和生产构建通过。
+17 -3
View File
@@ -6,8 +6,8 @@
| --- | --- |
| 文档类型 | 功能进度 |
| 状态 | 已实施,本机验收完成 |
| 版本 | 0.2 |
| 日期 | 2026-09-02 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 关联入口 | [直连模型 Agent 能力](./README.md) |
## 当前结论
@@ -55,6 +55,8 @@
- [x] Ask/Execute、Runtime target 和本机执行空间双重检查。
- [x] DeepSeek Harness、OpenCode 和 Continue 隔离。
- [x] 通用工具活动和“设置 > MCP > 直连模型”编程能力目录。
- [x] 普通问答、工具轮次和上下文摘要的瞬时网络错误及请求超时最多自动重试 3 次。
- [x] 递增退避、重试状态、取消传播和已显示输出不重放。
### 阶段 3:编程 Subagent
@@ -104,7 +106,19 @@
0、委派编程 Subagent 再次运行最终程序并综合结果;最终源码成功验收运行包含 8 次真实
模型调用。
- BigToken 连接的首次验收在 TLS 建立前被 `ECONNRESET` 中断,未进入模型工具路径;切换
到仓库已有的 DeepSeek 连接后完成验证。
到仓库已有的 DeepSeek 连接后完成验证。2026-09-03 已针对该故障补充统一模型请求重试。
## 2026-09-03 模型请求重试验证证据
- `ModelAgentRuntime` 对普通问答、工具轮次和上下文摘要统一处理瞬时 Node/Undici 网络错误
与请求超时;首次失败后最多重试 3 次,退避为 500 ms、1 s、2 s。
- 聚焦测试覆盖嵌套 `fetch failed` → `ECONNRESET` 恢复、工具轮次恢复、四次尝试后终止、
退避取消,以及已显示部分流式输出时不重放。
- `npm test -- --run src/main/agent/model-runtime.test.ts`:
`72 passed, 1 skipped`。
- 全量 `npm test`:`3484 passed, 51 skipped`。
- `npm run typecheck`、`npm run lint` 和 `npm run build`:通过。
- 最小真实直连文本模型验证:`1 passed, 13 skipped`,实际模型调用 1 次。
- 全量 `npm test`:`3410 passed, 50 skipped`。
- `npm run typecheck`:通过。
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 已实施 |
| 版本 | 0.2 |
| 日期 | 2026-09-01 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 关联 PRD | [PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| 相关架构 | [助手工作栏与执行空间](../assistant-workbar/prd.md)、[本机工具环境](../local-tool-environment/technical-design.md) |
@@ -319,12 +319,19 @@ type SubagentActor =
| Shell 无法启动 | 工具失败 |
| cwd 无效或越界 | 工具调用前失败 |
| 超时/取消 | 终止进程树,返回已有输出和原因 |
| 直连模型瞬时网络错误/请求超时,尚无可见输出 | 500 ms、1 s、2 s 退避,最多自动重试 3 次 |
| 直连模型失败,已有文本或推理输出 | 保留部分输出并失败,不自动重放 |
| 子级模型失败 | 返回 `failed`、部分输出和错误 |
| 子级工具失败 | 由子模型处理;最终无法完成时返回子级失败 |
| 父请求已终止 | 拒绝新调用,丢弃迟到内容并清理 |
不要用宽泛 catch 抹掉取消、超时、Provider HTTP 状态或进程退出上下文。
模型请求重试由 `ModelAgentRuntime` 的统一流式请求边界处理,覆盖普通问答、工具轮次和复用
同一 Runtime 的上下文摘要。重试分类检查错误及有界 `cause` 链中的 Node/Undici 网络错误
码;每次尝试创建独立超时信号。退避等待监听父请求取消。只有完整模型轮次返回后才执行
工具,因此未产生可见文本或推理内容的重试不会重复执行工具。
## 11. 性能
- Shell 探测按执行服务实例缓存,不在每轮模型调用重复探测。
@@ -347,6 +354,8 @@ type SubagentActor =
- 深度 1 过滤、并发 3、队列 20、取消传播和部分输出。
- 编程 Subagent 不创建 Task/Conversation。
- DeepSeek Harness/OpenCode/Continue 不获得本功能工具。
- TLS 建连 `ECONNRESET`、Undici 连接超时、单次请求超时、三次重试上限和退避取消。
- 已产生文本或推理增量后发生网络错误时不自动重放。
### 12.2 本机集成测试
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 已实施 |
| 版本 | 0.2 |
| 日期 | 2026-09-01 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 设计系统 | [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md) |
| 关联 PRD | [PRD](./prd.md) |
| 关联逻辑 | [功能逻辑设计](./logic-design.md) |
@@ -117,6 +117,8 @@ GoodBuddy 服务崩溃。
- Runtime 初始化、Shell 探测或全局服务失败可通过应用通知提示,并在设置状态卡提供重试。
- 同一错误不同时显示活动块副本和通知副本。
- 失败后保留用户 Prompt、命令摘要、工作目录和已有输出。
- 直连模型自动重试通过现有聊天状态显示“模型网络请求失败,正在重试(n/3)”,不增加
独立按钮、弹窗或通知副本;用户仍可使用当前停止操作取消请求。
## 7. 键盘与无障碍
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 状态 | 已实施,跨平台 CI 待验证 |
| 版本 | 0.2 |
| 日期 | 2026-09-01 |
| 版本 | 0.3 |
| 日期 | 2026-09-03 |
| 关联 PRD | [直连模型 Agent 能力 PRD](./prd.md) |
| 关联逻辑 | [功能逻辑设计](./logic-design.md) |
@@ -70,6 +70,14 @@ Given 命令仍在运行,When 用户取消父请求,Then 当前 Shell 及其
Given 命令输出超过上限,When 工具返回结果,Then 保留有用的开头和结尾并明确
`truncated: true`,And 模型与用户都不会把结果理解为完整日志。
### US-A7 从瞬时模型网络故障恢复
关联:FR-7、FR-9、FR-11。
Given 直连模型在 TLS 建连、网络传输或单次请求超时时失败且尚未显示输出,When 请求仍未
取消,Then GoodBuddy 显示当前重试次数并使用递增等待时间最多重试 3 次,And 成功后继续
原问答或工具轮次。Given 已经显示文本或推理内容,Then 不自动重放该调用。
## 3. Epic B:编程闭环
### US-B1 修改后运行验证
+18 -2
View File
@@ -1,6 +1,7 @@
# 知识库
知识库负责资料导入、解析、分块、索引、检索、图谱和带来源的知识问答。
知识库负责资料导入、解析、分块、索引、检索、图谱和带来源的知识问答,也负责将
Dify、FastGPT 和 RAGFlow 中已有的知识库接入同一检索范围。
## 文档导航
@@ -10,5 +11,20 @@
| [User Stories](./user-stories.md) | 知识使用者、维护者和调试者场景 |
| [本地文本向量技术设计](./local-embedding-technical-design.md) | 本地与兼容向量连接、模型包、索引和进程边界 |
| [知识检索评估](./retrieval-evaluation.md) | 检索质量评估方法、数据集、指标和门槛 |
| [外部知识库接入 PRD](./external-knowledge-prd.md) | 外部实例、知识库绑定、检索范围和产品验收 |
| [外部知识库 User Stories](./external-knowledge-user-stories.md) | 实例配置、添加、使用和故障恢复场景 |
| [外部知识库逻辑设计](./external-knowledge-logic-design.md) | 状态、不变量、配置优先级和失败规则 |
| [外部知识库 UI 设计](./external-knowledge-ui-design.md) | 知识库页面、外部实例管理和创建向导交互 |
| [外部知识库技术设计](./external-knowledge-technical-design.md) | Provider Adapter、凭据、持久化、IPC 和检索标准化 |
当前尚未拆出统一功能逻辑、UI 设计和功能进度文档。
## 术语
- **外部实例**:一个由用户配置的 Dify、FastGPT 或 RAGFlow 服务地址及其认证信息。
- **外部知识库绑定**:GoodBuddy 中的知识库记录与外部实例内一个远端知识库 ID 的只读关联。
- **Provider 配置**:某一 Provider 检索接口独有的设置,例如 RAGFlow 图谱检索。
外部知识库只提供检索结果。GoodBuddy 不接入外部 App、Chat、Assistant、Workflow
或 Agent,也不在外部实例中创建、修改、上传或删除知识库内容。远端资料不会批量同步
或进入本地索引;实际用于回答的有界片段会随引用保存在本地会话中。
当前尚未拆出覆盖整个知识库功能家族的统一逻辑、UI 设计和功能进度文档。
@@ -0,0 +1,343 @@
# 外部知识库逻辑设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计稿 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) |
| 关联场景 | [外部知识库 User Stories](./external-knowledge-user-stories.md) |
## 1. 不变量
### EK-I-01 单一知识库身份
本地知识库和外部知识库都使用 GoodBuddy `knowledgeBaseId` 参与聊天范围、检索模式、
工具授权和活动记录。Provider、实例 ID 和远端知识库 ID 不能替代该授权单位。
对应:`EK-FR-10`、`EK-FR-11`、`EK-US-C1`。
### EK-I-02 外部绑定只读
GoodBuddy 对外部实例只执行连接测试、目录读取、详情读取和检索。添加、编辑、移除
均只修改本机记录,不调用远端创建、更新、上传或删除接口。
对应:`EK-FR-15`、`EK-US-A3`。
### EK-I-03 凭据不离开 Main
API Key 明文只在 Main 内完成解密并短暂进入请求头。Renderer、Preload 返回值、Runtime、
Provider 配置、知识库元数据、诊断、日志和错误只能获得凭据状态。
对应:`EK-FR-02`、`EK-FR-11`。
### EK-I-04 Provider 不能定义界面
远端响应只提供数据和能力证据。可显示字段、字段类型、范围、默认值和请求映射均来自
GoodBuddy 内置、版本化的 Provider Adapter。
对应:`EK-FR-09`、`EK-US-D1`。
### EK-I-05 无静默切换
请求固定使用绑定保存的实例、远端知识库和 Provider 配置。失败时不切换实例、公共云
地址、认证方式或其他知识库。
对应:`EK-FR-14`、`EK-FR-16`、`EK-US-C4`。
### EK-I-06 零结果与失败分离
成功响应经过过滤后没有片段才是零结果。超时、认证、权限、限流、目标不存在、服务错误
和响应无效均为失败,不能折叠成零结果。
对应:`EK-FR-14`、`EK-US-D4`。
### EK-I-07 外部证据不可信
Provider 返回的名称、内容、元数据、URL 和错误均视为不可信输入,经过长度、数量、类型
和 URL 校验后才能显示或进入 Runtime。内容中的指令不能改变系统或工具权限。
对应:`EK-FR-12`。
### EK-I-08 Provider 配置不跨类型复用
Dify、FastGPT 和 RAGFlow 使用各自的判别配置。切换知识库类型或实例 Provider 时,旧类型
配置不参与新请求,也不能按同名字段自动迁移。
对应:`EK-FR-06`、`EK-FR-09`。
## 2. 对象关系
```text
ExternalKnowledgeInstance 1 ─── n ExternalKnowledgeBinding
│
│ 1:1
▼
KnowledgeBase
```
- 实例可以没有绑定。
- 外部知识库只能绑定一个实例和一个远端知识库 ID。
- 同一实例的同一远端知识库默认只允许存在一个绑定,避免重复选择和重复检索。
- 允许不同实例绑定同名或相同远端 ID,因为实例边界不同。
- 本地知识库不存在 `ExternalKnowledgeBinding`。
## 3. 实例状态
实例状态由启停、凭据和最近探测三个维度组成,不能用一个布尔值代替。
### 3.1 持久状态
| 维度 | 值 |
| --- | --- |
| 启停 | `enabled`、`disabled` |
| 凭据 | `configured`、`missing`、`unavailable` |
| 探测 | `untested`、`catalog-ready`、`healthy`、`list-restricted`、`auth-failed`、`unreachable`、`incompatible`、`failed` |
`unavailable` 表示系统安全存储当前无法解密或保存凭据,与用户尚未配置的 `missing` 分开。
### 3.2 派生可用性
| 条件 | 目录读取 | 已绑定知识库检索 | 页面状态 |
| --- | --- | --- | --- |
| disabled | 禁止 | 禁止 | 已停用 |
| missing / unavailable | 禁止 | 禁止 | 缺少凭据 / 凭据不可用 |
| auth-failed | 禁止 | 禁止 | 认证失效 |
| unreachable | 可重试 | 可重试 | 无法连接 |
| incompatible | 禁止 | 禁止 | 接口不兼容 |
| list-restricted | 禁止 | 允许按 ID 验证与检索 | 需手工指定知识库 |
| catalog-ready | 允许 | 创建绑定时验证 | 目录可用,检索待验证 |
| healthy | 允许 | 允许 | 正常 |
| untested | 允许测试 | 已有绑定可尝试检索 | 未测试 |
保存新实例要求至少完成服务可达和认证验证。目录受限可以保存;检索接口尚未通过时,
实例不能用于创建绑定。
## 4. 连接测试状态机
```text
idle
└─ test → connecting
├─ network failure → unreachable
└─ connected → authenticating
├─ 401 → auth-failed
├─ incompatible response → incompatible
└─ authenticated → listing
├─ 403 / unsupported → list-restricted
└─ success
├─ no target → catalog-ready
└─ target selected → probing-retrieval
├─ success / empty → healthy
└─ failure → failed
```
检索探测需要一个目标知识库。新增实例尚未选择目标时,目录成功即可把实例保存为“目录可用,
检索待绑定验证”;创建绑定时补做真实检索。手工模式必须先填写目标 ID,再执行检索探测。
检索请求正常完成但返回零条结果时,说明接口和目标可访问,可继续创建绑定;失败状态按错误
分类处理。
每次测试拥有 request ID 和取消信号。用户修改 Provider、地址、凭据或实例后,旧 request ID
的结果作废。
## 5. 目录读取规则
1. 只有选定、启用且凭据可用的实例可以读取目录。
2. Provider 列表接口按适配器定义分页,Main 最多向 Renderer 返回 500 个目录项。
3. FastGPT 列表中的文件夹和知识库必须按类型区分;文件夹不可作为绑定目标。
4. 列表搜索优先使用 Provider 支持的服务端过滤,不支持时只过滤已经有界加载的当前结果。
5. 下拉列表以远端 ID 为值,以远端名称为标签;重名时追加短 ID 或父级名称。
6. 已经绑定的目标显示“已添加”并禁止重复添加。
7. 远端列表项名称变化不自动修改 GoodBuddy 显示名称,只更新远端名称快照。
8. 刷新失败时可以保留上次成功目录供查看,但不得用旧目录完成新绑定,直到目标重新验证。
## 6. 创建向导状态
### 6.1 类型
```text
local | dify | fastgpt | ragflow
```
`local` 使用现有字段和验证。外部类型使用以下步骤状态:
```text
provider selected
→ instance selected
→ catalog loading / manual ID
→ remote target selected
→ configuration valid
→ retrieval verified
→ binding created
```
前置字段变化时,后续状态按下表失效:
| 变化 | 必须清除或重新验证 |
| --- | --- |
| 类型 | 实例、远端目标、全部 Provider 配置、测试结果 |
| 实例 | 远端目标、能力快照、Provider 配置、测试结果 |
| 远端目标 | 远端详情、能力适用性、测试结果 |
| 通用或 Provider 配置 | 测试结果 |
| 显示名称 | 不清除测试结果 |
## 7. 配置模型
### 7.1 通用配置
| 字段 | 范围 | 默认值 | 语义 |
| --- | --- | --- | --- |
| `resultLimit` | 1 至 20 | 6 | Provider 结果标准化后最多保留的片段数 |
| `requestTimeoutMs` | 1,000 至 60,000 | 15,000 | 单次远端请求总时限 |
| `maxSnippetCharacters` | 500 至 8,000 | 4,000 | 单片段进入 IPC 和 Runtime 前的字符上限 |
Provider 请求仍使用自己的候选数量或 Token 预算。`resultLimit` 只定义 GoodBuddy 最终保留
上限,不改写 Provider 字段的单位。
### 7.2 Dify 配置
| 字段 | 初始范围 | 说明 |
| --- | --- | --- |
| `useDatasetDefaults` | boolean,默认 true | 开启时不发送 `retrieval_model` 覆盖 |
| `searchMethod` | keyword / semantic / full-text / hybrid | 对应 Dataset Retrieve 检索方式 |
| `providerTopK` | 1 至适配器上限 | Dify 返回候选数量 |
| `scoreThresholdEnabled` | boolean | 是否向 Dify 发送分数阈值 |
| `scoreThreshold` | 0 至 1 | 开启阈值后生效 |
| `rerankingMode` | weighted-score / reranking-model | 覆盖检索配置时必填 |
| `rerankingProvider`、`rerankingModel` | 受支持的远端标识 | 选择模型重排时必填 |
| `keywordWeight`、`vectorWeight` | 0 至 1,总和为 1 | 加权混合模式时生效 |
| `embeddingProvider`、`embeddingModel` | 受支持的远端标识 | Dify 要求时从知识库详情选择或填写 |
`useDatasetDefaults` 开启时,其余覆盖字段隐藏且不发送。关闭后必须收集当前 Dify 接口
构造 `retrieval_model` 所需的完整字段,缺少 Provider 或模型标识时不能保存。
### 7.3 FastGPT 配置
| 字段 | 初始范围 | 说明 |
| --- | --- | --- |
| `searchMode` | embedding / fullTextRecall / mixedRecall | 搜索方式 |
| `tokenLimit` | 适配器限制,最高 20,000 | Provider 返回内容预算,不等同结果条数 |
| `similarity` | 0 至 1 | 最低相关度 |
| `usingRerank` | boolean | 使用 FastGPT 重排 |
初始版本不开放 FastGPT 查询优化字段,避免把查询发送给 FastGPT 配置的 LLM。检索和重排
仍可能产生远端 Embedding、Rerank 用量及 API 审计记录,测试与保存配置时必须说明。
### 7.4 RAGFlow 配置
| 字段 | 初始范围 | 说明 |
| --- | --- | --- |
| `similarityThreshold` | 0 至 1 | 最低综合相似度 |
| `vectorSimilarityWeight` | 0 至 1 | 向量分数权重 |
| `knnTopK` | 1 至适配器上限 | KNN 候选数量,不使用已弃用 `top_k` |
| `rerankCandidatesCount` | 0 或正整数 | 重排候选数量,实例支持时显示 |
| `rerankId` | 可选受限字符串 | 远端重排模型 ID |
| `useKg` | boolean,默认 false | 仅在已有图谱证据成立时允许开启 |
| `includeKnowledgeCompilation` | boolean,默认 false | 实例支持时显式发送,禁止依赖服务端默认 true |
| `metadataCondition` | 后续受控编辑器 | 初始版本不接受任意 JSON 文本 |
`parser_config.graphrag.use_graphrag` 描述构建状态,不是检索参数。只有列表、详情或受控
探测提供已有图谱的证据时,界面才允许开启 `useKg`。Knowledge Compilation 是另一项
检索能力,必须与旧 GraphRAG 分别显示和保存。
## 8. 配置优先级
生效请求由以下信息组成:
```text
GoodBuddy 固定安全上限
∩ 当前 Adapter 支持矩阵
∩ 当前绑定保存的通用配置和 Provider 配置
= 本次请求配置
```
- 固定安全上限不可被实例或用户扩大。
- 远端知识库自身默认值只用于创建表单的建议值,不在每次请求时覆盖本地已保存值。
- Provider 增加新字段不会自动启用。
- 当前实例版本离开 Adapter 支持矩阵,或 Adapter 新版本无法解析已保存配置时,绑定标记
配置失效;用户确认前不重写配置。
- 服务端忽略未知字段不能视为成功。Adapter 只发送已知且受支持的字段。
## 9. 绑定状态
| 状态 | 条件 | 可进入聊天范围 |
| --- | --- | --- |
| `ready` | 实例可用,目标最近验证成功,配置有效 | 是 |
| `untested` | 迁移或新增后尚未完成真实检索 | 否 |
| `instance-disabled` | 实例停用 | 否 |
| `credential-error` | 凭据缺失、不可用或认证失败 | 否 |
| `remote-missing` | 远端目标返回不存在 | 否 |
| `config-invalid` | 已保存配置不再受支持 | 否 |
| `temporarily-unavailable` | 超时、限流或服务错误 | 是,但本次显示失败并允许重试 |
短暂故障不立即永久禁用绑定;认证失败、目标不存在和配置失效会改变持久状态。
## 10. 多知识库检索
1. 读取请求冻结的 GoodBuddy 知识库 ID 集合。
2. 本地库进入本地检索,外部库按实例和绑定构造独立请求。
3. 同一请求最多选择现有契约允许的 20 个知识库,并遵循全局并发上限。
4. 每个库单独记录成功、零结果、失败、耗时和截断。
5. 成功结果按知识库保留来源,不比较不同 Provider 的原始分数。
6. 上下文拼装使用现有总字符预算,在各成功知识库间采用确定性配额。
7. 全部失败时返回检索失败;至少一个成功时返回证据和部分失败诊断。
初始版本不对跨 Provider 结果运行统一学习型重排。后续如增加,必须保留原始 Provider
分数和重排来源,且单独定义评估数据。
## 11. 错误分类
| 稳定错误码 | 用户状态 | 持久影响 |
| --- | --- | --- |
| `EXTERNAL_KB_NETWORK` | 无法连接实例 | 无 |
| `EXTERNAL_KB_TIMEOUT` | 检索超时 | 无 |
| `EXTERNAL_KB_AUTH` | 认证失效 | 实例标记认证失败 |
| `EXTERNAL_KB_FORBIDDEN` | 没有访问权限 | 目录或绑定按操作标记受限 |
| `EXTERNAL_KB_NOT_FOUND` | 远端知识库不存在 | 绑定标记目标不存在 |
| `EXTERNAL_KB_RATE_LIMITED` | 请求过于频繁 | 无,可显示建议等待 |
| `EXTERNAL_KB_INCOMPATIBLE` | 接口或配置不兼容 | 实例或绑定标记不兼容 |
| `EXTERNAL_KB_INVALID_RESPONSE` | 返回内容无法读取 | 无,记录脱敏诊断 |
| `EXTERNAL_KB_SERVER` | 外部服务处理失败 | 无 |
| `EXTERNAL_KB_CANCELLED` | 已取消 | 无 |
Provider 原始正文不能直接成为用户错误。Adapter 将其映射为稳定错误码和有界短消息。
## 12. 删除规则
| 操作 | 无绑定 | 有绑定 |
| --- | --- | --- |
| 停用实例 | 直接停用 | 允许;绑定变为不可用 |
| 清除凭据 | 直接清除 | 需要确认;绑定保留但不可用 |
| 删除实例 | 直接删除 | 阻止直接删除,要求同时移除本地绑定 |
| 移除绑定 | 删除本地绑定 | 不影响实例和远端知识库 |
删除确认不得使用“删除远端知识库”等文案。
## 13. 需求追踪
| 需求 | 主要逻辑 | User Story |
| --- | --- | --- |
| `EK-FR-01` 至 `EK-FR-03` | 实例状态、连接测试状态机 | `EK-US-A1`、`EK-US-A2` |
| `EK-FR-04`、`EK-FR-05` | 目录读取规则、手工目标验证 | `EK-US-B1`、`EK-US-B2` |
| `EK-FR-06`、`EK-FR-07`、`EK-FR-08`、`EK-FR-09` | 创建向导状态、配置模型和优先级 | `EK-US-B3`、`EK-US-B4`、`EK-US-D1`、`EK-US-D3` |
| `EK-FR-10` 至 `EK-FR-13` | 单一身份、多知识库检索和引用规则 | `EK-US-C1` 至 `EK-US-C3`、`EK-US-D2` |
| `EK-FR-14` | 绑定状态、错误分类和部分失败 | `EK-US-C4`、`EK-US-D4` |
| `EK-FR-15` | 对象关系和删除规则 | `EK-US-A3` |
| `EK-FR-16` | 无静默切换、请求冻结和传输风险确认 | `EK-US-C2` |
## 14. 逻辑完整性
已确定:
- 产品入口、对象关系、只读边界和凭据边界。
- 目录读取与手工 ID 的切换条件。
- 三种 Provider 的初始配置范围和 RAGFlow 图谱语义。
- 多知识库部分失败、删除和版本变化规则。
实施前仍需技术验证:
- 选定支持的 Dify、FastGPT、RAGFlow 最低版本,形成固定支持矩阵并保存真实响应 fixture。
- 验证各版本检索探测的 API 审计、用量和内容修改行为。
- 确定 Provider 返回 URL 的安全打开策略;未验证前只显示文本定位和远端 ID。
- 依据真实延迟确定默认并发上限和跨知识库上下文配额。
@@ -0,0 +1,254 @@
# 外部知识库接入 PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计稿 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 适用产品 | GoodBuddy 桌面端 |
| 关联逻辑 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) |
| 关联界面 | [外部知识库 UI 设计](./external-knowledge-ui-design.md) |
| 技术设计 | [外部知识库技术设计](./external-knowledge-technical-design.md) |
## 1. 背景
不少企业已经在内网部署 Dify、FastGPT 或 RAGFlow,并在其中维护制度、产品、研发
和客服知识。继续要求用户把相同资料导入 GoodBuddy,会产生重复存储、版本分叉和
额外维护成本。
GoodBuddy 需要把这些已有知识库加入当前知识库列表和聊天检索范围。外部系统只
承担知识存储与检索,问题理解、上下文组织、模型回答、Agent 和工具执行仍由
GoodBuddy 负责。
## 2. 产品决策
1. 外部知识库属于现有“知识库”功能,不新增一级页面,也不放入全局设置中心。
2. 知识库页面标题区增加“管理外部实例”;现有“新建知识库”向导增加知识库类型。
3. 一个外部实例代表一个 Provider 服务地址和一组凭据,可绑定多个远端知识库。
4. 一个外部知识库绑定在 GoodBuddy 中仍是一个 `KnowledgeBase`,可以进入现有聊天
范围、每次先检索、模型按需检索和检索测试流程。
5. 外部知识库保持远端检索,不批量同步或本地索引远端文档、分块、向量和图谱,也不
显示“已同步”。实际进入回答的有界片段和引用随会话保存在本机,并遵循会话删除规则。
6. Dify、FastGPT 和 RAGFlow 只使用知识库列表、详情和检索接口。App、Chat、
Assistant、Workflow 和 Agent API 均不接入。
7. 用户点击“添加”只创建本地绑定,不在外部实例中创建知识库;移除绑定也不删除
远端知识库。
8. 通用配置只包含跨 Provider 语义一致的选项。检索算法、阈值、重排和图谱开关
由 Provider 配置承担。
9. Provider 专属字段由 GoodBuddy 内置适配器定义。外部服务器不能下发任意表单、
脚本或字段 Schema。
10. API Key 在用户输入表单和受限 IPC 中短暂存在,由 Main 进程加密保存并用于请求;
保存后不回传 Renderer,也不进入 Runtime、日志、普通元数据和检索引用。
## 3. 目标
### 3.1 用户目标
- 在知识库页面配置企业现有的 Dify、FastGPT 或 RAGFlow 实例。
- 新建知识库时选择 Provider、实例和远端知识库,不手工复制资料。
- 在同一知识库列表和聊天选择器中使用本地与外部知识库。
- 按 Provider 能力调整检索参数,例如 RAGFlow 图谱检索。
- 明确看到查询发送到哪个实例、检索是否成功以及引用来自哪里。
- 连接失效后能更新凭据、重试或更换远端知识库,不丢失其他知识库配置。
### 3.2 产品目标
- 保持现有知识库 ID 作为聊天和 Runtime 的统一授权单位。
- 让三种 Provider 的结果进入同一有界证据与引用契约。
- 保留每种 Provider 的有效检索能力,不用最低公共字段覆盖全部差异。
- 将实例认证与知识库绑定分离,使一个实例可以服务多个绑定。
- 对 Provider 版本差异、列表权限关闭和部分检索失败提供可诊断状态。
## 4. 非目标
本功能不包含:
- 调用 Dify App、FastGPT 应用、RAGFlow Chat 或任何外部 Agent。
- 使用外部系统生成最终回答或维护外部会话。
- 在外部实例中创建、编辑、删除知识库、文档或分块。
- 向外部实例上传文件、网页、剪贴板、完整会话或本地知识库内容。
- 批量同步或本地索引外部文档、分块、Embedding、图谱或模型配置。回答实际引用的
有界片段仍会随会话保存在本机。
- 跨实例复制、迁移或统一管理外部知识内容。
- 企业 SSO、组织级凭据下发、文档级 ACL 映射或 ShareServer 管理策略。
- 自定义脚本适配器和任意 HTTP 请求模板。兼容检索 API 需要独立设计。
- 把不同 Provider 的原始分数解释成可直接比较的统一质量分数。
## 5. 核心对象
| 对象 | 定义 | 用户可执行操作 |
| --- | --- | --- |
| 外部实例 | Provider、服务地址、凭据和已探测能力 | 添加、测试、编辑、停用、删除 |
| 外部知识库目录项 | 实例列表接口返回的远端知识库摘要 | 刷新、选择 |
| 外部知识库绑定 | GoodBuddy 知识库与远端知识库的只读关联 | 添加、改名、配置、测试、移除 |
| Provider 配置 | 适配器支持的检索参数 | 查看、修改、恢复默认 |
| 标准化检索结果 | Provider 结果转换后的片段、来源和诊断 | 查看、引用、提供给 Runtime |
## 6. 主要流程
### 6.1 管理外部实例
1. 用户在知识库页面点击“管理外部实例”。
2. 页面列出实例名称、Provider、服务地址、状态和已绑定知识库数量。
3. 用户添加实例,填写名称、Provider、服务地址和 API Key。
4. “测试连接”分别验证服务可达、认证、知识库列表和检索能力。
5. 用户保存后,API Key 被加密保存,列表只显示凭据已配置。
列表接口不可用但已知远端知识库 ID 可检索时,实例可以保存为“需手工指定知识库”。
服务不可达或认证失败时不得保存为可用实例;用户可以保留表单并修正。
### 6.2 添加外部知识库
1. 用户点击现有“新建知识库”。
2. 在“知识库类型”中选择“本地”、Dify、FastGPT 或 RAGFlow。
3. 选择外部类型后,左侧选择同 Provider 的外部实例。
4. GoodBuddy 自动读取远端知识库列表,并填入可搜索下拉框。
5. 列表权限不可用时,用户可以手工填写远端知识库 ID。
6. 用户选择一个远端知识库,填写 GoodBuddy 显示名称。
7. 右侧展示通用配置和当前 Provider 的专属配置。
8. 用户填写测试查询并执行一次有界检索;请求成功后创建本地绑定,零结果不算失败。
添加完成后沿用现有知识库规则,把新绑定加入当前聊天选择,但不会重新启用用户此前
取消的其他知识库。
### 6.3 使用外部知识库
外部绑定出现在现有知识库列表和聊天知识范围中,持续显示 Provider 与实例名称。
检索请求只发送本次查询文本和该绑定已保存的检索配置。返回片段经过 Main 校验、
截断和标准化后,才作为不可信证据提供给 Runtime。
一次请求选择多个知识库时,本地与外部检索可以并行。单个外部知识库失败不扩大
检索范围,也不切换其他实例;其他知识库成功时可继续回答,并显示部分失败。
### 6.4 编辑和移除
- 修改显示名称只影响 GoodBuddy 本地名称。
- 修改 Provider 配置只影响后续检索,不写回远端知识库。
- 更换远端知识库属于重新绑定,保存前必须重新测试。
- 删除外部实例前必须处理其所有绑定:取消,或明确移除这些本地绑定。
- 移除绑定只删除 GoodBuddy 中的绑定和检索配置,不调用远端删除接口。已经保存在历史
会话中的引用继续可读,但不再提供依赖当前实例的刷新操作。
## 7. 功能需求
### EK-FR-01 知识库页面入口
知识库页面必须同时提供“管理外部实例”和“新建知识库”。外部实例管理不得迁移到
全局设置中心或创建新的一级导航。
### EK-FR-02 实例配置
实例必须包含唯一 ID、显示名称、Provider、规范化服务地址、凭据状态、启停状态、
最近测试结果和适配器能力快照。用户可以使用 `keep`、`replace`、`clear` 语义更新凭据。
### EK-FR-03 分项连接测试
连接测试必须区分服务可达、认证、列表读取和测试检索。新增实例尚未选择知识库时,
测试检索显示“绑定知识库时验证”,不能伪装成已通过。测试错误应包含可处理原因,
不得返回凭据、Authorization、完整响应正文或内部堆栈。
### EK-FR-04 远端目录读取
选择实例后自动读取有界、可分页的远端知识库列表。列表支持加载、刷新、搜索、空结果、
权限不足和失败状态,且异步旧响应不得覆盖当前实例结果。
### EK-FR-05 手工指定远端 ID
列表接口不受支持或没有权限时,允许手工填写远端知识库 ID 和显示名称。保存前必须通过
该 ID 的详情或检索验证;不能只验证字符串非空。
### EK-FR-06 知识库类型
新建向导必须提供“本地”、Dify、FastGPT、RAGFlow 四种类型。本地类型保持现有创建、
存储模式、图谱和导入行为;外部类型不得显示本地分块、导入或索引设置。
### EK-FR-07 两栏配置
外部类型的创建向导在常规宽度使用左右两栏。左侧负责 Provider、实例、远端知识库和
显示名称;右侧负责通用配置与 Provider 专属配置。窄窗口按来源、通用配置、专属配置
顺序堆叠。
### EK-FR-08 通用配置
通用配置至少包含最终返回片段数、请求超时和单片段最大字符数。数值必须有固定范围,
由 Main 再次校验。Provider 的 Top K、Token 预算或 Page Size 不得冒充同一字段。
### EK-FR-09 Provider 专属配置
Provider 配置使用受版本控制的判别联合。界面只展示当前适配器和已确认实例版本支持的字段;
不支持的已保存字段必须明确显示为失效配置并阻止静默提交。
### EK-FR-10 统一知识库列表
外部绑定与本地知识库出现在同一列表,但必须显示 Provider、实例和“远端检索”状态。
外部绑定不得显示本地文档数、同步、分块、向量覆盖率或本地图谱状态。
### EK-FR-11 统一检索范围
现有聊天知识范围、多选上限、`auto` 和 `always` 模式继续以 GoodBuddy 知识库 ID 工作。
Runtime 只能获得标准化后的有界证据,不能获得实例地址、API Key 或 Provider 原始响应。
### EK-FR-12 引用来源
每条外部结果必须保留 Provider、实例 ID、远端知识库 ID、文档名称和片段。远端文档 ID、
远端片段 ID、页码、URL、分数及其他定位只在 Provider 实际返回时保存,不得补造。
### EK-FR-13 检索测试
现有检索测试入口支持外部绑定。页面显示生效配置、总耗时、Provider 耗时、返回数量、
截断状态和脱敏错误。外部测试不创建聊天、调用 GoodBuddy LLM 或修改远端内容。
### EK-FR-14 故障隔离
认证失败、超时、限流、目标删除、版本不兼容和响应无效必须分别报告。多知识库检索中,
单项失败不得改写为零结果,也不得阻止已成功证据进入回答。
### EK-FR-15 生命周期边界
删除实例、移除绑定、清除凭据和更换远端目标必须经过明确确认。任何操作都不得调用
Provider 的远端创建、更新、上传或删除接口。
### EK-FR-16 网络与数据说明
界面必须说明查询会发送到所选实例,并列出不会发送的内容。请求遵循用户配置的服务地址
和当前内网兼容模式,不自动切换地址、Provider 或公共云端点。HTTP 或放宽证书校验会
传输 API Key,测试和保存实例前必须显示风险并取得针对当前地址的明确确认。
## 8. Provider 初始范围
| Provider | 目录能力 | 检索能力 | 首批专属配置 |
| --- | --- | --- | --- |
| Dify | 知识库列表与详情 | Dataset Retrieve | 使用知识库默认配置,或完整覆盖检索方式、Top K、Score 阈值及 Rerank 模式 |
| FastGPT | 知识库层级列表与详情 | Dataset Search Test | 搜索模式、Token 上限、相似度、Rerank |
| RAGFlow | Dataset 列表与详情 | Retrieval | 相似度、向量权重、KNN Top K、Rerank、图谱检索、Knowledge Compilation |
“首批专属配置”表示适配器允许表达的字段,不保证每个实例版本都支持。具体显隐由能力
探测和[逻辑设计](./external-knowledge-logic-design.md)共同决定。
## 9. 产品验收
1. 用户可以在知识库页面完成三种 Provider 的实例添加、连接测试、编辑和删除。
2. 每种 Provider 至少通过一个真实自部署实例完成列表读取、绑定和检索。
3. 列表权限关闭时,可以通过手工 ID 添加并验证一个可检索知识库。
4. 本地与外部知识库可以同时进入一次聊天检索,引用来源可区分。
5. RAGFlow 支持时可以开启图谱检索;不支持时不发送该字段。
6. 外部知识库详情不出现本地导入、同步、分块和索引操作。
7. 删除绑定或实例不会改变远端知识库和文档。
8. 保存完成后的 Renderer 状态、Runtime、SQLite 普通元数据、日志和错误中均找不到明文
API Key。
9. 超时、401/403、404、429、5xx 和响应格式错误都有独立测试并显示准确状态。
10. 中断、切换实例或关闭向导后,过期列表与测试响应不会写入当前表单。
11. FastGPT 页面说明检索会记录远端 API 使用情况,并可能产生 Embedding 或 Rerank 用量。
12. HTTP 或放宽证书校验的实例未经明确确认不能保存或发送 API Key。
## 10. 外部资料
以下链接用于核对初始适配范围,实施时仍须用受支持版本的真实实例和响应 fixture 验证:
- [Dify Knowledge API](https://docs.dify.ai/en/guides/knowledge-base/knowledge-and-documents-maintenance/maintain-dataset-via-api)
- [Dify Retrieve Chunks](https://docs.dify.ai/en/api-reference/knowledge-bases/retrieve-chunks-from-a-knowledge-base-test-retrieval)
- [FastGPT 知识库接口](https://doc.fastgpt.io/zh-CN/openapi/dataset)
- [RAGFlow HTTP API](https://ragflow.io/docs/dev/http_api_reference)
- [RAGFlow Release Notes](https://ragflow.io/docs/dev/release_notes)
@@ -0,0 +1,580 @@
# 外部知识库技术设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计稿 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 产品需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) |
| 逻辑设计 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) |
| UI 设计 | [外部知识库 UI 设计](./external-knowledge-ui-design.md) |
## 1. 设计约束
1. 外部知识库扩展现有知识库领域,不建立独立检索工具、聊天范围或 Runtime 权限模型。
2. 外部绑定使用远端检索,不落地远端文档、分块、向量和图谱。
3. 所有网络、凭据、Provider 响应校验和结果标准化都在 Main 完成。
4. Renderer 只能通过受限 IPC 管理实例、读取目录、保存绑定和发起测试。
5. Dify、FastGPT、RAGFlow 使用独立 Adapter 和判别配置,不通过任意 JSON 请求模板实现。
6. 首次实现只调用无内容修改语义的列表、详情和检索接口。
7. Provider API 变化通过真实 fixture、Adapter 版本和能力快照处理,不通过猜测字段兼容。
## 2. 现有代码边界
当前实现中的主要扩展点:
| 边界 | 现有位置 | 需要的变化 |
| --- | --- | --- |
| 共享知识契约 | `src/shared/knowledge-contracts.ts`、`src/shared/contracts.ts` | 增加实例、绑定、Provider 配置和外部引用判别联合 |
| IPC 名称 | `src/shared/ipc-channels.ts` | 增加实例和目录操作通道 |
| Preload | `src/preload/index.ts` | 暴露受限、类型化的外部知识库 API |
| Main IPC | `src/main/ipc.ts` | 校验输入、调用服务、投影脱敏结果 |
| 知识服务 | `src/main/knowledge/knowledge-service.ts` | 按知识库来源分派本地或外部检索 |
| 数据库 | `src/main/knowledge/knowledge-database.ts` | 增加实例、绑定和迁移 |
| 内部类型 | `src/main/knowledge/types.ts` | 增加 Provider、实例、绑定和标准化结果类型 |
| 知识页面 | `src/renderer/src/KnowledgeWorkspace.tsx` | 实例管理、创建向导类型和外部详情 |
| 检索测试 | `src/renderer/src/KnowledgeRetrievalWorkbench.tsx` | 支持本地与外部诊断判别联合 |
| 聊天预检索 | `src/main/ipc.ts` | 统一调用 Provider-aware 检索编排 |
| Agent 知识工具 | `src/main/agent/knowledge-mcp-gateway.ts` | 与预检索复用同一编排和引用标准化 |
外部 Provider 不是 `KnowledgeSourceType`。现有 `file | directory | url` 表示会进入本地
文档、分块和索引的资料来源;把远端绑定塞入该枚举会错误触发同步、分块和来源打开逻辑。
## 3. 模块设计
建议增加:
```text
src/main/knowledge/external/
├─ external-knowledge-service.ts
├─ external-knowledge-store.ts
├─ external-knowledge-errors.ts
├─ external-knowledge-http.ts
├─ external-knowledge-registry.ts
├─ external-knowledge-normalizer.ts
└─ providers/
├─ dify-adapter.ts
├─ fastgpt-adapter.ts
└─ ragflow-adapter.ts
```
职责:
- `ExternalKnowledgeService`:实例、目录、绑定、测试和检索编排。
- `ExternalKnowledgeStore`:实例和绑定持久化,凭据加解密。
- `ExternalKnowledgeHttp`:超时、取消、大小限制、地址和响应读取。
- `ExternalKnowledgeRegistry`:按 Provider 返回内置 Adapter,不接受运行时插件注册。
- `ExternalKnowledgeNormalizer`:统一结果边界、截断和引用。
- Adapter:构造固定端点与请求,解析 Provider 响应,声明静态配置和能力条件。
`KnowledgeService` 继续作为聊天、检索测试和 MCP 网关的统一入口。它根据
`KnowledgeBase.kind` 调用现有本地检索或 `ExternalKnowledgeService.retrieve`。
## 4. Provider Adapter
```ts
type ExternalKnowledgeProvider = 'dify' | 'fastgpt' | 'ragflow'
interface ExternalKnowledgeAdapter<PersistedConfig, CapabilitySnapshot> {
readonly provider: ExternalKnowledgeProvider
readonly configVersion: number
normalizeBaseUrl(input: string): URL
testConnection(context: AdapterContext): Promise<ConnectionTestResult>
listKnowledgeBases(
context: AdapterContext,
input: CatalogPageInput
): Promise<CatalogPage>
getKnowledgeBase(
context: AdapterContext,
remoteKnowledgeBaseId: string
): Promise<RemoteKnowledgeBaseSummary>
probeRetrieval(
context: AdapterContext,
input: ProviderRetrieveInput<PersistedConfig>
): Promise<ProviderRetrieveResult>
retrieve(
context: AdapterContext,
input: ProviderRetrieveInput<PersistedConfig>
): Promise<ProviderRetrieveResult>
parseCapabilities(input: unknown): CapabilitySnapshot
validateConfig(
input: unknown,
capabilities: CapabilitySnapshot
): PersistedConfig
}
```
`probeRetrieval` 可以复用 `retrieve`,但必须使用用户在创建向导中明确输入的测试查询。
不得生成会泄露组织信息的默认问题,不得调用 Provider 的 App 或模型问答接口,也不得
修改远端知识内容。Provider 自身记录 API 审计或计算检索用量不属于内容修改,必须由界面
提前说明。
Adapter 返回数据对象,不返回 React 组件、HTML 或远端 Schema。Renderer 中的配置定义
由共享的 Provider 判别联合和本地字段描述生成。
## 5. 共享契约
### 5.1 实例
```ts
type CredentialMutation =
| { action: 'keep' }
| { action: 'replace'; value: string }
| { action: 'clear' }
interface ExternalKnowledgeInstanceSummary {
id: string
name: string
provider: 'dify' | 'fastgpt' | 'ragflow'
baseUrl: string
enabled: boolean
credentialStatus: 'configured' | 'missing' | 'unavailable'
probeStatus:
| 'untested'
| 'catalog-ready'
| 'healthy'
| 'list-restricted'
| 'auth-failed'
| 'unreachable'
| 'incompatible'
| 'failed'
detectedVersion?: string
bindingCount: number
lastTestedAt?: string
lastErrorCode?: string
}
```
Renderer 输入使用 `CredentialMutation`,因此新密钥会在输入控件和该次 IPC 中短暂存在。
Main 返回值永远没有 `credential`、`apiKey`、`authorization` 或加密 envelope。
### 5.2 绑定
```ts
interface ExternalKnowledgeBinding {
knowledgeBaseId: string
instanceId: string
remoteKnowledgeBaseId: string
remoteName: string
remoteDescription?: string
commonConfig: ExternalKnowledgeCommonConfig
providerConfig: DifyConfig | FastGptConfig | RagflowConfig
providerConfigVersion: number
adapterVersion: number
lastVerifiedAt?: string
}
```
`providerConfig` 必须带 `provider` 判别字段,且与实例 Provider 一致。`remoteName` 是用于
显示和变化检测的快照,不是授权标识。
### 5.3 标准化结果
现有 `KnowledgeRetrievalResult` 假定结果映射到本地 `documentId/sourceId/chunkId`。需要改为
引用定位的判别联合,同时保持现有调用方可按统一字段读取片段:
```ts
type KnowledgeReferenceLocator =
| {
kind: 'local'
documentId: string
sourceId: string
chunkId: string
parentChunkId?: string
}
| {
kind: 'external'
provider: ExternalKnowledgeProvider
instanceId: string
remoteKnowledgeBaseId: string
remoteDocumentId?: string
remoteChunkId?: string
providerScore?: number
location?: string
sourceUrl?: string
metadata?: Record<string, string | number | boolean | null>
}
interface UnifiedKnowledgeRetrievalResult {
knowledgeBaseId: string
documentTitle: string
sourceDisplayName: string
snippet: string
relevance?: number
rank: number
locator: KnowledgeReferenceLocator
}
```
`remoteDocumentId` 和 `remoteChunkId` 只保存 Provider 实际返回的标识,缺失时不得从标题、
序号或内容哈希推导。`providerScore` 保留原始值,但不改名为统一 `relevance`。只有经过
单独定义和验证的归一化算法才能填写跨 Provider `relevance`。初始跨库拼装按结果配额和
稳定 Provider 顺序工作,不按原始分数混排。
所有共享 Schema 使用严格对象并限制:
- 查询不超过 4,000 字符;同时遵守 Provider 更小限制,例如 Dify 当前公开接口的
250 字符限制,超出时在发送前明确失败,不静默截断查询。
- 单次目录页不超过 100 项,总目录最多 500 项。
- 标准化结果最多 20 条,单片段最多 8,000 字符。
- 标题、ID、定位、URL 和元数据键值均设独立上限。
- Provider 原始响应总字节数设固定上限,超限立即取消读取。
## 6. 持久化
### 6.1 数据库变化
保留现有 `knowledge_bases` 表,增加:
```sql
ALTER TABLE knowledge_bases
ADD COLUMN kind TEXT NOT NULL DEFAULT 'local'
CHECK (kind IN ('local', 'external'));
```
增加实例表:
```sql
CREATE TABLE external_knowledge_instances (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
provider TEXT NOT NULL CHECK (provider IN ('dify', 'fastgpt', 'ragflow')),
base_url TEXT NOT NULL,
enabled INTEGER NOT NULL CHECK (enabled IN (0, 1)),
credential_envelope_json TEXT,
probe_status TEXT NOT NULL,
detected_version TEXT,
capabilities_json TEXT NOT NULL,
transport_security TEXT NOT NULL,
transport_risk_accepted_url TEXT,
transport_risk_accepted_at TEXT,
last_tested_at TEXT,
last_error_code TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
增加绑定表:
```sql
CREATE TABLE external_knowledge_bindings (
knowledge_base_id TEXT PRIMARY KEY
REFERENCES knowledge_bases(id) ON DELETE CASCADE,
instance_id TEXT NOT NULL
REFERENCES external_knowledge_instances(id) ON DELETE RESTRICT,
remote_knowledge_base_id TEXT NOT NULL,
remote_name TEXT NOT NULL,
remote_description TEXT,
common_config_json TEXT NOT NULL,
provider_config_json TEXT NOT NULL,
provider_config_version INTEGER NOT NULL,
adapter_version INTEGER NOT NULL,
binding_status TEXT NOT NULL,
last_verified_at TEXT,
last_success_at TEXT,
last_error_code TEXT,
UNIQUE(instance_id, remote_knowledge_base_id)
);
```
已有知识库迁移为 `kind = 'local'`。外部知识库仍保留现有名称、描述和创建时间,但本地
`storageMode`、图谱、分块和本地检索设置不进入外部请求;后续可以在数据库重构时再移除
这些历史必填默认值,本功能不为此扩大迁移范围。
### 6.2 凭据
复用 `SettingsCredentialCipher` 和 Electron `safeStorage`:
- `replace`:Main 加密新值后在一个事务中替换 envelope。
- `keep`:不读取、不重写现有 envelope。
- `clear`:确认后删除 envelope,并把实例凭据状态设为缺失。
- 安全存储不可用时拒绝新增或替换,不降级为明文。
- 解密只发生在发起请求前;请求结束后不缓存明文。
实例导出、诊断和数据库普通 JSON 视图均排除 `credential_envelope_json`。
## 7. IPC 设计
建议增加独立通道:
```text
knowledge:external-instances:list
knowledge:external-instances:test
knowledge:external-instances:save
knowledge:external-instances:set-enabled
knowledge:external-instances:delete
knowledge:external-catalog:list
knowledge:external-catalog:get
knowledge:external-bindings:create
knowledge:external-bindings:update
knowledge:external-bindings:remove
knowledge:external-retrieval:test
```
所有 Handler 必须:
1. `assertTrustedSender`。
2. 用共享 Zod Schema 解析严格输入。
3. 根据实例 ID 在 Main 读取地址和凭据,不接受 Renderer 在检索请求中重复传入。
4. 使用稳定错误码返回脱敏错误。
5. 对网络操作支持 AbortSignal、总超时和响应大小限制。
目录和测试请求返回 `requestId`。Renderer 仍需校验当前类型、实例和目标与响应关联,避免
旧响应覆盖新表单。
## 8. HTTP 边界
### 8.1 服务地址
- 只接受 `http:` 和 `https:`。
- 拒绝用户名、密码、查询和片段。
- Adapter 负责规范化 Provider 所需 API 根路径,避免用户填写完整操作端点。
- 请求只能拼接 Adapter 内置相对路径,远端目录项或错误不能改变目标 URL。
- 重定向默认拒绝;若某 Provider 的受支持部署确需重定向,只允许同源且不得跨跳转发送
Authorization。
- 当前全局“内网兼容模式”决定是否允许 HTTP 和非标准 HTTPS 证书;操作开始后冻结该值。
- HTTP 或实际使用证书例外时,Main 返回 `transportSecurity` 风险状态。用户必须针对规范化
后的当前地址明确确认,才能保存实例或发送 API Key;地址变化立即使旧确认失效。
- 不进行公共云回退,不把自部署地址替换成 Provider 官方地址。
用户明确配置内网地址属于产品用途,因此不能采用禁止私网地址的通用 SSRF 规则。安全边界
依靠显式实例配置、Main-only 请求、固定端点、无任意方法和无远端 URL 跟随。
### 8.2 请求限制
- 默认超时 15 秒,用户可在 1 至 60 秒内调整。
- 目录、详情和检索分别设置响应字节上限。
- JSON 解析后仍执行深度、数组数量、字符串长度和未知字段策略。
- 默认不自动重试。用户重试或上层下一次检索会产生新请求。
- 取消、应用关闭和实例删除立即终止相关请求。
- 日志只记录 Provider、实例 ID、操作、状态码、稳定错误码、耗时和字节数。
## 9. Provider 映射
本节记录 2026-09-03 核对的初始公开接口。实施时应把每个受支持版本的真实响应保存为
去敏 fixture,并以 fixture 和真实实例测试作为发布依据。
### 9.1 Dify
| 操作 | 接口 |
| --- | --- |
| 列表 | `GET /v1/datasets` |
| 检索 | `POST /v1/datasets/{dataset_id}/retrieve` |
| 认证 | `Authorization: Bearer <knowledge-api-key>` |
Adapter 只接受 Knowledge Service API Key,不调用应用 `/info`、Chat 或 Workflow。默认不
发送 `retrieval_model`,沿用远端知识库配置。用户选择覆盖时,Adapter 必须一次收集并验证
当前接口要求的搜索方式、Top K、阈值、`reranking_mode`,以及相应的 Rerank Provider/模型
或关键词/向量权重与 Embedding Provider/模型;缺少任何必填标识时不构造请求。响应从
`records[].segment`、`score`、文档名称和元数据提取标准化引用。
Dify 当前文档限制查询不超过 250 字符。GoodBuddy 不静默截断;超出时检索测试和聊天
诊断返回 Provider 限制,并允许后续单独设计查询压缩策略。
### 9.2 FastGPT
| 操作 | 接口 |
| --- | --- |
| 列表 | `POST /api/core/dataset/list` |
| 详情 | `GET /api/core/dataset/detail?id=...` |
| 检索 | `POST /api/core/dataset/searchTest` |
| 认证 | `Authorization: Bearer <api-key>` |
列表响应同时包含文件夹和知识库,只有 `type = dataset` 可以绑定。检索映射 `datasetId`、
`text`、Token `limit`、`similarity`、`searchMode` 和 `usingReRank`。初始版本不发送查询优化
字段,避免调用 FastGPT 配置的 LLM。
响应从 `q`、`a`、`sourceName`、`sourceId`、`collectionId` 和 `score` 构造片段与引用。
FastGPT 4.15.0 起部署实例的 `/apidoc/devapi` 是接口事实来源,手工文档可能落后。支持矩阵
必须按真实版本测试;不能因为健康页可访问就假定列表和检索 Schema 相同。`searchTest`
可能记录 `SEARCH_TEST` 等远端审计并更新 API Key 用量;Embedding 和 Rerank 也可能产生
费用。GoodBuddy 将这些行为作为检索成本显示,不宣称该接口没有副作用。
### 9.3 RAGFlow
| 操作 | 接口 |
| --- | --- |
| 列表/详情 | `GET /api/v1/datasets` |
| 检索 | `POST /api/v1/retrieval` |
| 认证 | `Authorization: Bearer <api-key>` |
检索映射 `question`、`dataset_ids`、`page_size`、`similarity_threshold`、
`vector_similarity_weight`、`knn_top_k`、重排、`use_kg` 和
`include_knowledge_compilation`。不发送已弃用的 `top_k`。响应从 `data.chunks` 和
`data.doc_aggs` 提取片段、文档、位置和原始相似度。
图谱规则:
- `parser_config.graphrag` 或等价详情字段作为已有图谱的证据,不写回。
- `use_kg` 是检索时开关,只在已有图谱和受支持版本均确认后发送。
- Knowledge Compilation 与旧 GraphRAG 分别建模。
- `include_knowledge_compilation` 使用本地保存的显式布尔值,初始默认 false;即使服务端
默认 true,也不依赖字段省略获得隐式行为。
- Provider 没有返回图节点或路径证据时,GoodBuddy 不生成图谱引用。
## 10. 检索编排
```text
retrieveMany(knowledgeBaseIds, query, requestContext)
├─ load and authorize GoodBuddy knowledge bases
├─ partition local and external bindings
├─ local: existing retrieval path
├─ external: bounded concurrent adapter requests
├─ validate and normalize each response
├─ allocate deterministic context budget
└─ return evidence plus per-library diagnostics
```
预检索和 `knowledge_search` 工具必须调用同一个 `retrieveMany`。如果只修改聊天预检索而
未修改 `KnowledgeMcpGateway`,`auto` 和 `always` 会产生不同能力,这是阻断发布的问题。
### 10.1 上下文预算
- 每个知识库先保留不超过其 `resultLimit` 的结果。
- 先给每个成功知识库一个最小有界配额,再按稳定顺序分配剩余字符。
- 不用不同 Provider 原始分数决定跨库优先级。
- 片段截断在 Main 完成并在引用中标记。
- 相同实例的多个绑定仍按绑定分别请求,除非 Provider 明确支持且 Adapter 实现等价的多
Dataset 请求;合并不得改变每个绑定的配置和诊断。
### 10.2 失败结果
返回结构包含每个知识库的:
```ts
interface KnowledgeRetrievalOutcome {
knowledgeBaseId: string
status: 'success' | 'empty' | 'failed' | 'cancelled'
durationMs: number
resultCount: number
errorCode?: ExternalKnowledgeErrorCode
truncated: boolean
}
```
至少一个 `success` 或 `empty` 表示检索编排完成;全部 `failed/cancelled` 时上层显示整体失败。
## 11. 引用与来源打开
外部结果不能进入依赖本地数据库的 `referenceContext` 和 `openSource` 路径。
- 引用详情直接使用请求时保存的标准化片段和定位。
- Main 可以按远端片段 ID 提供一次只读重新读取,但初始版本不要求。
- Provider 返回 URL 只作为不可信元数据保存。没有独立 URL 规则和真实实例验证前,
Renderer 不显示打开链接操作。
- 活动和会话持久化保存标准化引用,其中包含实际用于回答的有界远端片段;这属于引用
留存,不是批量同步或本地索引。删除会话或对应活动时按现有生命周期删除该副本。
- 实例删除后,历史引用仍显示 Provider、知识库、文档名称和已保存片段,但标记连接已移除。
## 12. 支持矩阵与版本变化
每个 Adapter 维护明确的最低版本、已验证版本和 `adapterVersion`。连接测试优先读取可靠的
版本接口或响应头;无法识别版本时,只允许经真实请求验证过的基础列表和检索字段,高级字段
保持不可用。
Adapter 可以保存少量受控事实,例如“目录接口可用”“详情表明已构建图谱”,但不尝试从
远端拼出完整 capability manifest,也不计算 capability fingerprint。
- `adapterVersion` 相同且实例仍在支持矩阵内:解析已保存配置并执行。
- Adapter 升级:使用明确的配置迁移或把绑定标记为 `config-invalid`,不猜测字段迁移。
- 实例版本离开支持矩阵:停止发送高级字段,并要求重新测试和确认。
- 新能力出现:保持默认关闭,用户主动配置后才发送。
- RAGFlow `use_kg` 还要求当前远端知识库存在已构建图谱的证据。
## 13. 安全与隐私
- API Key 在用户输入和保存 IPC 中短暂存在,随后使用系统安全存储加密;保存后不返回
Renderer,也不进入 Runtime、日志和普通元数据。
- 请求不附带完整会话、附件、本地知识库证据、系统提示、模型配置或其他 Provider 结果。
- Provider 结果使用与本地知识证据相同的不可信上下文标记。
- Adapter 拒绝响应中的原型污染键、过深对象、超长数组和非有限数值。
- 远端错误正文只用于 Main 内映射,日志与 UI 使用稳定错误码和短消息。
- 外部知识库不能扩大当前对话显式选择的 GoodBuddy 知识库 ID。
- Ask 与 Execute 对知识检索均为只读,Execute 不获得额外 Provider 权限。
- Provider 配置中的模型 ID、背景文本和 Metadata 条件按独立长度限制验证,不能携带凭据。
- HTTP 或证书例外需要按当前规范化地址确认凭据传输风险,地址变化后重新确认。
## 14. 测试
### 14.1 契约测试
- 每个 Provider 的列表、详情、检索成功和错误 fixture。
- 未知字段、缺字段、错误类型、超长内容、非有限分数和超大响应。
- Dify 查询长度、FastGPT 文件夹过滤、RAGFlow 图谱和 Knowledge Compilation 字段。
- Provider 配置判别联合拒绝类型错配和未知字段。
### 14.2 安全测试
- 保存完成后的 Renderer 快照、IPC 返回值、日志、错误、活动和导出中不存在明文 API Key;
`replace` 输入只在当前表单和单次 IPC 请求中短暂存在。
- `keep/replace/clear` 凭据语义和安全存储不可用路径。
- 地址中的凭据、查询、片段、危险 scheme、跨源重定向和远端 URL 注入被拒绝。
- 取消、超时、响应大小上限和应用关闭释放请求。
### 14.3 集成测试
- 实例保存、目录读取、手工 ID、绑定创建、编辑和本地移除。
- 同一远端目标重复绑定被拒绝。
- 删除实例的引用保护和级联本地移除确认。
- 本地与外部 `retrieveMany` 部分成功、全部失败和确定性上下文预算。
- 聊天 `always` 与 Agent `knowledge_search` 使用同一 Provider-aware 编排。
- 历史引用在实例或绑定移除后仍可读。
### 14.4 UI 测试
- 类型切换、异步旧响应、目录搜索、刷新和手工模式。
- 两栏与窄窗口堆叠、键盘、焦点恢复和离开保护。
- Provider 字段显隐、失效配置、RAGFlow 图谱说明和恢复默认。
- 外部详情不出现导入、同步、分块和本地图谱操作。
- 错误、零结果、部分失败和 Provider 引用显示。
### 14.5 真实实例验收
每个 Provider 至少选择一个明确版本的自部署实例,记录:
- 版本、部署方式和认证范围。
- 列表与检索的请求/响应 fixture,移除凭据和业务正文。
- 默认与专属配置是否真实生效。
- 401、403、404、429、超时和取消行为。
- 查询、片段、引用和诊断的实际上限。
Mock 或公开文档通过不能代替真实实例验收。
## 15. 实施顺序
这里的顺序只用于控制代码依赖,不裁剪产品设计:
1. 建立共享判别契约、数据库迁移和凭据存储。
2. 实现 HTTP 边界、错误映射和 Adapter Registry。
3. 逐个实现三种 Provider,并用 fixture 完成契约测试。
4. 实现实例管理和目录 IPC。
5. 扩展创建向导、统一列表和外部详情。
6. 扩展检索测试、引用和 `retrieveMany`。
7. 接入聊天预检索与 `KnowledgeMcpGateway`。
8. 完成真实实例、安全、响应式和跨平台验收。
## 16. 发布条件
- 三种 Provider 的受支持版本和限制已在产品界面或发布说明中可查。
- 真实实例列表、绑定、检索和错误矩阵均通过。
- 没有写入远端知识内容的代码路径。
- API Key 泄漏测试、地址边界和响应大小测试通过。
- 本地知识库创建、同步、检索、图谱和引用回归测试通过。
- `auto`、`always`、检索测试和 MCP 知识工具得到一致的外部知识能力。
## 17. 需求追踪
| 需求 | 技术落点 | 验证 |
| --- | --- | --- |
| `EK-FR-01` 至 `EK-FR-03` | 实例契约、Store、实例 IPC、凭据和测试状态 | 实例集成测试、安全测试 |
| `EK-FR-04`、`EK-FR-05` | Adapter 目录与详情接口、目录 IPC | Provider fixture、手工 ID 集成测试 |
| `EK-FR-06` 至 `EK-FR-09` | Provider 判别联合、Adapter 配置版本、Renderer 本地字段定义 | 契约测试、UI 字段显隐测试 |
| `EK-FR-10`、`EK-FR-11` | `KnowledgeBase.kind`、绑定表、统一 `retrieveMany` | 本地与外部混合检索测试 |
| `EK-FR-12`、`EK-FR-13` | 引用定位判别联合、结果标准化、检索测试 IPC | 引用持久化与 UI 测试 |
| `EK-FR-14` | 稳定错误码、逐库 Outcome、取消和超时 | 错误 fixture、部分失败测试 |
| `EK-FR-15` | 外键限制、绑定本地删除、无远端写接口 | 删除生命周期测试 |
| `EK-FR-16` | Main-only HTTP、固定端点、传输风险确认 | 地址、证书和凭据泄漏测试 |
@@ -0,0 +1,401 @@
# 外部知识库 UI 设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计稿 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 产品需求 | [外部知识库接入 PRD](./external-knowledge-prd.md) |
| 行为规则 | [外部知识库逻辑设计](./external-knowledge-logic-design.md) |
| 全局规范 | [GoodBuddy UI 设计系统](../../../UI-DESIGN.md) |
## 1. 信息架构
外部知识库不增加左侧主导航和知识库一级页签。现有知识库页面结构保持:
```text
知识库
├─ 页面标题区
│ ├─ 管理外部实例
│ └─ 新建知识库
├─ 知识库列表
└─ 当前知识库详情
```
“管理外部实例”管理可复用连接;“新建知识库”负责创建本地知识库或添加一个远端绑定。
## 2. 页面标题区
常规宽度:
```text
知识库 [管理外部实例] [新建知识库]
导入、检索并维护本地和企业现有知识
范围:全局
```
- “新建知识库”保持主按钮。
- “管理外部实例”是带服务器图标的次按钮。
- 沿用知识库页面的全局 `ScopeBadge`,创建和管理外部连接时持续可见。
- 按钮使用文字和图标,不增加只含缩写的厂商快捷入口。
- 窄窗口下两个按钮换到标题下方,保持同一行;空间不足时各占一半宽度。
对应:`EK-FR-01`。
## 3. 知识库列表
本地和外部知识库保留在同一主列表。条目采用现有列表密度,不增加卡片墙。
```text
研发规范
RAGFlow · 研发知识平台 · 远端可检索
产品资料
本地 · 12 个来源 · 全文与向量可用
```
### 3.1 外部条目字段
- GoodBuddy 显示名称。
- Provider 图标或文字徽标。
- 外部实例名称。
- “远端可检索”、认证失效、目标不存在等准确状态。
- 最近一次成功检索时间;从未检索时显示“尚未检索”。
外部条目不显示来源数量、同步进度、向量覆盖率、本地图谱或导入失败数。
### 3.2 列表筛选
现有搜索同时匹配 GoodBuddy 名称、远端名称、Provider 和实例名称。来源筛选使用菜单:
```text
全部来源
本地
Dify
FastGPT
RAGFlow
```
筛选不是新的页面页签。
## 4. 管理外部实例
点击“管理外部实例”打开大尺寸 Modal。Modal 使用标题、可滚动内容和固定操作区,遵循
全局焦点陷阱、Escape、背景隔离和焦点恢复规则。
```text
┌──────────────────────────────────────────────────────────────┐
│ 外部实例 [添加实例] [×] │
│ 管理 Dify、FastGPT 和 RAGFlow 的连接 │
├──────────────────────────────────────────────────────────────┤
│ 名称 Provider 服务地址 状态 操作 │
│ 研发知识平台 RAGFlow ragflow.example 正常 ⋯ │
│ 客服知识平台 FastGPT fastgpt.example 认证失效 ⋯ │
└──────────────────────────────────────────────────────────────┘
```
### 4.1 实例行
显示:
- 名称、Provider、脱敏后的主机名、状态。
- 已绑定知识库数量。
- 最近测试时间。
- 行尾菜单:测试连接、编辑、停用或启用、删除。
服务地址可以显示 scheme、host 和非默认端口,不显示路径中的敏感信息、查询或凭据。
### 4.2 空状态
```text
尚未配置外部实例
添加企业现有的 Dify、FastGPT 或 RAGFlow 服务。
[添加实例]
```
筛选无结果和加载失败使用不同 `EmptyState` 变体。
## 5. 添加和编辑实例
实例表单在管理 Modal 内切换为编辑面板,不叠加第二层 Modal。
```text
添加外部实例
实例名称 [研发知识平台 ]
Provider [RAGFlow ▾]
服务地址 [https://ragflow.example ]
认证方式 API Key
API Key [•••••••••••••••• ]
凭据状态 尚未保存
连接测试
服务连接 未测试
身份认证 未测试
知识库列表 未测试
检索接口 绑定知识库时验证
[取消] [测试连接] [保存实例]
```
### 5.1 字段行为
- Provider 使用原生 Select;编辑已被绑定的实例时不可切换 Provider。
- 服务地址只接受完整 HTTP(S) 地址。字段下方说明其受全局内网兼容模式影响。
- API Key 使用密码输入和显示/隐藏图标。明文会在当前表单和保存 IPC 中短暂存在;编辑时
留空表示保留,不使用占位假密钥。
- 已保存凭据旁提供“替换凭据”和“清除凭据”,清除属于需要确认的操作。
- 地址使用 HTTP,或连接测试依赖放宽证书校验时,在测试和保存操作旁持续显示传输风险。
用户必须针对当前地址确认后才能保存;地址变化会清除该确认。
- “测试连接”不保存草稿;测试期间按钮显示进度并提供取消。
- 保存成功进入应用通知,Modal 返回实例列表并聚焦对应行。
### 5.2 测试结果
分项状态使用图标、文字和说明,不只依靠颜色:
```text
服务连接 通过 183 ms
身份认证 通过
知识库列表 受限 当前凭据不能读取列表,可手工填写知识库 ID
检索接口 待验证 添加知识库时执行
```
长错误折叠显示,正文经过脱敏。失败后保留全部草稿和 API Key 输入。
FastGPT 实例额外显示:`检索会计入该实例的 API 使用记录,并可能产生 Embedding 或
Rerank 用量。`
## 6. 新建知识库向导
扩展现有页面内 `CreateLibraryWizard`,不改为新的全屏页面或 Modal。
### 6.1 类型选择
表单第一项使用单选卡片或共享 SegmentedControl:
```text
知识库类型
[本地] [Dify] [FastGPT] [RAGFlow]
```
只有选择本地时显示现有存储模式、图谱和本地说明。外部类型选中后显示两栏配置。
### 6.2 外部类型两栏布局
```text
┌──────────────────────────────┬──────────────────────────────┐
│ 知识库来源 │ 检索配置 │
│ │ │
│ Provider RAGFlow │ 通用配置 │
│ 外部实例 [研发平台 ▾] │ 返回片段数 [ 6 ] │
│ 远端知识库 [研发规范 ▾] │ 请求超时 [15 秒] │
│ [↻] │ 单片段上限 [4000] │
│ [手工填写知识库 ID] │ │
│ │ RAGFlow 配置 │
│ 显示名称 [研发规范 ]│ 相似度阈值 [0.20] │
│ 远端名称 研发规范 │ 向量相似度权重 [0.30] │
│ 远端 ID 7f31…c902 │ KNN Top K [1024] │
│ 测试查询 [部署网络要求 ]│ 图谱检索 [开关] │
│ │ Knowledge Compilation [开关]│
├──────────────────────────────┴──────────────────────────────┤
│ 将向研发知识平台发送测试查询。不会上传文件或完整会话。 │
│ [取消] [测试检索] [添加知识库] │
└─────────────────────────────────────────────────────────────┘
```
测试查询由用户填写,不自动生成,也不取用当前会话内容。“添加知识库”在真实检索请求
成功前禁用;成功返回零条结果仍可添加,但界面显示“检索成功,未找到匹配片段”。验证结果
在底部说明区展示,不用成功 Alert 撑高表单。
添加成功后沿用本地知识库行为,把新绑定加入当前聊天选择,不重新启用其他已取消知识库。
### 6.3 目录下拉框
- 选择实例后自动打开加载状态,不要求再点击“读取”。
- 使用可搜索 Select,选项显示名称、远端短 ID 和可用状态。
- 右侧刷新使用刷新图标按钮,Tooltip 为“刷新知识库列表”。
- 加载时保持控件稳定尺寸,显示“正在读取知识库…”。
- 目录为空显示“当前实例没有可访问的知识库”。
- 403 或不支持列表时显示原因和“手工填写知识库 ID”。
- 列表失败后显示“重试”,不自动切换到手工模式。
### 6.4 手工模式
```text
远端知识库 ID [ ]
显示名称 [ ]
[验证知识库]
```
验证成功后显示远端名称和 Provider 返回的摘要。返回自动列表不会丢弃已输入 ID,切换前
如有未保存输入须确认。
## 7. 配置面板
右侧配置固定分为“通用配置”和“{Provider} 配置”,不创建嵌套卡片。高级字段可以使用
一个折叠区,但核心检索方式、阈值和 RAGFlow 图谱检索保持直接可见。
控件选择:
- 开关:图谱检索、Score 阈值启用、Rerank、使用 Dify 知识库默认配置。
- Select 或 SegmentedControl:少量固定检索方式。
- 数字输入或 Stepper:结果数、Token、Top K、超时。
- Slider 加数字输入:0 至 1 的阈值和权重,二者保持同步。
- Metadata 条件:初始版本不提供自由 JSON 编辑器。
字段显隐由 GoodBuddy 内置 Provider 描述和 Main 返回的能力状态共同决定。服务端返回的
任意字符串不得变成 HTML、控件标签或帮助链接。
### 7.1 RAGFlow 图谱文案
开关名称:`图谱检索`
说明:`检索远端知识库已经生成的图谱内容,不会创建或更新图谱。`
只有远端详情或受控探测表明图谱已经构建时,该开关才可用。证据缺失时显示“未确认远端
图谱状态”并禁用。Knowledge Compilation 单独显示为 `包含 Knowledge Compilation`,默认
关闭,不把它和旧 GraphRAG 合成同一开关。
## 8. 外部知识库详情
选中外部绑定后,现有详情区按来源类型裁剪:
```text
研发规范 RAGFlow · 远端检索
研发知识平台 / 远端知识库 7f31…c902
[概览] [检索配置] [检索测试]
```
### 8.1 概览
显示 Provider、实例、远端名称、远端 ID、连接状态、最近成功检索和数据位置。操作包括:
- 刷新远端信息。
- 更换远端知识库。
- 编辑 GoodBuddy 显示名称。
- 从 GoodBuddy 移除。
不显示导入文件、添加目录、添加网页、同步、分块、重建索引和本地图谱入口。
### 8.2 检索配置
复用创建向导右侧配置面板。存在失效字段时,页头显示“配置需要确认”,字段附近说明
Provider 版本变化,保存按钮保持禁用直到用户处理。
### 8.3 检索测试
复用现有检索测试工作台的查询、结果和焦点行为,但外部结果采用 Provider 诊断:
```text
RAGFlow · 研发知识平台
总耗时 642 ms · 返回 6 条 · 保留 6 条
1 部署规范.pdf
第四章 网络要求…
Provider 分数 0.82 · 文档 ID 98ab…
```
外部测试不显示 FTS/CJK/本地向量扫描等本地通道,也不提供依赖本地分块的“查看上下文”。
## 9. 聊天知识范围
现有多选列表保留一个选择面,只在每行增加来源:
```text
[✓] 产品资料 本地
[✓] 研发规范 RAGFlow · 研发知识平台
[ ] 客服知识 FastGPT · 客服平台
```
- 不按 Provider 创建多套选择器。
- 不可用绑定保留显示但禁用勾选,并给出认证失效或目标不存在原因。
- 当前会话已选绑定变为不可用时,不静默移除 ID;发送前提示该知识库本次无法检索。
## 10. 执行状态和引用
活动状态:
```text
正在检索“研发规范”
RAGFlow · 研发知识平台 · 已发送当前检索词
```
部分失败:
```text
已检索 3 个知识库,2 个成功,1 个超时
[查看详情]
```
引用:
```text
部署规范.pdf / 网络要求
研发规范 · RAGFlow · 研发知识平台
```
引用详情显示远端文档名、ID、片段、Provider 分数和实际返回的定位。未完成 URL 安全设计前,
不显示“打开远端来源”。
## 11. 删除确认
移除绑定:
```text
从 GoodBuddy 移除“研发规范”?
这会删除本机绑定和检索配置,不会删除 RAGFlow 中的知识库或文档。
[取消] [移除]
```
删除有绑定的实例:
```text
“研发知识平台”仍被 3 个知识库使用
删除实例会同时从 GoodBuddy 移除这些绑定。远端数据不会改变。
[取消] [查看知识库] [删除实例和本地绑定]
```
破坏性主操作使用 `danger-solid`,默认焦点放在“取消”。
## 12. 可访问性与响应式
- 常规宽度两栏使用稳定 Grid;小于 `960px` 时改为单栏,顺序为来源、通用配置、Provider 配置。
- 小于 `720px` 时外部实例表格转为单列列表,行操作保留菜单按钮和完整可访问名称。
- Modal、创建向导、下拉框和折叠区支持完整键盘操作。
- 异步列表和测试状态使用 `aria-live="polite"`;错误使用与字段关联的 `role="alert"`。
- 图标按钮必须提供 Tooltip 和 `aria-label`。
- 状态不只靠颜色;加载、成功、受限和失败均包含文字。
- 长实例名、地址、远端名称和 ID 使用单行省略,完整值可通过 Tooltip 或详情查看。
- 切换类型、实例或退出时存在有效草稿,应按全局离开保护规则确认。
## 13. 文案表
| 场景 | 文案 |
| --- | --- |
| 页面操作 | 管理外部实例 |
| 创建类型 | 本地 / Dify / FastGPT / RAGFlow |
| 创建主操作 | 添加知识库 |
| 列表加载 | 正在读取知识库… |
| 列表受限 | 当前凭据不能读取知识库列表,可手工填写知识库 ID。 |
| 远端状态 | 远端可检索 |
| 移除操作 | 从 GoodBuddy 移除 |
| 图谱开关 | 图谱检索 |
| 测试说明 | 测试只发送当前查询,不会调用 GoodBuddy 模型或修改远端内容。 |
产品文案不使用“同步外部知识库”“导入 Dify 知识库”或“创建远端知识库”,因为这些动作
都不是本功能的实际行为。
## 14. 需求追踪
| UI 区域 | 需求 | 场景 |
| --- | --- | --- |
| 页面标题与统一列表 | `EK-FR-01`、`EK-FR-10` | `EK-US-C1` |
| 外部实例管理与表单 | `EK-FR-02`、`EK-FR-03`、`EK-FR-15`、`EK-FR-16` | `EK-US-A1` 至 `EK-US-A3` |
| 新建知识库向导 | `EK-FR-04` 至 `EK-FR-09` | `EK-US-B1` 至 `EK-US-B4` |
| 外部详情与检索测试 | `EK-FR-12`、`EK-FR-13` | `EK-US-C3`、`EK-US-D1` 至 `EK-US-D4` |
| 聊天范围、状态和引用 | `EK-FR-11`、`EK-FR-12`、`EK-FR-14` | `EK-US-C1` 至 `EK-US-C4` |
@@ -0,0 +1,212 @@
# 外部知识库 User Stories
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计稿 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [外部知识库接入 PRD](./external-knowledge-prd.md) |
## 1. 角色
- **知识库使用者**:在聊天中选择已经添加的本地或外部知识库并查看引用。
- **实例维护者**:持有外部知识库 API Key,负责配置实例、选择远端知识库和处理失效连接。
- **RAG 调试者**:需要调整 Provider 参数并直接验证检索结果。
## 2. 实例管理
### EK-US-A1 添加外部实例
作为实例维护者,我希望在知识库页面保存一个外部实例,以便后续绑定其中的多个知识库。
验收:
- Given 用户打开“管理外部实例”并选择 Dify、FastGPT 或 RAGFlow
- When 用户填写名称、服务地址和 API Key,并执行连接测试
- Then 页面分别显示服务、认证、目录和检索检查结果
- And 保存后只显示“凭据已配置”,不回显 API Key
- And 新实例可被同 Provider 的新建知识库向导选择
- And 使用 HTTP 或放宽证书校验时,保存前明确提示 API Key 的传输风险
### EK-US-A2 修复失效凭据
作为实例维护者,我希望替换认证失效的 API Key,同时保留已有知识库绑定。
验收:
- Given 实例因认证失败处于“认证失效”状态
- When 用户选择“替换凭据”、测试并保存
- Then 所有绑定继续引用同一实例 ID
- And 旧密钥不再使用
- And 用户没有输入新密钥时可以取消且不清除旧密钥
### EK-US-A3 删除仍被使用的实例
作为实例维护者,我希望删除实例前看到受影响的知识库,以免留下无法解释的绑定。
验收:
- Given 实例仍被一个或多个外部知识库绑定引用
- When 用户点击“删除实例”
- Then 确认界面列出受影响数量和名称
- And 用户可以取消,或明确同时移除本地绑定
- And 确认后不向外部实例发送删除请求
## 3. 添加知识库
### EK-US-B1 从远端列表添加
作为实例维护者,我希望选择 Provider 和实例后自动看到远端知识库,以便避免手工复制 ID。
验收:
- Given 至少存在一个可用的对应 Provider 实例
- When 用户在“新建知识库”中选择外部类型和实例
- Then GoodBuddy 自动读取有界知识库列表
- And 下拉框支持按名称搜索与刷新
- And 目录只显示当前凭据可见的知识库,不推断隐藏对象
- And 切换实例后旧实例的异步响应不会覆盖新列表
### EK-US-B2 手工填写远端 ID
作为实例维护者,我希望列表接口受限时仍可通过远端 ID 添加已有知识库。
验收:
- Given 连接测试表明认证和检索可用,但目录读取无权限或不受支持
- When 用户进入手工模式并填写远端知识库 ID
- Then GoodBuddy 使用详情或测试检索验证该目标
- And 验证失败时保留输入并显示原因
- And 未经真实验证不能完成添加
### EK-US-B3 配置 RAGFlow 图谱检索
作为 RAG 调试者,我希望在添加 RAGFlow 知识库时设置图谱检索,以便使用远端已经构建的图谱内容。
验收:
- Given 适配器确认实例检索接口支持 `use_kg`
- When 用户选择一个具备相关能力的 RAGFlow 知识库
- Then 右侧显示“图谱检索”开关并说明它只影响检索
- And 开启后测试请求携带该选项
- And 只有远端知识库存在已构建图谱的证据时才允许开启
- And 页面不把该开关描述为构建或更新远端图谱
- And 能力不明确时该开关禁用并说明原因
### EK-US-B4 保持本地创建流程
作为本地知识库用户,我希望新增外部类型后仍能按原流程创建本地知识库。
验收:
- Given 用户在新建向导中选择“本地”
- When 用户填写名称、存储模式和图谱设置
- Then 表单字段、默认值和创建结果与现有行为一致
- And 外部实例、远端 ID 和 Provider 配置不出现
## 4. 检索和引用
### EK-US-C1 在聊天中选择外部知识库
作为知识库使用者,我希望外部知识库出现在现有知识范围选择器中,以便与本地知识库一起使用。
验收:
- Given 外部知识库绑定处于可检索状态
- When 用户打开聊天知识范围
- Then 它与本地知识库出现在同一选择器中
- And 条目持续显示 Provider 和实例名称
- And 选择结果仍保存 GoodBuddy 知识库 ID
- And 新添加的绑定进入当前聊天选择,但不重新启用其他已取消知识库
### EK-US-C2 只发送检索查询
作为知识库使用者,我希望清楚知道哪些数据会发送到企业知识库。
验收:
- Given 本次请求选择了外部知识库
- When GoodBuddy 执行检索
- Then 只发送本次检索查询、远端知识库 ID 和已保存检索参数
- And 不发送完整会话、本地附件、其他知识库内容、系统提示或模型凭据
- And 活动状态显示目标 Provider 和实例
### EK-US-C3 查看外部引用
作为知识库使用者,我希望回答中的引用标明外部来源,以便回到企业知识系统核验。
验收:
- Given Provider 返回文档名、片段 ID、内容和分数
- When GoodBuddy 展示引用
- Then 引用显示 Provider、实例、知识库和文档名称
- And 详情只显示 Provider 实际返回且通过校验的定位信息
- And 缺失页码、链接或分数时不生成替代值
### EK-US-C4 多知识库部分失败
作为知识库使用者,我希望一个外部实例暂时失败时仍能使用其他成功结果。
验收:
- Given 本次选择了本地知识库和两个外部知识库
- When 一个外部请求超时,其他请求成功
- Then 成功证据继续进入回答
- And 页面显示一个知识库检索失败及其原因
- And 超时不显示为“未找到相关内容”
- And GoodBuddy 不自动改用同 Provider 的其他实例
## 5. 配置与诊断
### EK-US-D1 调整 Provider 配置
作为 RAG 调试者,我希望只看到当前 Provider 支持的参数,以便理解设置会怎样影响远端检索。
验收:
- Given 用户编辑一个外部知识库绑定
- When 页面读取实例能力和已保存配置
- Then 通用配置与 Provider 专属配置分区显示
- And 每个字段显示范围、默认值和作用
- And 不受支持的字段不会被静默发送
- And 恢复默认只恢复当前绑定的 Provider 配置
### EK-US-D2 运行检索测试
作为 RAG 调试者,我希望在知识库页面直接测试外部检索,不经过聊天模型。
验收:
- Given 当前选中一个外部知识库
- When 用户输入查询并点击“测试检索”
- Then 页面显示 Provider 耗时、返回数量、片段、来源和截断状态
- And 测试不创建聊天消息、不调用 GoodBuddy LLM、不修改远端内容
- And 远端原始响应和凭据不进入界面或日志
- And FastGPT 测试明确提示远端可能记录 API 使用并产生检索或重排用量
### EK-US-D3 Provider 版本变化
作为实例维护者,我希望升级外部系统后看到配置是否仍受支持。
验收:
- Given 最近一次能力探测与已保存快照不同
- When 用户测试实例或打开绑定设置
- Then 页面指出新增、失效或行为变化的配置
- And 失效配置不会静默删除或继续发送
- And 用户确认有效配置后才更新绑定
### EK-US-D4 远端知识库被删除
作为实例维护者,我希望远端目标不存在时看到准确状态并重新绑定。
验收:
- Given Provider 返回目标不存在
- When 用户打开知识库或执行检索
- Then 绑定状态显示“远端知识库不存在”
- And 聊天选择器不把它显示为可用
- And 用户可以刷新目录并选择新的远端知识库
- And 保存新目标前必须重新测试
@@ -5,8 +5,8 @@
| 项目 | 内容 |
| --- | --- |
| 文档类型 | 功能进度 |
| 状态 | 已实施,发布候选 CI 验收中 |
| 版本 | 0.5 |
| 状态 | 已实施,六平台打包验收已通过;真实 Skills/MCP 与自定义环境验收待完成 |
| 版本 | 0.6 |
| 日期 | 2026-09-03 |
| 关联入口 | [工具执行环境](./README.md) |
@@ -21,7 +21,8 @@
带入许可证文件。标准六平台打包任务已改用目标架构原生 Runner,并在打包前执行托管 Python
的真实安装、SSL、pip 和 venv 探针。不可变的 `v0.12.0` 尝试已通过 Windows 与 macOS,
但 Linux x64/ARM64 在发布前暴露 TAR 大小写冲突误判;`v0.12.1` 在同步 Agent 发布时
于原生打包前取消。修正后的 `0.12.2` 候选仍需通过全部六个目标才能完成对应跨平台验收。
于原生打包前取消。已发布的 `v0.12.2` 随后通过全部六个原生打包任务及其托管 Python
安装、SSL、pip 和 venv 探针。
## 已完成
@@ -67,8 +68,7 @@
- [x] 发布六个平台/架构的字节完全相同 OSS 镜像对象并公开回读校验。
- [x] 确认托管 Python 保持按需下载,Desktop 发行包不额外带入许可证文件。
- [x] Windows ARM64、macOS x64/ARM64 原生 CI 安装、SSL、pip、venv 探针。
- [ ] Linux x64/ARM64 原生 CI 安装、SSL、pip、venv 探针;`v0.12.0` 因 TAR 大小写
冲突误判失败,`v0.12.1` 未进入原生矩阵,等待 `0.12.2` 修正候选运行结果。
- [x] Linux x64/ARM64 原生 CI 安装、SSL、pip、venv 探针;`v0.12.2` 标签工作流已通过。
- [ ] 最低 Windows/macOS/glibc 支持矩阵和 Windows NuGet 签名核验。
- [ ] 真实 JavaScript/Python SKILL 成果及 Node/Python stdio MCP 调用。
- [ ] 自定义 Node/Python 在产品 UI 中的真实选择和任务执行。
@@ -100,6 +100,9 @@
- `0.12.2` 协调候选的 Release Notes 校验通过;Agent/Desktop 发布链路聚焦测试
`106/106` 通过、1 项平台用例跳过。最终 `npm test` 为 `3478` 通过、`51` 跳过,
typecheck 与 lint 通过;仍未运行本地 production build、package 或安装探针。
- `v0.12.2` 标签工作流 `33714902060` 随后成功完成 source validation、production
bundle、Windows/macOS/Linux x64/arm64 六个原生 package job 及各自托管 Python
安装探针,并于 2026-09-03 发布非 draft、非 prerelease 的正式 Release。
### 2026-09-02 完整实现
+46
View File
@@ -0,0 +1,46 @@
# 模型连接请求定制
本功能负责 LLM 模型连接的自定义请求头和自定义请求体。用户可以在同一个连接中保存
Provider 要求的附加元数据;GoodBuddy 只在直连链路或 Runtime 明确原生支持的链路中应用
这些值,不为不支持的 Runtime 伪造透传能力。
## 文档导航
| 文档 | 权威职责 |
| --- | --- |
| [UI 设计](./ui-design.md) | 编辑入口、校验反馈、兼容性说明和无障碍行为 |
| [技术设计](./technical-design.md) | 契约、持久化、合并优先级、Runtime 支持矩阵和验证 |
| [功能进度](./progress.md) | 已验证实现、剩余工作和验证证据 |
本次改动是现有模型连接的有界扩展,不新增独立业务对象或工作流,因此不单独建立 PRD、
User Stories 和逻辑设计;本页定义产品边界,技术设计定义唯一的请求合并规则。
## 术语
| 术语 | 定义 |
| --- | --- |
| 自定义请求头 | 用户为一个 LLM 模型连接保存的附加 HTTP Header 字符串映射 |
| 自定义请求体 | 用户为一个 LLM 模型连接保存的附加 JSON 顶层字段 |
| Runtime 字段 | GoodBuddy 或 Runtime 为模型、消息、工具、流式、认证和协议生成的字段 |
| 直连 | GoodBuddy Main 直接向模型 Provider 发出请求 |
| 受管远程 OpenCode | 模型请求通过 GoodBuddy Agent 的可信模型网关发出的远程 OpenCode |
## 产品边界
- 两项设置都位于“设置 → 模型连接 → LLM → 连接详情”底部,以 JSON 对象编辑。
- 直连模型支持请求头和请求体,包括连接测试、文本、工具、摘要和图像生成请求。
- Continue 原生支持请求头和请求体。
- 本地 OpenCode 原生支持请求头,不使用自定义请求体。
- DeepSeek Harness 的 Pi-AI Provider 原生支持请求头,不使用自定义请求体。
- 受管远程 OpenCode 的可信模型网关支持请求头和请求体。
- 使用 Runtime 自有配置时不引用 GoodBuddy 模型连接,因此不会应用连接级请求定制。
- Runtime、协议和认证字段始终优先。用户不能用请求定制替换模型、消息、工具、流式或
认证信息。
- 自定义值作为普通连接设置保存。API Key 和其他密钥必须使用专用凭据字段,不应写入
自定义请求头或请求体。
## 相关功能
- [直连模型 Agent](../direct-model-agent/README.md)
- [DeepSeek Harness Runtime](../deepseek-harness/README.md)
- [远程主机与远程执行](../remote-host/README.md)
@@ -0,0 +1,38 @@
# 模型连接请求定制进度
## 2026-09-03
### 已完成
- 模型连接设置、v20 → v21 迁移、公开与 resolved profile 已接入有界自定义 Header/Body。
- 设置页已提供两个 JSON 编辑器、就地校验、保存阻止、普通设置存储提示和 Runtime 支持范围。
- 直连连接测试、文本、工具、摘要和图像请求已统一采用 Runtime 优先的浅合并。
- Continue 1.5.47 已通过原生 `requestOptions.headers` 与 `extraBodyProperties` 接入两项配置。
- bundled OpenCode 已通过 Provider `options.headers` 接入 Header,并确认没有发送未支持的
自定义 Body。
- DeepSeek Harness 已通过控制协议 v3 把 Header 交给 Pi-AI Provider,没有接入未支持的
自定义 Body。
- Main 与 Agent 远程模型网关已接入 Prompt-scoped Header/Body;`runtime/acp` capability
已提升为 v5。
- 功能入口、支持矩阵、合并优先级、普通设置存储边界和协议版本已写入功能文档及双语
`FEATURES`。
### 已验证
- `npm run typecheck`
- `npm run lint`
- 20 个聚焦测试文件:527 项通过,4 项按环境跳过。
- 全量 `npm test`:326 个测试文件通过,8 个文件按环境跳过;3506 项通过,55 项跳过。
- bundled OpenCode 与 Continue 分别对 loopback Provider 验证 Chat Completions 和
Responses。4 项原生探针全部通过:两者都发送自定义 Header;Continue 发送自定义 Body;
OpenCode 不发送不支持的 Body。
- Main/Agent 远程网关测试验证了合并优先级、认证优先、profile digest 绑定和单次分发。
- 三路清理审查完成;已复用共享协议路径、Header 规范化、ACP capability 常量和 JSON
类型,并为 Renderer JSON 草稿增加解析前长度上限。
### 待验证
- 生产 `npm run build`。
- 最小真实模型 Header/Body 探针。
- DeepSeek Harness 真实 Provider Header 探针。
- 当前源码 Agent bundle 在共享 Linux x64 Host 上的受管远程模型请求。
@@ -0,0 +1,87 @@
# 模型连接请求定制技术设计
## 1. 共享契约
`src/shared/model-request-customization.ts` 定义:
- `ModelRequestHeaders`:字符串到字符串的映射。
- `ModelRequestBody`:顶层 JSON 对象。
- 两个 Zod schema、统一边界和合并函数。
模型连接输入、公开设置、Main 内 resolved profile 和远程 Prompt profile 复用这些类型。
Runtime 设置文件版本为 21;版本 20 迁移为两个空对象,不改变已有连接或凭据。
## 2. 边界
请求头最多 64 项;名称最长 128 字符,值最长 4096 字符。名称必须是合法 HTTP token,
大小写归一后不能重复,值不能包含控制字符。
请求体编码后最多 64 KiB,最大深度 16、最大节点数 4096;对象或数组单层最多 256 项。
键最长 256 字符,字符串值最长 32768 字符,数字必须有限。
下列请求头由认证、协议或传输层拥有,不能在连接中配置:
`Authorization`、`X-API-Key`、`API-Key`、`Content-Type`、`Content-Length`、`Host`、
`Connection`、`Transfer-Encoding`、`User-Agent`、`Anthropic-Version`。
下列请求体顶层字段由 GoodBuddy 或 Runtime 拥有,不能在连接中配置:
`model`、`stream`、`messages`、`input`、`instructions`、`system`、`tools`、
`tool_choice`、`parallel_tool_calls`、`max_tokens`、`max_output_tokens`、
`stream_options`、`prompt`、`n`、`quality`、`response_format`。
## 3. 合并规则
请求头先装载用户值,再装载 Runtime 和协议值,最后注入认证值。名称比较不区分大小写,
因此 Runtime、协议和认证值始终覆盖同名用户值。
请求体只做一次顶层浅合并:
```ts
const providerBody = {
...connection.requestBody,
...runtimeBody
}
```
不递归合并嵌套对象。最终请求仍需通过现有协议策略和请求大小校验。
## 4. Runtime 支持矩阵
| 请求路径 | 自定义 Header | 自定义 Body | 实现边界 |
| --- | --- | --- | --- |
| GoodBuddy 直连 | 支持 | 支持 | Main 在连接测试、文本、工具、摘要和图像请求中合并 |
| Continue | 支持 | 支持 | 使用 Continue 1.5.47 的 `requestOptions.headers` 与 `extraBodyProperties` |
| 本地 OpenCode | 支持 | 不支持 | 使用 OpenCode Provider `options.headers`;不生成未证实的 Body 配置 |
| DeepSeek Harness | 支持 | 不支持 | 使用 `dsh-llm-pi-ai` Provider 的 `headers`;Utility 控制协议 v3 传递 Header |
| 受管远程 OpenCode | 支持 | 支持 | Agent Prompt profile 传到可信模型网关后合并 |
| Runtime 自有配置 | 不适用 | 不适用 | 该模式不使用 GoodBuddy 模型连接 |
对不支持自定义 Body 的 Runtime,设置仍保留在连接上,切换回支持路径时继续生效;当前
Runtime 不接收也不发送该 Body。
## 5. 远程模型网关
非空请求定制随 Prompt-scoped model profile 发送,不写入 Agent 环境、命令行或模型调用
ledger。模型 profile digest 绑定规范化后的 Header 和 Body,但继续排除 API Key。
Main 网关和 Agent 网关都执行相同的可信 profile 合并、协议策略检查和最终 Body 大小检查。
认证 Header 在请求摘要生成后注入,避免凭据进入摘要或 ledger。受管远程
`runtime/acp` capability 为 v5;旧 v4 Agent 不能接收带请求定制的新 Prompt profile,
由现有受管更新流程升级后再运行。
## 6. 凭据
API Key 继续由现有 Main 加密设置和 Prompt-scoped Agent 凭据路径管理。自定义 Header 和
Body 是普通可见连接设置,不提供第二套通用秘密存储;UI 必须提示不要把密钥放入这两个
JSON 对象,标准认证 Header 也由 schema 拒绝。
## 7. 验证
- 共享 schema:JSON 类型、边界、保留字段和优先级。
- 设置存储:v20 → v21 迁移与 Header/Body round trip。
- 直连:连接测试、流式文本、工具、摘要和图像请求。
- Runtime:真实 bundled OpenCode/Continue 对 loopback Provider 的请求探针;DeepSeek
Utility 启动配置和 Pi-AI Provider 配置。
- 远程:Main/Agent 网关合并、认证优先级、摘要绑定、大小限制和不重放。
- UI:默认值、保存、无效 JSON 保留和 Runtime 支持说明。
@@ -0,0 +1,38 @@
# 模型连接请求定制 UI 设计
本文件定义模型连接请求定制的界面行为。全局视觉、表单和无障碍规则继续以
[`UI-DESIGN.md`](../../../UI-DESIGN.md) 为准。
## 信息架构
“自定义请求头”和“自定义请求体”位于当前 LLM 连接详情的最底部、连接兼容性摘要之前。
它们不新增页签、弹窗或高级模式。每个编辑器使用可纵向调整大小的等宽文本域,默认值为
`{}`。
## 编辑与保存
- 请求头接受 JSON 对象,所有值必须是字符串。
- 请求体接受 JSON 对象,值必须是有界、可序列化的 JSON。
- 用户输入在编辑时原样保留。JSON 语法、类型、大小或保留字段无效时,文本域显示
`aria-invalid` 和就地错误;点击保存时阻止写入,并在现有设置错误区域显示对应连接。
- 保存成功后,编辑器使用两个空格缩进重新格式化已保存对象。
- 新连接从两个空对象开始。
## 兼容性说明
请求头说明明确列出直连、Continue、OpenCode 和 DeepSeek Harness 在原生支持范围内使用
该值。请求体说明明确列出直连、Continue 和受管远程 OpenCode 支持,并说明本地
OpenCode 与 DeepSeek Harness 不使用该值。
同一区域还必须说明:
- Runtime、协议和认证生成的字段优先。
- 请求体只做顶层浅合并,不递归合并对象。
- 自定义值按普通连接设置保存,不能用于保存 API Key 或其他密钥。
## 无障碍与键盘
- 每个文本域有稳定的可访问名称,并通过 `aria-describedby` 关联兼容性说明。
- 无效状态不能只依赖颜色,必须同时提供错误文字和 `aria-invalid`。
- 文本域遵循标准键盘编辑、选择、复制和粘贴行为;保存继续使用设置页唯一的主保存操作。
- 焦点边框、字体、间距、颜色和深浅主题全部复用全局语义令牌。
+42 -27
View File
@@ -12,7 +12,7 @@ Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。
Windows 到 Linux x64 的安装、Agent-owned Prompt、Agent 本地模型 gateway、断线恢复、
同一 OpenCode Session 续接、取消和终态清理已经使用真实模型与工具验证。Agent
`0.11.14` 已通过独立 workflow 发布 Linux x64/arm64 复合包和签名累计目录;当前未发布
Agent 源码 lock 为 `0.11.15`,Desktop 恢复候选为 `0.12.2`,等待各自独立发布审批。
Agent 源码 lock 为 `0.11.17`,Desktop 候选为 `0.12.3`,等待各自独立发布审批。
现有源码显示本地与远端 OpenCode 原生 Task,并取消 GoodBuddy 对生产 Prompt 的
固定墙钟总时限。失败的 `agent-v0.11.3` 保持不可变且未发布。
@@ -182,21 +182,18 @@ selection,Renderer 会恢复为当前 OpenCode 配置;Main 的远程请求
### Ask
Ask 使用系统 `bwrap`:
Ask 与 Execute 一样直接启动已签名 Runtime,不要求 Host 安装额外的进程隔离命令:
- Workspace 和 Runtime bundle 只读 bind;
- 独立可写 scratch HOME、TMPDIR 和 XDG 目录;
- 固定环境名和固定 Runtime argv;
- 通过只读边界阻止 Workspace 修改。
- `cwd` 为项目 Workspace,并继承 SSH 账号的正常环境;
- ACP 权限请求只有在工具种类为原生 `read` 且 Runtime 提供 `allow_once` 选项时才允许;
search、edit、execute、未知工具以及只提供持久授权的请求全部拒绝。
`allow_once` 只解决 OpenCode 发起原生读取前的 ACP 协商,真正的文件系统边界仍是只读
`bwrap` bind。Host 缺少 `bwrap` 时 Ask 不可用。
- Ask 启动时同时注入 OpenCode 顶层和 build Agent 的 `permission: "ask"`;Agent
工具权限分发边界只批准上述原生读取请求。该双重边界不另加文件系统 confinement,
也不改变 Runtime 的直接启动方式。
### Execute
Execute 不经过 Ask 的 bubblewrap profile:
Execute 直接启动已签名 Runtime:
- 直接启动已签名 Runtime entrypoint;
- 使用 Agent 本地模型 gateway 时,签名 Agent launcher 会 `exec` 候选 manifest 锁定的
@@ -204,12 +201,14 @@ Execute 不经过 Ask 的 bubblewrap profile:
launcher 路径;
- `cwd` 为项目 Workspace;
- 继承 SSH 账号的正常环境、文件系统、进程和网络能力;
- 不注入 OpenCode Ask 权限配置;
- 不进行 T2/T3、confinement attestation、approval bridge 或逐工具批准。
两种模式都保留输入/输出字节上限、用户取消和进程组清理。生产 Prompt 不设置固定墙钟
总时限;只要 Runtime 尚未按自身协议结束,Main 和 Agent 就允许其持续运行。启动、握手、
单个控制 RPC、重连尝试和关闭清理仍使用独立的有界超时,测试可以显式注入短 Prompt
时限验证取消升级,但该覆盖值不是生产默认。无界 Prompt 继续复用已发布的必填
两种模式都保留输入字节上限、用户取消和进程组清理;输出只用有界内存队列与 journal
配合背压,不按 Prompt 累计输出量停止 Runtime。生产 Prompt 不设置固定墙钟总时限;
只要 Runtime 尚未按自身协议结束,Main 和 Agent 就允许其持续运行。启动、握手、单个
控制 RPC、重连尝试和关闭清理仍使用独立的有界超时,测试可以显式注入短 Prompt 时限
验证取消升级,但该覆盖值不是生产默认。无界 Prompt 继续复用已发布的必填
`deadlineAt` 字段,以 year-9999 哨兵表达;Main 与 Agent 必须同时理解该哨兵,Agent
`0.11.10` 及更早版本仍会按其 manifest 上限夹紧,因此桌面启用这一语义前必须配套更新
到 Agent `0.11.13` 或更高版本。
@@ -226,9 +225,11 @@ Execute 不经过 Ask 的 bubblewrap profile:
调度、跨 channel 超车、批发送或第二套拥塞控制。
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
- Runtime 事件先经过单事件、单请求输出和每次 Prompt 最多 100 个不同工具调用的边界;
通过边界后,Renderer 和 Main 将已接受的工具详情保存到本地 SQLite,不再额外缩短其
输入、输出、错误或摘要。
- Runtime 事件仍对单项展示字段做有界截断,但不按固定工具调用数或 Desktop 累计展示量
中止 Prompt;待消费事件达到阈值时通过 ACP 入站暂停施加背压,不取消原生 Runtime。
Renderer 和 Main 为每条回复保留最多 500 条工具与子 Agent 活动以及 2,000 个交错
展示块;超过后显示本地截断提示,但继续执行 Runtime,也不把展示容量当作 Prompt
工具调用上限。已保留活动的后续状态更新仍会正常归并。
- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID
解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP
首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent
@@ -238,8 +239,9 @@ Execute 不经过 Ask 的 bubblewrap profile:
- 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。
- 只有远端返回 identity 匹配且 `closed: true` 时,Main 才删除持久化 binding;传输失败或未确认 close 会保留 recovery identity。
- 终态 close 由 Agent 在一个事务中为两个方向记录 sequence high-water tombstone,删除剩余 frame 和 active channel,并归零对应 journal quota。tombstone 用于拒绝迟到的旧 epoch frame,因此当前不会按时间自动裁剪。
- 断线不主动终止正在运行的 Runtime。用户取消、显式关闭、输出边界或 Agent shutdown
才触发停止;连接恢复只受当前用户取消信号和单次重连控制超时约束,不把 Prompt 已运行
- 断线不主动终止正在运行的 Runtime。用户取消、显式关闭或 Agent shutdown 才触发停止;
输出队列和 journal 达到容量时暂停读取并等待 ACK 释放容量,不把展示或传输容量转换为
Runtime 取消。连接恢复只受当前用户取消信号和单次重连控制超时约束,不把 Prompt 已运行
时长当作停止条件。
- Agent 失联期间继续把 Runtime 输出写入本机 journal;重新连接只校验 controller
identity、恢复 binding,并从 Main 最后确认的 cursor 后同步缺失数据。已接受的
@@ -250,7 +252,9 @@ Execute 不经过 Ask 的 bubblewrap profile:
- 托管 SSH 的生产 Prompt 从一开始就由 Agent 持有 ACP `ClientSideConnection`、原始
Prompt Promise、Provider 轮次和 Runtime 进程。SSH relay 或 Desktop 进程不属于该
Promise 的生命周期,因此正常退出、强制结束 Desktop 或本机网络中断后,Host 仍能继续
后续模型/工具轮次。该路径要求 `runtime/acp` capability v4,即 Agent `0.11.14` 或更高。
后续模型/工具轮次。该路径现在要求 `runtime/acp` capability v5;v5 同时保证 Agent
能理解 Prompt-scoped 模型连接的自定义 Header/Body,旧 v4 Agent 由现有受管更新流程
升级后再运行。
- Agent 把 ACP session update、permission decision 和唯一 Prompt 终态写入有界语义
transcript。Main 不恢复旧 JSON-RPC Promise,也不重发 `session/prompt`;Fresh Desktop
只对同一 controller/binding/operation 执行 takeover、attach 和 ACK-exclusive page。
@@ -289,9 +293,10 @@ Execute 不经过 Ask 的 bubblewrap profile:
各限制 768 KiB,响应同时核对声明长度与短读。每个
`(binding, operation, roundIndex)` 只 dispatch 一次;已 dispatch 但未证明完整交付的调用
变为 `outcome-unknown`,不会自动重试。
- Prompt 最多 100 个模型调用,并受单次与累计输出 Token 上限保护;这些是资源边界,不是
Prompt 墙钟总时限。Provider 单次请求仍有独立超时。实际 usage 在语义 transcript 中同步
回 Desktop 的 usage/task 数据。Agent 在所有进程终止路径统一清除 Prompt token 计数。
- Agent 不按固定模型调用次数或 Prompt 累计输出 Token 数停止 Runtime;每次 Provider
请求仍遵循所选模型 profile 的单次输出设置和独立请求超时。模型桥协议不包含 Prompt
调用次数或累计 Token 配额字段。实际 usage 在语义 transcript 中同步回 Desktop 的
usage/task 数据。
- helper 可以接收同一 Prompt 内并发到达的模型桥请求;它在单一稳定模型桥上按到达
顺序等待并交付,不返回本地 `bridge-busy`,每个响应只有在 HTTP 完整 flush 后才
发送 delivery ACK。
@@ -349,7 +354,7 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
- 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0`
和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布
的双架构验收。Agent `0.11.10` 后续已由原生 workflow 发布并公开验证双架构工件;
当前源码 lock 固定为 `0.11.15`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
当前源码 lock 固定为 `0.11.17`;正式发布状态以独立 Agent Release 与签名 catalog 为准。
- 2026-08-30 在共享 Linux x64 Host 的隔离测试 HOME 中验证当前 `0.11.13` 源码候选:
签名 Runtime 清单的测试墙钟上限为 1 秒,模型桥首轮故意延迟 2.515 秒后 Prompt 仍在
5.770 秒正常完成;随后 OpenCode 原生 Task 依次产生 `running`、`completed` 子 Agent
@@ -397,6 +402,17 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
workflow 与 Release Notes 聚焦测试 `106/106` 通过、1 项平台用例跳过;全量
`npm test` 为 `3478` 通过、`51` 跳过,typecheck 与 lint 通过。本轮没有新增模型调用,
也未在本地运行 production build、package 或安装探针。
- 2026-09-03 使用包含当前 Runtime capability 修复的源码 harness,在共享 Linux x64 Host
的唯一 `/tmp/goodbuddy-e2e-runtime-capability-*` 隔离 HOME 中读取签名 Runtime 副本。
Agent 成功报告已登记 OpenCode Runtime;最终源码又在唯一
`/tmp/goodbuddy-e2e-direct-modes-*` 隔离 HOME 中分别直接启动 Ask 与 Execute,两个模式
都未经过外部进程隔离命令。Runtime owner 和隔离目录均已清理,未修改 Host current
Agent/Runtime。本次只验证能力声明和真实进程启动,没有发送 Prompt,真实文本模型调用
0 次。
- 2026-09-03 使用包含本次模型轮次与输出背压修复的当前源码 Agent,在共享 Linux x64
Host 的唯一 `/tmp/goodbuddy-e2e-*` 目录完成 Runtime 激活和 Agent 本机模型 gateway
验证。模型 gateway 通过所选 Anthropic 连接返回预期随机哨兵,HTTP 状态为 200,实际
文本模型调用恰好 1 次;Host 临时目录随后删除,共享 Agent/Runtime 安装和进程未修改。
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
@@ -439,9 +455,8 @@ Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转
identity 已变化且旧 authority 无法恢复,或原 binding 已有 `outcome-unknown` 终态时,显式
新 Prompt 才替换旧 binding。新 ACP session 的首次 Prompt 会把 Desktop 已持久化的有界
会话历史作为不可信数据一并发送;成功加载或恢复原 ACP session 时不会重复注入历史。
Ask 模式的 Linux Runtime 进程由 `bwrap` 启动后 `exec` 到已签名 Runtime 或 Agent
model bridge helper;监督器只接受同一 PID/进程组从固定 `bwrap` 路径到预期最终可执行文件
的有界转换,再登记为运行中进程。
Ask 和 Execute 的 Linux Runtime 进程都直接启动已签名 Runtime 或 Agent model bridge
helper;监督器核对启动后的最终 executable,再登记为运行中进程。
不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema
v32 保留项目及关联用户数据,并加入 remote-recoverable Task/message/provenance/cursor;
+29
View File
@@ -0,0 +1,29 @@
# ShareServer
ShareServer 是 GoodBuddy 设备共享网络的独立 Web 服务。它为一个或多个组织提供身份与设备
目录、能力发布、授权策略、任务路由、可选数据中继、能力包更新、网关联邦和审计管理。
ShareServer 不属于 Electron Main,不负责桌面端局域网发现,也不自动取得设备上的能力、
文件、凭据或 SSH Host。
## 文档导航
| 文档 | 权威职责 |
| --- | --- |
| [PRD](./prd.md) | 管理角色、产品范围、功能需求和验收 |
| [功能逻辑](./logic-design.md) | 组织隔离、设备、发布、策略、任务和联邦状态规则 |
| [管理界面设计](./ui-design.md) | ShareServer Web 控制台的信息架构与交互 |
| [技术设计](./technical-design.md) | 服务模块、API、数据、部署、运维和验证 |
| [共享网络总体设计](../../architecture/share-network-architecture.md) | 跨端身份、能力、授权、任务、更新和协议权威 |
当前没有单独的 User Stories:管理员、能力发布者、审计员和普通成员场景已进入 PRD 与逻辑
设计。服务尚未实施,因此不创建 `progress.md`。
## 角色
- **系统管理员**:维护实例、身份源、域名、证书、存储和全局限制。
- **组织管理员**:管理本组织成员、设备、组、策略、能力和联邦关系。
- **能力发布者**:在获准范围内发布设备能力或签名能力包。
- **审批人**:处理调用、发布、更新和联邦申请。
- **审计员**:只读查看和导出获准范围内的审计事件。
- **成员**:主要通过 GoodBuddy Desktop 使用能力,只在 Web 端处理个人设备和审批。
+209
View File
@@ -0,0 +1,209 @@
# ShareServer 功能逻辑设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 共享规则 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
## 1. 核心不变量
### INV-1 组织隔离先于角色
任何查询和变更先绑定 organization,再计算角色。系统管理员的跨组织操作使用独立系统管理
入口并形成审计,不能依靠省略 organization 条件获得全局数据。
### INV-2 注册不等于发布
设备注册只建立身份和连接资格。没有有效 Publication 时,目录中没有该设备能力。
### INV-3 目录不等于授权
看到能力不能推导可调用或可下载。创建任务和下载包时重新计算当前授权。
### INV-4 服务端授权不能扩大端点授权
ShareServer 的 `allow` 只是签发任务资格。Provider 和调用方仍执行各自本地策略;任一端拒绝
即终止动作。
### INV-5 策略拒绝优先
同一动作命中多个规则时使用 `deny > require_approval > allow`。没有匹配 allow 时默认 deny。
### INV-6 联邦为双边交集
单方创建或接受的关系不生效。双方 active 且两端发布、Grant 和数据策略均允许时才能跨组织
创建任务。
### INV-7 任务提供者不可改写
ShareServer 只路由调用方冻结的 provider,不在离线、繁忙或失败时替换节点。
### INV-8 不恢复短期权威
备份恢复不恢复 Web 会话、设备 socket、一次性挑战、审批 nonce 或任务数据通道。终端必须
重新连接并证明身份。
### INV-9 初始化入口一次性关闭
初始化只在实例尚无系统管理员时可用。首个系统管理员创建成功后,后续请求即使持有旧的
一次性引导材料也不能再次进入初始化流程。
## 2. 主要状态
### 2.1 成员
```text
invited -> active -> suspended -> active
invited -> expired | revoked
active/suspended -> removed
```
`suspended` 不能登录或创建新任务;历史归属保留。`removed` 不级联删除审计和已完成任务。
### 2.2 注册设备
```text
pending -> active <-> offline
pending -> expired | rejected
active/offline -> revoked
```
客户端心跳只决定 `active/offline`,不能清除 `revoked`。重新注册生成新设备 registration。
### 2.3 Publication
```text
draft -> pending_approval -> active
draft/pending_approval -> rejected
active <-> paused
active/paused -> revoked
```
provider 离线不会把 Publication 变为 paused;目录同时展示“已发布”和“当前离线”两个事实。
### 2.4 审批
```text
pending -> approved | rejected | cancelled | expired
```
审批对象内容摘要、申请主体或策略版本变化后,原审批不能复用。approved 只产生其声明范围内
的 Grant 或一次性决定。
### 2.5 Package Release
```text
draft -> verifying -> available -> paused -> available
verifying -> invalid
available/paused -> revoked
```
发布后的版本和字节不可变。修复内容必须使用新版本;revoked 不删除审计证据和对象摘要。
### 2.6 FederationTrust
```text
draft -> offered -> mutually_confirmed -> active
offered -> rejected | expired
active <-> paused
任意非终态 -> revoked
```
`mutually_confirmed` 只表示身份确认完成;双方策略同步成功后才进入 active。
## 3. 授权计算顺序
1. 验证请求身份、会话 audience 和 organization。
2. 验证用户、设备、provider、Publication 和 FederationTrust 当前状态。
3. 校验能力版本、动作、输入 schema、数据类别、大小和委托链。
4. 收集调用方组织、提供方组织、Publication 和 Grant 规则。
5. 按拒绝优先合并,计算 `deny/require_approval/allow`。
6. 校验次数、并发、预算和有效期。
7. 返回决定、稳定原因和策略引用;需要批准时建立内容绑定申请。
8. 创建任务时再次计算,并把决定快照写入任务。
目录查询可以预计算“可能可用”,但不能替代第 1 至 8 步。
## 4. 组织角色边界
| 动作 | 系统管理员 | 组织管理员 | 发布者 | 审批人 | 审计员 | 成员 |
| --- | --- | --- | --- | --- | --- | --- |
| 实例设置 | 是 | 否 | 否 | 否 | 只读有限项 | 否 |
| 成员与组 | 紧急管理 | 是 | 否 | 否 | 只读 | 自己 |
| 注册设备 | 只读/撤销 | 是 | 自己 | 否 | 只读 | 自己 |
| 发布能力/包 | 否 | 是 | 授权范围 | 否 | 只读 | 否 |
| 编辑策略 | 否 | 是 | 否 | 否 | 只读 | 否 |
| 处理审批 | 否 | 可配置 | 否 | 授权范围 | 只读 | 自己的申请 |
| 联邦关系 | 实例级许可 | 是 | 否 | 可共同审批 | 只读 | 否 |
| 审计导出 | 实例级 | 组织级 | 自己相关 | 自己相关 | 授权范围 | 自己相关 |
实际权限仍由策略计算,表格定义产品默认职责而非硬编码角色名称。
## 5. 任务路由决策
| 条件 | 结果 |
| --- | --- |
| provider 在线且双方可直连 | 签发短期直连协商信息 |
| 无法直连且双方策略允许中继 | 建立有界中继通道 |
| 中继关闭或任一方禁止 | 创建失败,说明无可用路由 |
| provider 离线 | 不排队到无限期,按能力配置有界等待或拒绝 |
| provider 接受后连接中断 | 保留 task,等待同一 provider 恢复/查询 |
| provider 终态未知且恢复期结束 | `outcome_unknown` |
| 调用方取消 | 转发取消;provider 确认后终态 cancelled |
中继容量不足只产生排队或资源错误,不切换 provider,不改变是否允许中继。
## 6. 更新策略优先级
```text
紧急撤销
> 组织强制固定版本/禁止版本
> 设备组发布通道
> 用户允许的版本范围
> 当前已安装版本
```
任何权限扩大都从自动路径转为 `require_approval`。降级只有在目标版本仍被签名目录允许且
策略明确指定时才提供,不自动视为故障回滚。
## 7. 联邦可见性
联邦目录只导出对方明确允许的能力投影:组织显示名、能力、版本范围、数据政策和可用性摘要。
不导出内部设备名、成员清单、组结构、节点地址、内部策略全文或审计记录。跨组织请求使用
双方关联 ID,双方各自解析本地主体。
## 8. 删除、撤销与保留
- 删除组织前必须停止新任务、撤销联邦和设备,并按保留策略归档审计;不提供无提示级联。
- 删除用户不删除其历史动作,审计显示不可变主体摘要。
- 撤销设备、Publication、Grant、Package 或联邦后立即阻止新令牌。
- 对象存储中的未完成任务正文按任务期限清理;审计元数据按组织保留策略清理。
- 法定保留只锁定匹配审计事件,不使被撤销身份重新可用。
## 9. 需求追踪
| 需求 | 主要逻辑 |
| --- | --- |
| `FR-1` | `INV-1`、`INV-9` 和系统管理员角色边界 |
| `FR-2` | 组织隔离不变量、成员状态和角色边界 |
| `FR-3` | 注册不等于发布、设备状态和短期权威恢复规则 |
| `FR-4` | 注册、Publication、目录与授权不变量 |
| `FR-5` | 拒绝优先不变量和授权计算顺序 |
| `FR-6` | 审批状态、内容绑定和授权重算 |
| `FR-7` | provider 冻结不变量和任务路由决策 |
| `FR-8` | Package Release 状态和更新策略优先级 |
| `FR-9` | 联邦双边交集、FederationTrust 状态和联邦可见性 |
| `FR-10` | 组织隔离、删除撤销与保留规则 |
| `FR-11` | 组织角色边界及各对象状态;展示行为由 UI 设计定义 |
| `FR-12` | 短期权威恢复规则和逻辑完整性;部署结构由技术设计定义 |
## 10. 逻辑完整性
本设计覆盖实例、组织、成员、设备、发布、授权、审批、任务、更新、联邦、撤销和恢复。实现
前仍需确定首个 OIDC 提供者矩阵、默认审计保留期、部署规模目标和中继带宽上限;这些部署
参数应可配置,不改变拒绝优先、双边联邦和 provider 冻结规则。
+149
View File
@@ -0,0 +1,149 @@
# ShareServer PRD
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 适用产品 | 独立部署的 ShareServer 与 Web 管理控制台 |
| 关联架构 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
| 关联文档 | [功能逻辑](./logic-design.md)、[管理界面](./ui-design.md)、[技术设计](./technical-design.md) |
## 1. 背景
局域网配对适合附近设备,但不能统一解决跨网络访问、成员离职、组织策略、集中更新、审计和
跨组织协作。ShareServer 提供一个可自建的组织控制面,让设备通过主动出站连接注册能力并
接收任务,不要求为每台桌面设备开放公网端口。
## 2. 产品目标
1. 组织可在自有网络、私有云或公网独立部署 ShareServer。
2. 管理员可统一管理用户、组、设备、能力、权限、更新和审计。
3. 成员通过桌面端使用获准能力,无需日常进入管理控制台。
4. 设备离线、用户撤销或策略变化能准确影响新任务,不破坏历史证据。
5. 两个组织可经双边确认建立有限范围的联邦协作。
6. ShareServer 可以只运行控制面,也可按部署策略提供数据中继和包存储。
## 3. 产品范围
### 3.1 包含
- 本地账号及可配置 OIDC 身份源。
- 组织、成员、角色、用户组、设备组和服务账号。
- 设备注册、状态、撤销和合规元数据。
- 能力目录、Publication、Grant、强制策略和审批。
- 设备出站控制通道、任务路由、取消和可选中继。
- 签名能力包仓库、发布通道、更新策略和撤销。
- ShareServer 间双边联邦。
- 审计、用量、保留和导出。
- Web 管理控制台和成员自助页面。
### 3.2 不包含
- 远程管理设备桌面、任意 Shell 或任意文件系统。
- 发现未注册的局域网设备。
- 保存或分发用户 API Key、Cookie 和桌面登录状态。
- 默认查看任务正文、附件和模型输出。
- 未经提供者发布自动盘点设备能力。
- 自动信任同域邮箱、同一网络或其他 ShareServer。
- 公共开放市场、计费结算或匿名能力调用。
- 替代 GoodBuddy 官方客户端、模型和 Agent 发布源。
## 4. 功能需求
### FR-1 实例初始化
- 首次启动通过一次性引导创建实例标识和首个系统管理员。
- 引导完成后永久关闭初始化入口,后续管理员通过正常身份流程创建。
- 实例公开描述包含名称、协议版本、登录方式和联邦端点,不包含内部拓扑。
### FR-2 身份与组织
- 支持一个实例承载多个隔离组织,也支持部署为单组织模式。
- 支持本地账号和 OIDC;组织可限制允许的身份源。
- 成员可以属于多个组并拥有有界角色,角色不隐式跨组织。
- 停用用户立即停止签发新设备和任务凭据。
### FR-3 设备管理
- 桌面端经用户登录、一次性挑战和设备签名完成注册。
- 设备条目显示所有者、平台、客户端版本、最近在线、连接和撤销状态。
- 设备主动建立出站控制连接;服务端不能借此调用未发布能力。
- 管理员可撤销设备并终止其新任务资格。
### FR-4 能力目录
- 只接收已注册设备或服务节点提交的显式 Publication。
- 目录按调用者身份、设备状态、组织和策略过滤后返回。
- 支持同一能力多个版本和多个 provider,不掩盖来源。
- provider 离线时保留目录事实但显示不可用,不派发新任务。
### FR-5 授权策略
- Grant 可绑定用户、组、设备、设备组和联邦组织。
- 可约束能力、版本、动作、输入类型、数据分类、提供者、时间、次数、并发和委托。
- 支持组织强制策略和用户可收紧偏好;拒绝优先。
- 每个决定返回稳定结果、命中策略和面向用户的原因。
### FR-6 审批
- 调用、能力发布、权限扩大更新和联邦关系可以要求审批。
- 审批项显示申请人、对象、影响范围、数据类别、期限和策略依据。
- 批准结果有明确范围和有效期,不提供无边界“永久允许”。
- 申请内容变化后旧审批失效。
### FR-7 任务路由
- 根据冻结 provider、在线连接和路由策略把任务声明发送给目标设备。
- 支持直连协商、网关中继和取消传播。
- 任务正文与控制元数据分离;默认审计不保存正文。
- 已接受任务断线后允许查询同一 task,不自动创建重复执行。
### FR-8 能力包与更新
- 保存不可变签名 Package 和发布目录。
- 支持组织、组、平台、架构和维护窗口的更新策略。
- 支持固定版本、允许版本范围、暂停发布和紧急撤销。
- 权限扩大必须触发新批准,不能被普通自动更新策略覆盖。
- 包来源、大小、摘要、签名和下载结果可审计。
### FR-9 联邦
- 两个 ShareServer 通过指纹交换和双方管理员确认建立关系。
- 双方分别配置公开能力、允许主体、数据范围、用量和期限。
- 最终权限取双方策略交集;任一端撤销后停止新任务。
- 不默认链式转发到第三个组织。
### FR-10 审计与用量
- 记录身份、设备、发布、授权、审批、任务、包和联邦管理事件。
- 默认仅记录共享架构定义的有界元数据。
- 支持按组织隔离查询、条件筛选、分页和流式导出。
- 审计员只读,不能因查看日志取得任务正文或管理权限。
### FR-11 管理控制台
- 提供概览、组织与成员、设备、能力目录、权限策略、审批、发布与更新、网关联邦、任务与
中继、审计、系统设置。
- 普通成员只看到自己的设备、授权和待办;管理员栏目按权限隐藏并在 API 再次校验。
- 所有高影响操作说明对象、范围和后果。
### FR-12 可部署性
- 支持容器部署,配置与持久数据分离。
- 支持外部 PostgreSQL;对象存储和中继按功能启用。
- 提供健康、就绪、指标、结构化日志、备份和恢复说明。
- 服务升级不能要求同时升级所有在线桌面端;按协议版本协商兼容集合。
## 5. 产品验收
- 新实例可完成初始化、登录、创建组织、邀请成员和注册真实桌面设备。
- 两个不同组织不能通过 API、搜索、计数、错误或审计结果读取对方数据。
- 设备只有发布能力后才进入获准目录,撤销后不能接收新任务。
- 调用授权能同时处理用户、设备、组织、提供者、数据和联邦限制。
- 任务中继关闭时控制面仍可运行;中继开启时可取消并形成准确终态。
- 权限扩大的包更新不能自动部署。
- 两个独立 ShareServer 能建立、使用、暂停和撤销双边联邦。
- 备份恢复后组织、策略、发布和审计身份保持一致,短期连接和令牌不恢复。
@@ -0,0 +1,242 @@
# ShareServer 技术设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
| 共享协议 | [共享网络总体设计](../../architecture/share-network-architecture.md) |
## 1. 仓库与运行边界
实现时在仓库顶层创建独立项目:
```text
shareserver/
README.md
package.json
src/
api/
auth/
organizations/
devices/
capabilities/
policies/
approvals/
tasks/
relay/
packages/
federation/
audit/
web/
migrations/
deploy/
tests/
```
当前只提交设计文档,不创建无法运行的目录占位。服务端和 Desktop 共同依赖未来的
`packages/share-protocol`,ShareServer 不导入 Electron 的 `src/shared`。
## 2. 技术选型约束
- 使用与仓库一致的 TypeScript/Node.js 工具链,具体 HTTP 框架在实现前根据维护性选择。
- PostgreSQL 是组织、策略、任务元数据和审计的权威数据库。
- Package 与可选中继暂存使用 S3 兼容对象存储;不开启对应功能时不要求对象存储。
- Web 控制台与 API 同源部署,采用严格 CSP,不在浏览器保存设备凭据。
- 单实例部署可完整工作;水平扩展时再引入共享事件/连接路由组件,不为未验证规模预设复杂
分布式协议。
## 3. 服务模块
| 模块 | 职责 |
| --- | --- |
| `auth` | 本地身份、OIDC、Web session、服务和设备凭据 |
| `organizations` | 组织、成员、组、角色和范围解析 |
| `devices` | 注册挑战、设备记录、出站连接和在线状态 |
| `capabilities` | manifest、provider、Publication 和目录投影 |
| `policies` | Grant、强制策略和确定性授权计算 |
| `approvals` | 内容绑定申请、决定、过期和通知 |
| `tasks` | task/attempt、provider 路由、状态和取消 |
| `relay` | 可选有界数据中继,不解释能力正文 |
| `packages` | 不可变对象、签名目录、通道和撤销 |
| `federation` | 网关身份、offer、双边策略和跨组织映射 |
| `audit` | 事务内审计写入、查询、保留和导出 |
模块通过服务接口调用,API handler 不直接拼接跨组织 SQL 或自行计算权限。
## 4. API 边界
建议分为四类 audience:
```text
/api/v1/web/* 管理控制台与成员自助 API
/api/v1/device/* 设备注册、目录和任务控制 API
/api/v1/federation/* ShareServer 间 API
/api/v1/public/* 服务描述、健康与登录入口
```
设备使用 WebSocket `/api/v1/device/connect` 建立出站控制通道。包下载使用短期、绑定主体和对象
摘要的 URL。联邦 API 使用网关身份,不接受普通用户 session。每类 token 使用不同 issuer/
audience/scope,不能互换。
所有请求经 schema、认证、organization scope、授权、速率和大小边界。列表使用游标分页和固定
最大页;错误返回 request ID、稳定 code 和有界参数。
## 5. 数据模型
核心表建议为:
```text
instances
identity_providers
users
organizations
memberships
groups
group_members
role_bindings
registered_devices
device_credentials
device_connections
capabilities
capability_versions
providers
publications
grants
policies
approval_requests
approval_decisions
tasks
task_attempts
task_events
package_objects
package_releases
release_channels
update_policies
federation_trusts
federation_capability_exports
audit_events
revocations
```
要求:
- 所有组织数据表包含不可为空的 `organization_id`,并由 repository 层强制作用域。
- 唯一约束同时包含 organization,避免跨组织名称冲突泄漏。
- 密钥保存为单向摘要或经外部主密钥加密的密文;数据库不保存明文 token。
- 在线连接和短期 nonce 主要在内存;表中只保存恢复所需元数据,不把 socket 当持久对象。
- task event 只存控制状态和有界统计;正文流不进入普通关系表。
## 6. 事务与一致性
- 业务变更与对应审计事件在同一 PostgreSQL 事务提交。
- Publication/Grant/Policy 使用 revision 做乐观并发,Web 草稿不能覆盖他人更新。
- 授权决定创建 task 时再次读取当前 revision,并将命中策略摘要冻结到 task。
- 设备在线状态允许短暂最终一致;身份、撤销、授权和任务接受必须走权威存储。
- 重复注册挑战、联邦 offer 和 task create 使用唯一幂等键。
- 无法确认 provider 副作用时只更新 `outcome_unknown`,不靠消息队列自动重放。
## 7. 设备连接与任务
连接建立后完成设备 credential、organization、客户端协议和当前 registration 校验。服务端为
每个连接设置心跳、空闲、帧、发送队列和并发上限。
任务流程:
```text
authorize -> create task -> resolve exact provider connection
-> send declaration -> provider accepts/rejects
-> establish direct or relay route -> stream progress
-> provider terminal result -> caller delivery -> audit terminal state
```
同一设备重复连接时使用 connection generation;新连接接管未来任务,旧连接仍只允许完成或
查询已归属 task,随后有界关闭。不能把新任务随机发到身份相同但 generation 过期的 socket。
## 8. 中继
中继是可选模块:
- 控制面与中继可部署在不同进程,但共享 task 授权验证。
- 每个 relay ticket 绑定 task、调用方、provider、方向、期限和最大字节。
- 实现背压和全局/组织/设备并发及带宽上限。
- 不解析能力正文,不写普通日志;需要临时落盘时使用加密 GoodBuddy-owned 暂存并按 task 清理。
- 中继关闭、过载或策略禁止时返回准确路由错误,不静默转到其他节点。
## 9. Package 存储
上传先进入不可公开暂存:流式计算大小和 SHA-256,验证签名、manifest、版本、平台和权限后,
以 digest 发布为不可变对象。相同 digest 可去重,但组织授权和 Release 分别保存。
Release catalog 使用独立签名域。服务器不能重打包已签名 payload;镜像目标必须保持相同字节、
大小和 SHA-256。撤销更新目录和授权,不覆盖或原地删除仍受审计保留的对象。
## 10. 联邦协议
- 每个实例生成网关身份和可轮换证书,私钥不进入数据库备份明文。
- offer 包含双方可人工核对的组织名、端点、指纹、协议和 nonce。
- 双方确认后交换有界 capability projection 和本地映射 ID。
- 跨组织 task 带双方关联 ID 和短期签名声明;每端独立授权并各写审计。
- 不接受调用方提供的任意回调 URL、第三方 hop 或内部主体 ID。
- 证书轮换需要旧身份签名或双方重新确认;身份突变直接暂停关系。
## 11. Web 安全
- 使用 HttpOnly、Secure、SameSite cookie 和 CSRF 防护管理 Web session。
- OIDC 使用 state、nonce、PKCE 和固定回调地址。
- 富文本字段按纯文本显示;manifest/schema 使用安全代码查看器,不执行 HTML。
- 权限隐藏仅改善界面,所有 API 在服务端重新校验。
- 登录、设备注册、目录、审批、导出和联邦接口分别限速。
- 密钥字段只能替换、轮换和测试,永不回显。
## 12. 部署与配置
最小生产部署:
```text
TLS reverse proxy/load balancer
ShareServer API + Web
PostgreSQL
可选:S3 compatible object storage
可选:Relay worker
```
部署配置通过环境变量或挂载文件提供数据库、主密钥、公开 URL、OIDC、邮件、对象存储和限制。
普通 Web 管理员不能修改基础设施秘密。提供数据库迁移命令、不可变镜像、非 root 容器、健康
与就绪端点、优雅停止和部署示例。
## 13. 可观测性与备份
- 结构化日志只包含 request/task/organization 关联 ID、稳定事件和错误码。
- 指标覆盖请求、WebSocket、授权结果、任务状态、中继、Package 和联邦,不包含能力正文。
- tracing 跨内部服务传播关联 ID,跨联邦只传播双方同意的关联标识。
- 备份 PostgreSQL、Package 对象和网关身份;不备份缓存、Web session、nonce 或中继暂存。
- 恢复后撤销列表、组织 ID、签名目录和审计序列保持有效,所有设备重新连接。
## 14. 测试策略
### 14.1 自动化
- schema、认证 audience、CSRF、OIDC 和设备挑战。
- 每个 repository/API 的跨组织隔离与枚举防护。
- 拒绝优先授权、审批内容绑定、预算和并发。
- device generation、离线、撤销、重连和 provider 冻结。
- task 幂等、取消、结果未知和不自动重放。
- Package 流式校验、不可变性、权限 diff 和撤销。
- 联邦双方确认、策略交集、暂停、证书变化和禁止第三 hop。
- 审计事务、过滤、分页、导出和正文缺失。
### 14.2 集成与部署
- Desktop 与真实 ShareServer 完成注册、目录、调用、文件和取消。
- 两个隔离组织验证搜索、计数、错误、对象和审计不泄漏。
- 两个 ShareServer 完成联邦建立、跨组织调用和单方撤销。
- 中继关闭与开启、容量耗尽、连接中断和大文件背压。
- 数据库备份恢复、版本升级、滚动停止和设备重连。
- 生产 TLS、OIDC 和对象存储组合的端到端验证。
设计完成不代表服务已实现;只有独立部署和真实 Desktop 端到端路径通过后才能更新实施状态。
+159
View File
@@ -0,0 +1,159 @@
# ShareServer Web 管理界面设计
## 文档信息
| 项目 | 内容 |
| --- | --- |
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | [ShareServer PRD](./prd.md) |
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
ShareServer 是独立 Web 产品,不直接复用 Electron 页面壳层实现,但沿用 GoodBuddy 的语义
词汇、状态含义和克制的运营工具风格。界面以列表、表格和主从详情为主,不使用营销首页或
装饰性卡片墙。
## 1. 使用对象
- 普通成员主要在 GoodBuddy Desktop 使用能力;Web 端只处理自己的设备、申请和授权。
- 组织管理员使用完整组织控制台。
- 系统管理员使用与组织控制台分开的实例管理范围。
- 审计员进入只读视图,不显示可变更控件。
登录后直接进入当前身份可用的实际控制台,不显示产品宣传页。
## 2. 全局框架
```text
顶部栏:ShareServer 实例名 | 当前组织 | 全局搜索 | 审批 | 账号
侧栏:
概览
组织与成员
设备
能力目录
权限策略
审批
发布与更新
网关联邦
任务与中继
审计
系统设置(仅系统管理员)
```
当前组织始终显示。切换组织会重新加载整个数据范围,不保留上一组织的选择、筛选或详情。
系统管理使用“实例范围”标识,不能伪装成某个组织。
## 3. 概览
概览只显示需要行动和运行事实:
- 待审批数量及最旧等待时间。
- 在线/离线/已撤销设备。
- 可用/离线/暂停能力。
- 当前任务、失败和结果未知。
- 包更新异常和紧急撤销。
- 联邦连接异常。
- 中继流量和容量,仅在中继启用时显示。
指标可点击进入已带筛选的对应页面。没有事件时不显示虚构趋势。
## 4. 组织与成员
使用“成员”“用户组”“角色”三个同级页签。成员表包含姓名、身份源、组、状态、设备数和
最近活动。邀请是唯一主操作;暂停和移除进入行末菜单。
成员详情采用抽屉,显示身份、组、角色、设备、有效 Grant、申请和审计。管理员不能查看成员
任务正文或桌面文件。
## 5. 设备
```text
设备 所有者 平台 状态 能力 最近在线
Jiang-PC Jiang Windows 在线 2 刚刚
GPU-Worker-02 AI Team Linux 离线 8 2 小时前
```
设备详情包含概览、连接、已发布能力、Grant 和活动。设备状态与 Publication 状态分别显示。
撤销设备使用危险确认,说明新任务、活动任务和重新注册影响。
## 6. 能力目录
目录以能力为第一列,提供者为可展开信息:
```text
能力 版本 提供者 可见范围 可用性
图像生成 2.1.0 3 个节点 设计组 2/3 在线
```
详情包含 manifest、provider、Publication、允许输入、数据政策、调用趋势和最近错误。管理员
看到的是组织元数据,不通过目录读取能力内部数据。
## 7. 权限策略
策略页分“强制策略”和“Grant”。创建和编辑使用结构化表单:主体、能力、动作、输入、提供者、
时间、预算、批准和委托。保存前提供自然语言摘要:
> 允许“设计组”从合规设备调用组织内“图像生成”2.x;只发送文本和图片;每人每日 20 次;
> 不允许下游委托。
策略冲突预览显示最终结果和命中规则,但不提供允许规则覆盖拒绝规则的顺序拖拽。
## 8. 审批
审批收件箱按调用、发布、更新和联邦分类。详情必须显示申请人、能力或对象、目标范围、数据
类别、权限变化、期限和策略依据。批准按钮使用具体文案,例如“允许本次调用”“批准此版本
发布”,不能只有“同意”。
批量批准仅用于同类型、同策略且不包含权限扩大的低影响申请;联邦和权限扩大更新不支持
批量批准。
## 9. 发布与更新
页面分“能力包”“发布通道”“更新策略”“撤销记录”。Package 表显示版本、发布者、签名、
大小、目标平台、权限变化和状态。上传后先进入验证,不直接发布。
版本详情提供 manifest、摘要、签名、兼容范围、权限 diff、依赖和下载事实。紧急撤销位于
独立 danger zone,需要输入版本标识确认,并说明受影响设备和新任务。
## 10. 网关联邦
```text
合作组织 对端地址 状态 开放能力 有效期
法务服务商 share.legal.example 已连接 2 项 12-31
```
建立关系采用向导:填写端点、核对组织和指纹、选择公开范围、设置调用与数据限制、生成 offer、
等待对方确认、复核双方交集。只有双方完成后显示“已连接”。
详情明确展示“我方开放”“对方开放”“最终可用”三个集合。暂停和撤销说明对活动任务的处理。
## 11. 任务与中继
任务表显示调用方、能力、provider、路由、数据类别、状态、耗时和时间。默认不显示正文。
活动任务提供取消;结果未知使用独立状态并显示 provider 最后确认点。
中继未启用时页面显示控制面正常和启用位置,不展示零值图表。启用后显示活动连接、吞吐、
排队、容量和拒绝原因,不提供查看正文的调试入口。
## 12. 审计
提供操作者、动作、对象、组织、结果、时间和关联 ID 的可筛选表格。导出前显示条件、估算行数、
时区和字段范围;大导出作为后台任务生成有时效下载。审计详情保持只读,不把原始内部错误、
认证信息或任务正文显示给管理员。
## 13. 系统设置
只包含实例名称、公开地址、身份源、邮件、数据库/对象存储状态、中继开关与上限、保留策略、
联邦总开关、证书和版本。秘密字段只支持替换或测试,不回显原值。
运行时基础设施配置优先来自部署环境;Web 界面不能修改数据库连接串、主密钥或容器挂载路径。
## 14. 状态、响应式与无障碍
- 加载、无数据、无匹配、无权限、服务错误和部分不可用分别展示。
- 管理表格支持键盘访问、可见焦点、表头关联和分页;行菜单有可访问名称。
- 窄屏将表格转为字段列表,保持组织、来源、状态和风险信息,不把管理动作挤成不可辨图标。
- 所有时间显示实例时区并可查看 ISO 时间;联邦事件同时显示对端时间时标明时区。
- 状态不只依赖颜色。危险操作在确认后保持原页面上下文和可恢复错误。
+258
View File
@@ -0,0 +1,258 @@
# GoodBuddy 0.12.3 发布前审查
## 状态
发布候选验证完成。代码问题和发布前 UI 逻辑问题已完成主线程复核与聚焦
回归;真实模型、当前源码 Agent 的共享 Linux x64 Host E2E、全量测试、
类型检查、Lint 和获批双语发布说明校验均已通过。Desktop/Agent 已分别升至
`0.12.3`/`0.11.17`。本次按用户选择不构建 LoongArch 实验预览;剩余操作
仅为审查暂存内容并提交,未经后续明确授权不 push、tag 或发布。
## 审查范围
- 原始审查范围:`f18e4abb3c0e8c9e7c5c870cb50a67e9ed8880fe` 至
`4e60a4ca616846fbfc312932456e1a1e7e0a0388`。
- 发布比较基线:Desktop `v0.12.2`
(`c52732441aba599e643211b3d2175c8ff7027e26`)。
- 后续补充:Linux arm64 上本机 OpenCode Runtime 切换性能。
- 用户已有的 Desktop Pet 设计提交
`374968d50b3f1ae30165a5e12adf7f6213b6c00c` 保持不变,并纳入最终
发布范围核对。
审查分别覆盖 Desktop/UI、文档与构建、远程执行、模型 Runtime、
稳定性以及性能/KISS。Subagent 服务曾出现无 Token、Provider 暂不可用、
认证失败和本机 `EPERM`,这些基础设施失败未计为审查完成;六个方向最终
都获得了结果,所有候选又由主线程沿生产调用链复核和去重。
## 已验证并修复的问题
### 1. 默认直连模型遗漏连接级请求设置
`createAgentRuntime()` 的默认直连聊天路径没有把当前模型连接的
`requestHeaders`、`requestBody` 和 `maximumOutputTokens` 交给
`ModelAgentRuntime`。设置页显示已保存,但普通聊天不会采用这些值。
处理:默认直连路径与独立 profile 路径使用相同字段,并通过真实请求
mock 验证 Header、Body 和输出上限到达 Provider 请求。
### 2. 切换 Embedding 连接会清空无关模型字段
Main 在切换当前 Embedding 连接时重建 `modelProfiles`,遗漏
`maximumOutputTokens`、自定义 Header 和自定义 Body。一次向量连接切换
会静默清除已保存的 LLM 设置。
最终处理:不再从完整设置 snapshot 重建输入。切换操作进入
`RuntimeSettingsStore` 的串行队列后,只验证并更新
`activeEmbeddingConnectionId`;失败回滚也只在当前值仍等于该次选择时恢复
这一字段,因此不会丢失模型 profile、API Key keep 语义、Runtime 选择、
环境覆盖或并发保存的其他设置。
### 3. ACP 入站背压过早恢复
远端 ACP 队列消费一个事件后就恢复入站,即使剩余项目数或字节数仍达到
上限,积压时会反复暂停和恢复并继续放大内存压力。
处理:统一使用 `pendingUpdateLimitReached()`,只有项目数和字节数都降到
阈值以下时才恢复输入。
### 4. Continue 截断后可能重复或乱序显示答案
Continue Host 的事件流出现 gap 后仍继续转发后续 delta,最终 fallback
又可能输出完整答案,导致已显示内容重复或乱序。
处理:一旦确认事件流溢出就停止转发后续 delta;最终文本只有在它确实以
已显示文本为前缀时才追加未显示后缀。
### 5. 直连模型可能重放已到达 Provider 的请求
普通 `ECONNRESET`、Header/Body timeout 和 GoodBuddy 自身请求 timeout
并不能证明 POST 尚未到达 Provider。自动重试这些错误可能重复生成和计费,
且每次尝试会重新开始请求时限。
处理:自动重试仅保留明确的连接前失败,包括 DNS 临时失败、连接拒绝、
网络/主机不可达、connect timeout,以及明确发生在 TLS 建立前的 reset。
普通 reset 和请求阶段 timeout 不再自动重放。
### 6. 远端 Ask 不能只依赖 ACP 权限回调
OpenCode 1.18.9 的默认 build Agent 最终权限包含通配 `allow`。若 Runtime
未产生 `requestPermission`,仅在 ACP 回调中拒绝写工具不能形成完整的
Ask 只读边界。
处理:Ask 启动同时注入顶层和 build Agent 的 `permission: "ask"`;
Execute 不注入该覆盖。受管模型桥 helper 增加固定 `--work-mode` 参数,
让直接 Runtime 和模型桥 Runtime 使用同一规则。构建、双语功能清单、
远程 Host 技术设计及源码注释同步改为这条双重边界,不再描述已撤回的
read-only sandbox。
实际使用隔离配置运行 `opencode debug config` 和
`opencode debug agent build --pure` 验证过 OpenCode 1.18.9 的配置优先级。
曾评估独立 HOME/XDG 与 `--pure`,但它们会隐藏用户正常环境或插件,已因
可能影响功能而撤回。
### 7. Main 中保留了一套没有生产调用者的旧模型网关
`RemoteModelGateway`、`ModelCallOperationStore` 及其共享 operation
contracts 只被自身测试和旧 Main 网关引用。真实远端调度由 Agent 侧
`AgentModelGateway` 承担,保留两套实现会让修复落到错误路径。
处理:删除无生产构造者的 Main 旧实现及孤立测试;把仍在使用的 resolved
profile digest 移入 `managed-model-bridge.ts`。摘要继续绑定请求定制,
但明确排除 API Key。
### 8. Linux arm64 切换 OpenCode 会阻塞在用后即毁的冷启动
Composer 切换先同步等待一次 `agent.getStatus()`。旧路径会读取已分配
Skills/MCP、创建临时配置和全新 XDG 目录、启动 `opencode serve`、等待
health、调用 `session.list`,随后立即杀死进程并递归清理。切换成功后
原生能力清单还会执行另一次独立冷启动,首次真实请求再启动可复用实例。
此前提高 30 秒上限、改用 health 和跳过 `--version` 都没有消除这次
阻塞冷启动。
处理:本机受管 OpenCode 的被动状态检查只解析设置、模型凭据和已选择
路径,不读取 Skills/MCP,也不启动 Server。设置中的显式连接测试、原生
能力清单和首次真实请求仍执行完整启动、health 与连接检查。状态探针仍
为一次性对象,不把空闲 OpenCode Server 留在执行缓存中。
Windows x64 上仓库现有真实 bundled OpenCode 回环探针显示每次冷启动约
1.8 秒;历史 Linux ARM 回归已证明同一阶段可能超过 10 秒。修复后的被动
状态单测为毫秒级且断言没有 spawn、client 或 health 调用。
### 9. OpenCode 被动状态可能验证错误的 fallback 路径
被动状态初版先让 validator 搜索可用二进制。若用户明确配置的路径已被
删除,validator 可能改为找到 bundled 或 PATH 中的另一个候选并报告就绪,
但真实启动仍会选择那条失效的配置路径。
处理:先按真实 launch 的优先级只选择目标路径,再对这个精确路径执行
不启动进程的 canonical-file 校验;校验阶段禁止 fallback 到其他候选。
### 10. 自定义 Header 可能保存 Fetch 无法编码的值
原 schema 会接受中文等 UTF-16 code unit 大于 255 的 Header 值,但
Runtime 构造 Fetch `Headers` 时会因不符合 ByteString 要求抛错。用户会
看到设置保存成功,实际请求却在发送前失败。
处理:保存和 Runtime 共用的 schema 现在同时拒绝控制字符及 code unit
大于 255 的值,并增加中文 Header 值回归用例。
### 11. 设置保存进行中仍可编辑并丢失新输入
保存按钮会进入 pending 状态,但 textarea 等设置控件仍可修改。旧保存
响应返回后会用已保存 snapshot 覆盖这段新输入并清除 dirty 状态。
处理:设置主体在保存或连接测试期间统一设为 `inert` 和
`aria-busy`,避免产生无法归属到当前请求的新草稿,同时保持现有异步反馈。
### 12. Embedding-only 更新可能写回环境值或覆盖并发保存
Embedding-only helper 曾从应用环境变量覆盖后的 effective profile 生成
写回输入。在环境临时指定 Anthropic/API Key 时,切换 Embedding 连接可能
把这些有效值持久化进原本的 OpenAI/no-auth 配置。该 helper 还会在设置
串行队列外读取完整 snapshot;若另一项设置保存先入队,旧 snapshot 随后
可能把新保存内容整体覆盖,失败回滚也有同样风险。
处理:删除整份设置重建 helper,改为在 `RuntimeSettingsStore` 队列内只
验证并更新 `activeEmbeddingConnectionId`。失败回滚也只恢复这一字段;
若期间用户已选择其他连接则不覆盖。测试覆盖环境覆盖移除、并发完整保存
和窄回滚后其他设置仍保留。
### 13. 内置浏览器卡片没有说明仅 Execute 可用
“能力与工具”中的浏览器卡片只显示通用的“按模式读写”,但后端只在
Execute 模式提供 browser tools,容易让 Ask 用户误以为当前模式可用。
处理:浏览器卡片增加双语“仅 Execute / Execute only”摘要,其他 MCP
卡片和后端权限逻辑不变。
### 14. 长任务活动展示没有实际容量边界
候选已取消“超过 100 个工具便终止 Runtime”的功能限制,但同时移除了
消息中工具、子 Agent 和交错 block 数组的所有保留上限。长任务会让
Renderer 每次事件反复复制并线性搜索不断增长的数组,恢复与 SQLite
metadata 也会持续变大,和文档所述“只截断本地展示副本”不一致。
处理:Runtime 继续不受工具调用数限制;每条回复的本地副本最多保留
500 条工具/子 Agent 活动和 2,000 个交错展示块。超出后显示持久的双语
提示,已保留活动仍可更新状态,原生任务、模型轮次和工具执行均不取消。
该上限高于 `v0.12.2` 已发布的 100 条 schema 上限,不破坏已发布数据。
### 15. 持续 journal 背压会高频轮询 SQLite
远端 Runtime 输出在 journal 暂时满时每 25 毫秒重新执行一次同步 SQLite
append。Desktop 断连或消费者较慢数分钟时,每个受阻 binding 都会每秒
触发 40 次无效事务和事件循环唤醒。
处理:第一次仍立即尝试,失败后从 25 毫秒开始指数退避,最高 1 秒;
容量恢复、取消、deadline 和 Runtime 输出暂停语义不变。
## 候选问题处置
- Desktop/UI 首轮审查未发现 P0-P3;发布前第二轮按 Runtime 切换、
模型/Embedding 设置和 MCP/工具展示三组检查,共确认并修复上述 5 个
可复现问题。
- 文档与网站校验未发现发布阻断问题。
- 不为 Ask 使用独立 HOME/XDG,也不加 `--pure`,以免禁用正常插件或用户
环境。
- 不让普通状态或原生清单探针永久进入执行缓存,避免恢复“仅查看状态便
留下多个空闲 OpenCode 服务”的已修复问题。
- 未发现 Main 旧模型网关的生产、文档或导出调用者;Agent 侧真实网关及
共享 wire contracts 保留。
- 删除 `canonicalModelRequestHeaders()`;其唯一调用结果会立即进入已有
`canonicalJson()`,后者已按稳定 code-unit 顺序递归排序对象键。直接传入
Headers 对象转换结果可保持摘要不变并移除 locale 排序重复。
- OpenCode/ACP 在单次活动 Prompt 内保留增量工具身份,以正确归并后续状态
并在终态识别未完成/失败工具;这些 map 随 Prompt 结束释放。本轮只限制
Renderer/Main 的持久展示副本,不为 Runtime 增加会中止功能的工具配额。
- 未对没有具体失败路径的全局配额、持久恢复或额外安全层建议增加框架。
## 当前验证证据
- `npm run typecheck`:通过。
- `npm run lint`:通过。
- `npm test`:323 个测试文件通过,8 个文件按环境跳过;3,471 项通过,
55 项跳过。
- 发布修复聚焦矩阵:13 个测试文件,427 项通过,6 项按环境跳过。
- OpenCode/SelectedRuntime 聚焦矩阵:79 项通过。
- UI 逻辑矩阵:7 个测试文件,319 项通过;其中相关修复矩阵 4 个文件
224 项通过,OpenCode 单文件 62 项通过。
- 最终审查修复矩阵:8 个测试文件、389 项通过;最后一次受影响子集
4 个文件、286 项通过。受影响文件 ESLint 和 `git diff --check` 通过。
- 真实 bundled OpenCode 回环 Provider 探针:2 次本机模型路由均通过,
每次约 1.8 秒冷启动。
- 2026-09-03 共享 Linux x64 Host 使用当前源码 profile 和已登记真实
OpenCode 1.18.9 运行 `debug agent build --pure`:Ask 的最后一条
`*/*` wildcard 为 `ask`,Execute 为 `allow`;Ask 保留正常 HOME/XDG,
Execute 不注入配置覆盖,直接 Runtime 与 model bridge 规则一致。共
0 次模型调用,唯一 `/tmp/goodbuddy-e2e-release-profile-*` 目录已清理,
共享安装未修改。
- 直连 Runtime 自定义 Header+Body 真实最小请求:1 次调用通过;DeepSeek
Harness 自定义 Header 真实最小请求:1 次调用通过。两者均由本机 loopback
先观察最终请求再转发,未启用工具或附件,也未输出凭据。
- 使用当前源码构建的测试签名 Agent `0.11.17` Linux x64 bundle,在共享
Host 的唯一 `/tmp/goodbuddy-e2e-release-*` HOME 中完成真实 attach、
workspace validation 和 OpenCode 1.18.9 ACP Runtime 验证。两个并发 Ask
分别通过原生读取工具返回各自证据文件,Execute 通过原生 Bash 创建并读回
`execute-output.txt`;3 个 Runtime Prompt 共发生 6 次真实 Provider
请求,每次均观察到连接级自定义 Header+Body,6 个请求 Body digest
全部不同,未发生重放。
- 同一 Host E2E 扫描隔离 Agent state 的 7 个文件、170,534 字节,未发现
API Key、自定义 Header 值或自定义 Body 值。测试 Agent、Provider 代理
均已精确停止;独立清理探针确认剩余测试目录和进程均为 0,共享安装未修改。
该 bundle 只使用临时测试签名,不是 production 构建或发布产物。
- Desktop `0.12.3`、Agent `0.11.17` 的版本一致性脚本通过,
`git diff --check` 通过。
- 用户已批准 `0.12.3` 精确中英文发布说明并选择不构建 LoongArch 实验预览;
`npm run release:notes:verify` 通过。发布说明源码测试不再把某一历史候选
恰好超过 500 字符的内容强加给每个后续版本,而是用独立 schema 样例持续
验证长场景条目不受任意字符上限限制。
- 当前没有可用 Linux arm64 真机,因此本轮未取得 arm64 实机切换耗时;
该限制不冒充 x64 结果。修复已由不 spawn 的被动状态单测、Windows x64
回环时序和共享 Linux x64 当前源码 Runtime 路径共同覆盖。
## 提交与发布边界
1. 提交前审查暂存内容,排除本机 `.diff*.txt`、临时 bundle/probe、凭据、
日志和其他用户私有产物。
2. 提交后报告精确 commit。只有取得用户后续明确授权,才可 fetch/push 两个
`main`、等待 main CI 并创建 `agent-v0.11.17` 或 `v0.12.3` 标签。
@@ -356,8 +356,9 @@
### UX-02 Runtime 显式状态
- **优先级 / 状态:** P1 / 已完成
- **范围:** 顶栏显示就绪、连接中、不可用等文字,并通过 `aria-describedby` 暴露原因。
- **验收:** 不依赖颜色或 `title` 即可理解 Runtime 状态和发送不可用原因。
- **范围:** 顶栏以状态点和 Runtime/模型名称紧凑表达状态:就绪为绿色,连接中为黄色,
不可用为灰色;不重复显示状态文字,并通过 `aria-describedby` 暴露完整原因。
- **验收:** 三种状态均不重复显示状态文字;发送不可用原因仍由发送控件的可访问说明提供。
### UX-03 等待审批实时通知
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.12.2",
"version": "0.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.12.2",
"version": "0.12.3",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.12.2",
"version": "0.12.3",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+50
View File
@@ -1,6 +1,56 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.12.3",
"releasedAt": "2026-09-04",
"notes": {
"zh-CN": {
"highlights": [
"GoodBuddy 0.12.3 新增按模型连接定制请求 Header 和 Body,并把内置浏览器扩展到 OpenCode 与 Continue;同时改善多会话并发、长任务、远程 Ask/Execute 和 OpenCode 切换体验。"
],
"features": [
"**模型请求定制。** 每个 LLM 连接现在可以填写有界 JSON 请求头和顶层请求体,用于兼容自建网关、局域网服务和其他需要额外字段的 Provider。直连模型与 Continue 支持 Header 和 Body,本地 OpenCode 与 DeepSeek Harness 使用各自原生支持的 Header,受管 SSH OpenCode 支持两者;认证、协议和 Runtime 自有字段始终优先。",
"**Runtime 共享内置浏览器。** 内置浏览器现在可以分配给直连模型、GoodBuddy 管理的 OpenCode 和 Continue,并在 Execute 模式使用。用户与 Agent 共享同一个按会话隔离的浏览器页面;关闭 Agent 浏览器能力后,用户仍可在工作栏中手动导航。"
],
"fixes": [
"**多会话和长任务不再被人为中止。** 同一项目中的不同 OpenCode 会话现在可以并行运行,同一会话内仍保持顺序。OpenCode、Continue 和受管 SSH 任务不再因 GoodBuddy 固定的工具、活动、模型调用或累计输出数量而取消;本机展示达到容量时会给出提示,但原生任务继续运行。",
"**OpenCode 状态检查和 Runtime 切换更快。** 被动状态检查现在只验证所选路径和模型凭据,不再启动并立即销毁一个 OpenCode Server,减少较慢设备和 Linux ARM Host 上的切换等待。设置中的显式连接测试、原生能力读取和首次实际请求仍执行完整启动与健康检查,失效的自定义路径也不会被其他候选静默替代。",
"**模型重试和流式结果更可靠。** 直连模型会在尚未输出内容时,对明确发生在请求发送前的临时连接故障执行可取消重试;可能已经到达 Provider 的请求不会自动重放。Continue 事件流溢出后也不会再把后续增量与最终完整答案重复显示。",
"**远程 Ask 与 Execute 启动边界更一致。** 两种模式现在都直接启动已签名 Runtime,不再要求 Host 安装 bubblewrap。Ask 同时使用 OpenCode Ask 权限和 Agent 原生读取审批保持只读;Execute 不注入该限制,继续使用所选 SSH 账号的完整权限。",
"**设置保存和能力提示更准确。** 保存或测试期间设置表单会暂时锁定,避免旧响应覆盖新输入;切换 Embedding 连接也不再改写模型凭据、环境覆盖或并发保存的其他字段。无效 Header 会在保存时提示,浏览器卡片会明确标注仅 Execute 可供 Agent 使用。",
"**工作栏与终端操作恢复一致。** 终端工具栏粘贴改走受信任的 Main/Preload 剪贴板路径,避免 Electron 拒绝浏览器剪贴板权限后无法粘贴。工作栏页签、关闭按钮和“+”入口恢复圆角样式,终端面板与工具栏继续保持直角。"
],
"notices": [
"**远程项目仍为技术预览。** Host 需要单独发布的 Agent `0.11.17`;Desktop `0.12.3` 不内置 Agent 或远程 Runtime。Ask 保持只读,Execute 表示授权所选 SSH 账号可用的完整工具、进程、网络和写入权限。",
"**请求定制不是密钥存储。** 自定义 Header 和 Body 会随连接普通设置保存,请勿在其中填写 API Key 或其他密钥。各 Runtime 只应用其原生支持的字段,界面会标明实际生效范围。",
"**发布平台。** GoodBuddy 使用 Windows、macOS、Linux 的 `x64`/`arm64` 标准发布矩阵。本次不构建独立的 LoongArch 实验预览。"
]
},
"en-US": {
"highlights": [
"GoodBuddy 0.12.3 adds per-connection request headers and body fields and extends the built-in browser to OpenCode and Continue, together with smoother concurrent conversations, long tasks, Remote Ask/Execute, and OpenCode switching."
],
"features": [
"**Custom model requests.** Each LLM connection can now supply bounded JSON headers and top-level body fields for self-hosted gateways, LAN services, and providers that require additional values. Direct models and Continue support both headers and body fields, local OpenCode and DeepSeek Harness use their native header support, and managed SSH OpenCode supports both; authentication, protocol, and Runtime-owned fields always take precedence.",
"**Built-in browser shared across Runtimes.** The built-in browser can now be assigned to direct models, GoodBuddy-managed OpenCode, and Continue for use in Execute. The user and Agent share the same conversation-isolated page, while disabling Agent browser access still leaves manual workbar navigation available."
],
"fixes": [
"**Concurrent conversations and long tasks keep running.** Separate OpenCode conversations in one project can now run in parallel while requests within one conversation stay ordered. OpenCode, Continue, and managed SSH tasks are no longer cancelled by GoodBuddy-defined tool, activity, model-call, or accumulated-output counts; reaching local display capacity shows a notice while native execution continues.",
"**Faster OpenCode status checks and Runtime switching.** Passive status checks now validate only the selected path and model credential instead of starting and immediately destroying an OpenCode Server, reducing switching delays on slower systems and Linux ARM Hosts. Explicit connection tests, native capability discovery, and first real use still perform full startup and health checks, and an invalid custom path is never silently replaced by another candidate.",
"**Safer model retries and streaming results.** Direct models perform cancellable retries before any visible output only for temporary connection failures proven to occur before request dispatch; requests that may have reached the provider are never replayed automatically. Continue also avoids showing later stream deltas again with a complete fallback response after an event-stream overflow.",
"**Consistent Remote Ask and Execute startup boundaries.** Both modes now launch the signed Runtime directly without requiring bubblewrap on the Host. Ask stays read-only through both OpenCode Ask permissions and Agent native-read approval, while Execute applies no such override and retains the selected SSH account's full permissions.",
"**Reliable settings saves and capability guidance.** Settings are temporarily locked while saving or testing so an older response cannot overwrite newer input, and changing the Embedding connection no longer rewrites model credentials, environment overrides, or unrelated concurrent saves. Invalid headers are rejected when saving, and the browser card now clearly identifies Agent access as Execute-only.",
"**Restored workbar and terminal behavior.** Terminal toolbar paste now uses the trusted Main/Preload clipboard path instead of a browser clipboard call that Electron could reject. Workbar tabs, close buttons, and the “+” entry use rounded styling again, while the terminal panel and toolbar remain square."
],
"notices": [
"**Remote Projects remain a technical preview.** Hosts require the separately published Agent `0.11.17`; Desktop `0.12.3` does not embed the Agent or Remote Runtime. Ask remains read-only, while Execute authorizes the full tools, processes, network access, and writable paths available to the selected SSH account.",
"**Request customization is not credential storage.** Custom headers and body fields are saved as ordinary connection settings, so do not place API keys or other secrets in them. Each Runtime applies only the fields it supports natively, and the interface identifies the effective scope.",
"**Release platforms.** GoodBuddy uses the standard Windows, macOS, and Linux `x64`/`arm64` release matrix. This release does not include a separate experimental LoongArch preview."
]
}
}
},
{
"version": "0.12.2",
"releasedAt": "2026-09-03",
+3 -2
View File
@@ -9,8 +9,9 @@
以及知识库、魔法笔记、智能心跳、桌面上下文和远程消息通道等桌面助手能力。
下载区位于主要功能说明之前,并明确列出统信 UOS、银河麒麟、海光、兆芯、
鲲鹏、飞腾和龙芯。页面不重复设置底部下载推广区。
英文页面位于 `en.html`,不展示信创适配文案,三个平台的下载按钮始终前往
GitHub 最新正式 Release。
英文页面位于 `en.html`,用平台与架构覆盖描述同一产品事实,不使用中文信创
政策语汇;页面会标明独立的 LoongArch 实验预览,但三个平台的下载按钮仍始终
前往 GitHub 最新正式 Release。
首屏产品界面默认正面展示,在精确指针设备上使用克制的 3D 倾斜、
柔和跟随光效和同步浮动标签;触屏设备保持静态布局,系统启用“减少动态
效果”时不运行该交互。
+4 -4
View File
@@ -72,7 +72,7 @@
<nav class="site-navigation" id="site-navigation" aria-label="Main navigation" data-navigation>
<a href="#download">Download</a>
<a href="#features">Agent Runtime</a>
<a href="#features">Highlights</a>
<a href="#assistant">Desktop assistant</a>
</nav>
@@ -289,7 +289,7 @@
<div class="section-inner">
<div class="section-heading">
<div>
<p class="kicker">Agent Runtime</p>
<p class="kicker">Product highlights</p>
<h2 id="features-title">Agent Runtimes in one desktop interface</h2>
</div>
<p>
@@ -321,8 +321,8 @@
</svg>
</div>
<span class="feature-number">02</span>
<h3>Built for desktop platforms</h3>
<p>Windows, macOS, and Linux releases are available for both x64 and arm64.</p>
<h3>Broad desktop architecture coverage</h3>
<p>Official Windows, macOS, and Linux releases cover x64 and arm64, with a separate experimental LoongArch preview.</p>
</article>
<article class="feature-card">
+8 -7
View File
@@ -72,7 +72,7 @@
<nav class="site-navigation" id="site-navigation" aria-label="主导航" data-navigation>
<a href="#download">下载</a>
<a href="#features">Agent Runtime</a>
<a href="#features">产品亮点</a>
<a href="#assistant">桌面助手</a>
</nav>
@@ -352,7 +352,8 @@
<h3 id="domestic-support-title">桌面版本覆盖国产 x64、ARM64 和龙芯 LoongArch</h3>
<p>
正式下载覆盖国产 x64 与 ARM64;龙芯 LoongArch 使用独立编译的
loong64 预览版。
loong64 预览版。这里描述的是系统、指令集与安装包兼容性,
不代表已完成所有厂商和整机认证。
</p>
</div>
<div class="domestic-support__items">
@@ -375,11 +376,11 @@
<div class="section-inner">
<div class="section-heading">
<div>
<p class="kicker">Agent Runtime</p>
<h2 id="features-title">在一个界面中选择和配置 Runtime</h2>
<p class="kicker">产品亮点</p>
<h2 id="features-title">本地工作空间,统一 Runtime,也支持信创环境</h2>
</div>
<p>
Runtime、模型连接、Skills、MCP 和工作模式均可在桌面界面中配置。
从模型连接到执行记录,从桌面工作到国产软硬件适配,都由同一个应用承载。
</p>
</div>
@@ -407,8 +408,8 @@
</svg>
</div>
<span class="feature-number">02</span>
<h3>覆盖主流桌面系统</h3>
<p>Windows、macOS、Linux 同步提供 x64 与 arm64 架构版本。</p>
<h3>覆盖主流桌面与信创环境</h3>
<p>Windows、macOS、Linux 正式提供 x64 与 arm64 版本,龙芯 LoongArch 另有独立实验预览。</p>
</article>
<article class="feature-card">
+4
View File
@@ -250,6 +250,8 @@ for (const breakpoint of ["1199px", "959px", "719px"]) {
const requiredCopy = [
"免注册",
"支持信创软硬件的",
"产品亮点",
"本地工作空间,统一 Runtime,也支持信创环境",
"桌面版本覆盖国产 x64、ARM64 和龙芯 LoongArch",
"龙芯 LoongArch 独立编译预览版",
"龙芯 LoongArch(实验预览)",
@@ -275,9 +277,11 @@ for (const copy of requiredCopy) {
const requiredEnglishCopy = [
"No account required.",
"Product highlights",
"Model chat and coding agents",
"on your desktop.",
"Windows, macOS, and Linux",
"separate experimental LoongArch preview",
"Agent Runtimes in one desktop interface",
"Direct models",
"OpenCode",
+74 -2
View File
@@ -36,8 +36,6 @@ const profile: AgentPromptModelProfile = {
capabilities: { imageInput: false },
limits: {
maximumOutputTokens: 4_096,
maximumModelCalls: 8,
maximumTotalOutputTokens: 32_768,
requestTimeoutMilliseconds: 5_000
}
}
@@ -124,6 +122,80 @@ describe('AgentModelGateway', () => {
ledger.close()
})
it('merges trusted profile request customization without overriding Runtime fields', async () => {
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
const headers = new Headers(init?.headers)
expect(headers.get('x-tenant-id')).toBe('agent-tenant')
expect(headers.get('authorization')).toBe(`Bearer ${secret}`)
expect(JSON.parse(String(init?.body))).toEqual({
max_output_tokens: 4_096,
metadata: { source: 'goodbuddy' },
model: 'model-1',
temperature: 0.8
})
return new Response('{}')
})
const { gateway, ledger } = setup(fetcher)
const customizedProfile: AgentPromptModelProfile = {
...profile,
requestHeaders: {
'x-tenant-id': 'agent-tenant'
},
requestBody: {
temperature: 0.2,
metadata: { source: 'goodbuddy' }
}
}
const result = await gateway.dispatch(
{
...context,
profile: customizedProfile
},
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'model-1',
temperature: 0.8
})
).toString('base64')
},
new AbortController().signal
)
await result.acknowledgeDelivery()
expect(fetcher).toHaveBeenCalledOnce()
ledger.close()
})
it('does not impose prompt-wide model-call or output-token quotas', async () => {
const fetcher = vi.fn<typeof fetch>(async () =>
new Response(
JSON.stringify({
id: 'response',
usage: { output_tokens: 4_096 }
}),
{
status: 200,
headers: { 'content-type': 'application/json' }
}
)
)
const { gateway, ledger } = setup(fetcher)
for (let roundIndex = 0; roundIndex < 101; roundIndex += 1) {
const result = await gateway.dispatch(
{ ...context, roundIndex },
request,
new AbortController().signal
)
await result.acknowledgeDelivery()
}
expect(fetcher).toHaveBeenCalledTimes(101)
ledger.close()
})
it('caps OpenCode chat-completions max_tokens before dispatch', async () => {
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
expect(JSON.parse(String(init?.body))).toMatchObject({
+44 -80
View File
@@ -1,6 +1,9 @@
import { createHash } from 'node:crypto'
import type { DatabaseSync } from 'node:sqlite'
import type { AgentPromptModelProfile } from '../shared/model-bridge-contracts'
import {
modelBridgePathMatchesProtocol,
type AgentPromptModelProfile
} from '../shared/model-bridge-contracts'
import {
REMOTE_MODEL_GATEWAY_LIMITS,
remoteModelGatewayRequestSchema,
@@ -12,6 +15,11 @@ import {
assertTextModelRequestPolicy,
ModelRequestPolicyError
} from '../shared/model-request-policy'
import { canonicalJson } from '../shared/agent-protocol/canonical'
import {
mergeModelRequestBody,
mergeModelRequestHeaders
} from '../shared/model-request-customization'
import {
BoundedResponseTruncatedError,
BoundedResponseTooLargeError,
@@ -313,7 +321,6 @@ export type AgentModelGatewayContext = {
export class AgentModelGateway {
readonly #ledger: AgentModelCallLedger
readonly #fetch: typeof fetch
readonly #promptOutputTokens = new Map<string, number>()
constructor(options: {
ledger: AgentModelCallLedger
@@ -334,24 +341,10 @@ export class AgentModelGateway {
}> {
signal.throwIfAborted()
const request = remoteModelGatewayRequestSchema.parse(requestInput)
const promptKey = `${context.bindingId}\0${context.operationId}`
const usedOutputTokens = this.#promptOutputTokens.get(promptKey) ?? 0
const remainingOutputTokens =
context.profile.limits.maximumTotalOutputTokens - usedOutputTokens
if (remainingOutputTokens < 1) {
throw new AgentModelGatewayError(
'policy',
'Prompt total output token limit is exhausted'
)
}
const reservedOutputTokens = Math.min(
context.profile.limits.maximumOutputTokens,
remainingOutputTokens
)
const prepared = prepareProviderRequest(
context.profile,
request,
reservedOutputTokens
context.profile.limits.maximumOutputTokens
)
const requestDigest = sha256(prepared.canonicalRequest)
const callId = createAgentModelCallId(context)
@@ -444,14 +437,6 @@ export class AgentModelGateway {
headers,
bodyBase64: Buffer.from(bytes).toString('base64')
})
this.#promptOutputTokens.set(
promptKey,
usedOutputTokens +
Math.min(
reservedOutputTokens,
responseOutputTokens(bytes) ?? reservedOutputTokens
)
)
this.#ledger.complete(callId)
let finalized = false
return {
@@ -474,9 +459,6 @@ export class AgentModelGateway {
}
}
finalizePrompt(bindingId: string, operationId: string): void {
this.#promptOutputTokens.delete(`${bindingId}\0${operationId}`)
}
}
export function createAgentModelCallId(
@@ -506,7 +488,12 @@ function prepareProviderRequest(
body: Uint8Array
canonicalRequest: string
} {
if (!pathMatches(profile.protocol, request.path)) {
if (
!modelBridgePathMatchesProtocol(
profile.protocol,
request.path
)
) {
throw new AgentModelGatewayError(
'policy',
'Provider path does not match the prompt profile'
@@ -524,6 +511,10 @@ function prepareProviderRequest(
'Provider request body is not UTF-8 JSON'
)
}
const normalized = mergeModelRequestBody(
profile.requestBody ?? {},
json as Record<string, unknown>
)
try {
assertTextModelRequestPolicy(
{
@@ -531,7 +522,7 @@ function prepareProviderRequest(
model: profile.model,
supportsImageInput: profile.capabilities.imageInput
},
json
normalized
)
} catch (error) {
if (!(error instanceof ModelRequestPolicyError)) {
@@ -542,7 +533,6 @@ function prepareProviderRequest(
'Provider request does not match the prompt profile'
)
}
const normalized = { ...(json as Record<string, unknown>) }
const outputField =
profile.protocol === 'openai-responses'
? 'max_output_tokens'
@@ -564,22 +554,37 @@ function prepareProviderRequest(
requestedOutput === undefined
? maximumOutputTokens
: Math.min(requestedOutput as number, maximumOutputTokens)
const body = Buffer.from(JSON.stringify(normalized), 'utf8')
const body = Buffer.from(canonicalJson(normalized), 'utf8')
if (
body.byteLength >
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
) {
throw new AgentModelGatewayError(
'policy',
'Customized provider request exceeds the configured byte limit'
)
}
const url = providerUrl(profile)
const headers = { ...request.headers }
const headers = mergeModelRequestHeaders(
profile.requestHeaders ?? {},
request.headers
)
if (profile.protocol === 'anthropic-messages') {
headers.set('anthropic-version', '2023-06-01')
}
const canonicalHeaders = Object.fromEntries(headers.entries())
if (profile.protocol === 'anthropic-messages') {
headers['anthropic-version'] = '2023-06-01'
if (profile.authentication === 'api-key') {
headers['x-api-key'] = profile.apiKey!
headers.set('x-api-key', profile.apiKey!)
}
} else if (profile.authentication === 'api-key') {
headers.authorization = `Bearer ${profile.apiKey!}`
headers.set('authorization', `Bearer ${profile.apiKey!}`)
}
return {
url,
headers,
headers: Object.fromEntries(headers.entries()),
body,
canonicalRequest: JSON.stringify({
canonicalRequest: canonicalJson({
profileId: profile.profileId,
provider: profile.provider,
protocol: profile.protocol,
@@ -587,40 +592,13 @@ function prepareProviderRequest(
url: url.toString(),
request: {
path: request.path,
headers: Object.fromEntries(
Object.entries(request.headers).sort(([left], [right]) =>
left.localeCompare(right)
)
),
headers: canonicalHeaders,
bodyBase64: body.toString('base64')
}
})
}
}
function responseOutputTokens(body: Uint8Array): number | undefined {
let value: unknown
try {
value = JSON.parse(new TextDecoder().decode(body))
} catch {
return undefined
}
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return undefined
}
const usage = (value as { usage?: unknown }).usage
if (usage === null || typeof usage !== 'object' || Array.isArray(usage)) {
return undefined
}
const record = usage as Record<string, unknown>
const tokens = record.output_tokens ?? record.completion_tokens
return typeof tokens === 'number' &&
Number.isSafeInteger(tokens) &&
tokens >= 0
? tokens
: undefined
}
function providerUrl(profile: AgentPromptModelProfile): URL {
const url = new URL(profile.baseUrl)
const path = url.pathname.replace(/\/+$/u, '')
@@ -635,20 +613,6 @@ function providerUrl(profile: AgentPromptModelProfile): URL {
return url
}
function pathMatches(
protocol: AgentPromptModelProfile['protocol'],
path: string
): boolean {
if (protocol === 'anthropic-messages') {
return path === '/v1/messages'
}
if (protocol === 'openai-chat-completions') {
return path === '/chat/completions' || path === '/v1/chat/completions'
}
return path === '/responses' || path === '/v1/responses'
}
function sha256(value: string | Uint8Array): string {
return `sha256:${createHash('sha256').update(value).digest('hex')}`
}
+5
View File
@@ -58,6 +58,8 @@ describe('Agent CLI fixed command contract', () => {
'private-model',
'--supports-image-input',
'false',
'--work-mode',
'ask',
'--opencode-entrypoint',
'/runtime/bin/opencode'
],
@@ -69,6 +71,7 @@ describe('Agent CLI fixed command contract', () => {
protocol: 'openai-responses',
model: 'private-model',
supportsImageInput: false,
workMode: 'ask',
opencodeEntrypoint: '/runtime/bin/opencode'
})
await expect(
@@ -83,6 +86,8 @@ describe('Agent CLI fixed command contract', () => {
'private-model',
'--supports-image-input',
'false',
'--work-mode',
'ask',
'--opencode-entrypoint',
'/runtime/bin/opencode',
'--api-key',
+7
View File
@@ -185,6 +185,7 @@ async function runModelBridgeHelper(
'protocol',
'model',
'supports-image-input',
'work-mode',
'opencode-entrypoint'
])
requireOptions(options, [
@@ -192,6 +193,7 @@ async function runModelBridgeHelper(
'protocol',
'model',
'supports-image-input',
'work-mode',
'opencode-entrypoint'
])
const protocol = options.protocol
@@ -206,6 +208,10 @@ async function runModelBridgeHelper(
if (imageInput !== 'true' && imageInput !== 'false') {
throw new Error('Invalid model bridge image-input option')
}
const workMode = options['work-mode']
if (workMode !== 'ask' && workMode !== 'execute') {
throw new Error('Invalid model bridge work-mode option')
}
return await (
dependencies.runModelBridgeHelper ??
runOpenCodeModelBridgeHelper
@@ -214,6 +220,7 @@ async function runModelBridgeHelper(
protocol: protocol as ModelBridgeProtocol,
model: options.model!,
supportsImageInput: imageInput === 'true',
workMode,
opencodeEntrypoint: options['opencode-entrypoint']!
})
}
@@ -24,24 +24,25 @@ afterEach(() => {
})
describe('direct Linux stdio Runtime ownership', () => {
it('spawns exact bubblewrap argv without systemd using detached fixed stdio', async () => {
it('spawns Ask directly without systemd using detached fixed stdio', async () => {
const registry = createRegistry()
const child = fakeChild()
let launchedIdentity!: LinuxRuntimeProcessIdentity
const spawn = vi.fn<DirectLinuxStdioSpawn>((executable, args, options) => {
expect(executable).toBe('/usr/bin/bwrap')
expect(args).not.toContain('--die-with-parent')
expect(args).toContain('--unshare-all')
expect(args).toContain('--new-session')
expect(args).toContain('GOODBUDDY_RUNTIME_OWNER_TOKEN')
expect(executable).toBe('/bundle/bin/opencode')
expect(args).toEqual(['acp'])
expect(args.join('\0')).not.toMatch(/systemd-run|systemctl|DBUS/iu)
expect(options).toEqual({
shell: false,
detached: true,
windowsHide: true,
cwd: '/state/scratch',
cwd: '/workspace',
stdio: ['pipe', 'pipe', 'pipe'],
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' }
env: {
PATH: '/usr/bin:/bin',
LANG: 'C.UTF-8',
GOODBUDDY_RUNTIME_OWNER_TOKEN: 'a'.repeat(32)
}
})
launchedIdentity = identity()
queueMicrotask(() => child.emit('spawn'))
@@ -122,47 +123,6 @@ describe('direct Linux stdio Runtime ownership', () => {
registry.close()
})
it('waits for bubblewrap to exec the supervised bridge helper', async () => {
const registry = createRegistry()
const child = fakeChild()
const bwrapIdentity = identity()
const helperIdentity = {
...bwrapIdentity,
executablePath: '/agent/node'
}
const spawn: DirectLinuxStdioSpawn = () => {
queueMicrotask(() => child.emit('spawn'))
return child
}
const readProcessIdentity = vi
.fn()
.mockResolvedValueOnce(bwrapIdentity)
.mockResolvedValue(helperIdentity)
const owner = await launchDirectLinuxStdioProcessOwner({
manifest: manifest(),
profile: {
...profile(),
processExecutable: '/agent/node'
},
identity: { launchId: 'launch-1', processId: 'process-1' },
installationId: 'installation-1',
registry,
deadlineAt: '2030-01-01T00:00:00.000Z',
maximumInputBytes: 1024,
platform: 'linux',
spawn,
randomOwnerToken: () => 'a'.repeat(32),
readProcessIdentity
})
expect(readProcessIdentity).toHaveBeenCalledTimes(2)
expect(owner.processIdentity).toEqual(helperIdentity)
child.exitCode = 0
child.emit('close', 0, null)
registry.close()
})
it('rejects PID reuse before sending any signal', async () => {
const registry = createRegistry()
const child = fakeChild()
@@ -294,6 +254,56 @@ describe('direct Linux stdio Runtime ownership', () => {
}
})
it('uses stdout backpressure instead of stopping when output queues fill', async () => {
const registry = createRegistry()
const child = fakeChild()
const launched = identity()
const sendSignal = vi.fn<typeof process.kill>(() => true)
let releaseFirst!: () => void
const firstConsumed = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const owner = await launchDirectLinuxStdioProcessOwner({
manifest: manifest(),
profile: profile(),
identity: { launchId: 'launch-output', processId: 'process-output' },
installationId: 'installation-1',
registry,
deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE,
maximumInputBytes: 1024,
maximumOutputQueueChunks: 1,
platform: 'linux',
spawn: () => {
queueMicrotask(() => child.emit('spawn'))
return child
},
randomOwnerToken: () => 'a'.repeat(32),
readProcessIdentity: async () => launched,
sendSignal
})
const pause = vi.spyOn(child.stdout, 'pause')
const resume = vi.spyOn(child.stdout, 'resume')
const received: string[] = []
owner.subscribeOutput(async ({ data }) => {
received.push(Buffer.from(data).toString())
if (received.length === 1) {
await firstConsumed
}
})
child.stdout.write('one')
await vi.waitFor(() => expect(pause).toHaveBeenCalled())
child.stdout.write('two')
releaseFirst()
await vi.waitFor(() => expect(received).toEqual(['one', 'two']))
expect(resume).toHaveBeenCalled()
expect(sendSignal).not.toHaveBeenCalled()
child.exitCode = 0
child.emit('close', 0, null)
registry.close()
})
it('startup reconciles only owners from the current installation', async () => {
const registry = createRegistry()
const reservation = {
@@ -350,24 +360,12 @@ function fakeChild(): DirectLinuxStdioChild & EventEmitter {
function profile() {
return {
executable: '/usr/bin/bwrap' as const,
processExecutable: '/usr/bin/bwrap' as const,
cwd: '/state/scratch',
executable: '/bundle/bin/opencode' as const,
processExecutable: '/bundle/bin/opencode' as const,
cwd: '/workspace',
env: { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8' },
workMode: 'ask' as const,
args: [
'--new-session',
'--unshare-all',
'--clearenv',
'--proc',
'/proc',
'--ro-bind',
'/workspace',
'/workspace',
'--',
'/bundle/bin/opencode',
'acp'
]
args: ['acp']
}
}
@@ -377,7 +375,7 @@ function identity(): LinuxRuntimeProcessIdentity {
pid: 42,
startTimeTicks: 100n,
processGroupId: 42,
executablePath: '/usr/bin/bwrap'
executablePath: '/bundle/bin/opencode'
}
}
@@ -18,7 +18,6 @@ import {
import type { ModelBridgePolicy } from '../shared/model-bridge-contracts'
import {
createOpenCodeLaunchProfile,
OPENCODE_BWRAP_EXECUTABLE,
type OpenCodeLaunchProfile
} from './opencode-runtime-profile'
import {
@@ -102,7 +101,6 @@ export type DirectLinuxStdioProcessOwnerOptions = {
profileInput?: {
bundleDirectory: string
workspaceDirectory: string
scratchDirectory: string
workMode: 'ask' | 'execute'
modelBridge?: {
agentExecutablePath: string
@@ -172,13 +170,13 @@ export class DirectLinuxStdioProcessOwner {
#maximumPendingStdinBytes: number
#inputBytes = 0
#pendingInputBytes = 0
#outputBytes = 0
#queuedOutputBytes = 0
#writeTail: Promise<void> = Promise.resolve()
#deadlineTimer?: NodeJS.Timeout
#promptGeneration = 1
#promptActive = true
#draining = false
#outputPaused = false
#closed = false
#stdinClosed = false
#stopPromise?: Promise<void>
@@ -282,7 +280,6 @@ export class DirectLinuxStdioProcessOwner {
this.#maximumPendingStdinBytes = this.#maximumInputBytes
this.#inputBytes = 0
this.#pendingInputBytes = 0
this.#outputBytes = 0
this.#promptActive = true
this.#promptGeneration += 1
this.#scheduleDeadline(
@@ -451,24 +448,17 @@ export class DirectLinuxStdioProcessOwner {
#acceptOutput(chunk: Buffer | Uint8Array): void {
if (this.#closed || chunk.byteLength === 0) return
if (
this.#outputBytes + chunk.byteLength > this.#maximumOutputBytes ||
this.#outputQueue.length >= this.#maximumOutputQueueChunks ||
this.#queuedOutputBytes + chunk.byteLength > this.#maximumOutputBytes
) {
this.#child.stdout.pause()
this.#outputQueue.splice(0)
this.#queuedOutputBytes = 0
void this.stop({
reason: 'output-quota',
deadlineAt: new Date(this.#now() + DEFAULT_STOP_TIMEOUT_MS).toISOString()
}).catch(() => undefined)
return
}
const copy = Uint8Array.from(chunk)
this.#outputBytes += copy.byteLength
this.#queuedOutputBytes += copy.byteLength
this.#outputQueue.push(copy)
if (
this.#outputQueue.length >= this.#maximumOutputQueueChunks ||
(this.#maximumOutputBytes > 0 &&
this.#queuedOutputBytes >= this.#maximumOutputBytes)
) {
this.#child.stdout.pause()
this.#outputPaused = true
}
this.#scheduleDrain()
}
@@ -486,6 +476,15 @@ export class DirectLinuxStdioProcessOwner {
for (const listener of [...this.#outputListeners]) {
await listener({ stream: 'stdout', data: data.slice() })
}
if (
this.#outputPaused &&
this.#outputQueue.length < this.#maximumOutputQueueChunks &&
(this.#maximumOutputBytes === 0 ||
this.#queuedOutputBytes < this.#maximumOutputBytes)
) {
this.#outputPaused = false
this.#child.stdout.resume()
}
}
} catch {
void this.stop({
@@ -771,26 +770,12 @@ export function bindDirectOwnerToken(
) {
throw identityError('Runtime owner token marker is invalid or duplicated')
}
if (profile.workMode === 'execute') {
return {
...profile,
env: {
...profile.env,
[OWNER_TOKEN_ENVIRONMENT_NAME]: ownerToken
}
}
}
const separator = profile.args.indexOf('--')
return {
...profile,
args: [
...profile.args.slice(0, separator),
'--setenv',
OWNER_TOKEN_ENVIRONMENT_NAME,
ownerToken,
...profile.args.slice(separator)
],
env: { ...profile.env }
env: {
...profile.env,
[OWNER_TOKEN_ENVIRONMENT_NAME]: ownerToken
}
}
}
@@ -856,31 +841,8 @@ function validateProfile(profile: OpenCodeLaunchProfile): OpenCodeLaunchProfile
) {
throw identityError('Runtime launch profile is malformed')
}
if (
profile.workMode === 'ask' &&
(
profile.executable !== OPENCODE_BWRAP_EXECUTABLE ||
profile.args.includes(['--die', 'with-parent'].join('-')) ||
!profile.args.includes('--unshare-all') ||
!profile.args.includes('--new-session') ||
!profile.args.includes('--clearenv') ||
!profile.args.includes('--ro-bind') ||
profile.args.filter((argument) => argument === '--').length !== 1
)
) {
throw identityError('Ask Runtime launch profile is not read-only')
}
if (
profile.workMode === 'execute' &&
(
profile.executable === OPENCODE_BWRAP_EXECUTABLE ||
profile.args.includes('--unshare-all') ||
profile.args.includes('--clearenv') ||
profile.args.includes('--ro-bind') ||
profile.args.includes('--bind')
)
) {
throw identityError('Execute Runtime launch profile must run directly')
if (profile.processExecutable !== profile.executable) {
throw identityError('Runtime launch profile must run directly')
}
return {
executable: profile.executable,
+21 -2
View File
@@ -44,10 +44,14 @@ export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
export type OpenCodeModelBridgeProviderConfig = {
model: string
permission?: 'ask'
agent: {
title: {
disable: true
}
build?: {
permission: 'ask'
}
}
provider: Record<
string,
@@ -389,6 +393,7 @@ export async function runOpenCodeModelBridgeHelper(options: {
protocol: ModelBridgeProtocol
model: string
supportsImageInput: boolean
workMode: 'ask' | 'execute'
opencodeEntrypoint: string
environment?: Readonly<NodeJS.ProcessEnv>
spawn?: ModelBridgeHelperSpawn
@@ -409,7 +414,8 @@ export async function runOpenCodeModelBridgeHelper(options: {
protocol: options.protocol,
model: options.model,
loopbackOrigin: origin,
supportsImageInput: options.supportsImageInput
supportsImageInput: options.supportsImageInput,
workMode: options.workMode
})
const environment = credentialFreeHelperEnvironment(
options.environment ?? process.env,
@@ -487,6 +493,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
loopbackOrigin: string
name?: string
supportsImageInput?: boolean
workMode?: 'ask' | 'execute'
}): OpenCodeModelBridgeProviderConfig {
const model = boundedMetadataText(input.model, 'Model name')
const name = boundedMetadataText(
@@ -500,13 +507,25 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
const baseURL = `${origin}/v1`
return {
model: openCodeModelBridgeModelId(input.protocol, model),
...(input.workMode === 'ask'
? {
permission: 'ask' as const
}
: {}),
// GoodBuddy owns conversation titles. A first-prompt title request can
// race the real ACP prompt, abandon its Provider response, and correctly
// poison the no-replay ledger before the user request can run.
agent: {
title: {
disable: true
}
},
...(input.workMode === 'ask'
? {
build: {
permission: 'ask' as const
}
}
: {})
},
provider: {
[providerId]: {
+19 -1
View File
@@ -465,14 +465,19 @@ describe('model bridge loopback helper', () => {
name: 'Private model',
loopbackOrigin:
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
supportsImageInput: true
supportsImageInput: true,
workMode: 'ask'
})
expect(config).toMatchObject({
model: `${providerId}/private-model`,
permission: 'ask',
agent: {
title: {
disable: true
},
build: {
permission: 'ask'
}
},
provider: {
@@ -500,6 +505,19 @@ describe('model bridge loopback helper', () => {
expect(JSON.stringify(config)).not.toContain('provider.example')
}
)
it('leaves Execute provider permissions unchanged', () => {
const config = createOpenCodeModelBridgeProviderConfig({
protocol: 'openai-responses',
model: 'private-model',
loopbackOrigin:
`http://127.0.0.1:12345/${modelBridgeRouteToken}`,
workMode: 'execute'
})
expect(config).not.toHaveProperty('permission')
expect(config.agent).not.toHaveProperty('build')
})
})
describe('model bridge Unix broker', () => {
@@ -12,7 +12,6 @@ import {
type RemoteRuntimeBundleManifest
} from '../shared/remote-runtime-launch-contracts'
import {
OPENCODE_BWRAP_EXECUTABLE,
createOpenCodeLaunchProfile
} from './opencode-runtime-profile'
@@ -24,28 +23,35 @@ afterEach(() => {
}
})
describe('OpenCode bubblewrap launch profile', () => {
it('uses a read-only Workspace bind for Ask', () => {
describe('OpenCode direct launch profile', () => {
it('runs Ask directly in the Workspace', () => {
const fixture = createFixture()
const profile = createOpenCodeLaunchProfile({
...fixture,
workMode: 'ask'
})
expect(profile.executable).toBe(OPENCODE_BWRAP_EXECUTABLE)
expect(profile.cwd).toBe(fixture.scratchDirectory)
expect(profile.args).toContain('--unshare-all')
expect(profile.args).toContain('--clearenv')
expectArgumentSequence(profile.args, [
'--ro-bind',
fixture.workspaceDirectory,
fixture.workspaceDirectory
])
expectArgumentSequence(profile.args, [
'--',
resolve(fixture.bundleDirectory, 'bin', 'opencode'),
'acp'
])
expect(profile.executable).toBe(
resolve(fixture.bundleDirectory, 'bin', 'opencode')
)
expect(profile.processExecutable).toBe(profile.executable)
expect(profile.args).toEqual(['acp'])
expect(profile.cwd).toBe(fixture.workspaceDirectory)
expect(profile.env).toMatchObject({
HOME:
process.env.HOME ?? fixture.workspaceDirectory,
PATH: process.env.PATH || '/usr/bin:/bin'
})
expect(
JSON.parse(profile.env.OPENCODE_CONFIG_CONTENT!)
).toMatchObject({
permission: 'ask',
agent: {
build: {
permission: 'ask'
}
}
})
})
it('runs Execute directly with the SSH account environment', () => {
@@ -98,29 +104,12 @@ describe('OpenCode bubblewrap launch profile', () => {
expect(execute.args).not.toContain('--unshare-all')
})
it('sets only the fixed environment and rejects a changed profile', () => {
it('keeps Runtime arguments credential-free and rejects a changed manifest', () => {
const fixture = createFixture()
const profile = createOpenCodeLaunchProfile({
...fixture,
workMode: 'ask'
})
const setEnvironmentNames = profile.args.flatMap(
(argument, index) =>
argument === '--setenv'
? [profile.args[index + 1]]
: []
)
expect(setEnvironmentNames).toEqual([
'HOME',
'LANG',
'LC_ALL',
'PATH',
'TMPDIR',
'XDG_CACHE_HOME',
'XDG_CONFIG_HOME',
'XDG_DATA_HOME',
'XDG_STATE_HOME'
])
expect(profile.args.join('\0')).not.toContain('ANTHROPIC_API_KEY')
expect(() =>
@@ -142,18 +131,9 @@ describe('OpenCode bubblewrap launch profile', () => {
).toThrow(/normalized absolute/iu)
})
it('rejects overlapping bundle, Workspace, and scratch mounts', () => {
it('does not impose mount-layout constraints on direct launches', () => {
const fixture = createFixture()
for (const conflict of [
{
scratchDirectory: fixture.workspaceDirectory
},
{
scratchDirectory: resolve(
fixture.workspaceDirectory,
'scratch'
)
},
{
workspaceDirectory: resolve(
fixture.bundleDirectory,
@@ -167,13 +147,13 @@ describe('OpenCode bubblewrap launch profile', () => {
)
}
]) {
expect(() =>
expect(
createOpenCodeLaunchProfile({
...fixture,
...conflict,
workMode: 'ask'
})
).toThrow(/must not overlap/iu)
).toMatchObject({ workMode: 'ask' })
}
})
@@ -215,19 +195,10 @@ describe('OpenCode bubblewrap launch profile', () => {
}
})
expectArgumentSequence(profile.args, [
'--ro-bind',
agentInstallationDirectory,
agentInstallationDirectory
])
expectArgumentSequence(profile.args, [
'--ro-bind',
bridgeDirectory,
bridgeDirectory
])
expectArgumentSequence(profile.args, [
'--',
resolve(agentInstallationDirectory, 'node'),
expect(profile.executable).toBe(
resolve(agentInstallationDirectory, 'node')
)
expect(profile.args).toEqual([
resolve(agentInstallationDirectory, 'lib', 'agent.cjs'),
'model-bridge-helper',
'--socket-path',
@@ -238,6 +209,8 @@ describe('OpenCode bubblewrap launch profile', () => {
'private-model',
'--supports-image-input',
'false',
'--work-mode',
'ask',
'--opencode-entrypoint',
resolve(fixture.bundleDirectory, 'bin', 'opencode')
])
@@ -286,11 +259,6 @@ describe('OpenCode bubblewrap launch profile', () => {
'runtimes',
'opencode'
)
const scratchDirectory = resolve(
managedRoot,
'scratch',
'prompt'
)
const agentInstallationDirectory = resolve(
managedRoot,
'agent',
@@ -324,7 +292,6 @@ describe('OpenCode bubblewrap launch profile', () => {
manifest: fixture.manifest,
bundleDirectory,
workspaceDirectory,
scratchDirectory,
workMode: 'execute',
modelBridge
})
@@ -332,16 +299,18 @@ describe('OpenCode bubblewrap launch profile', () => {
cwd: workspaceDirectory,
workMode: 'execute'
})
expect(() =>
expect(
createOpenCodeLaunchProfile({
manifest: fixture.manifest,
bundleDirectory,
workspaceDirectory,
scratchDirectory,
workMode: 'ask',
modelBridge
})
).toThrow(/paths must not overlap/iu)
).toMatchObject({
cwd: workspaceDirectory,
workMode: 'ask'
})
})
})
@@ -349,7 +318,6 @@ function createFixture(): {
manifest: RemoteRuntimeBundleManifest
bundleDirectory: string
workspaceDirectory: string
scratchDirectory: string
} {
const root = resolve(
mkdtempSync(join(tmpdir(), 'goodbuddy-opencode-profile-'))
@@ -357,7 +325,6 @@ function createFixture(): {
temporaryPaths.push(root)
const bundleDirectory = resolve(root, 'bundle')
const workspaceDirectory = resolve(root, 'workspace')
const scratchDirectory = resolve(root, 'scratch')
for (const directory of [root, bundleDirectory, workspaceDirectory]) {
mkdirSync(directory, { recursive: true, mode: 0o700 })
if (process.platform !== 'win32') {
@@ -367,7 +334,6 @@ function createFixture(): {
return {
bundleDirectory,
workspaceDirectory,
scratchDirectory,
manifest: remoteRuntimeBundleManifestSchema.parse({
formatVersion: 2,
product: 'GoodBuddy',
@@ -433,17 +399,3 @@ function createFixture(): {
})
}
}
function expectArgumentSequence(
args: readonly string[],
sequence: readonly string[]
): void {
const start = args.findIndex(
(value, index) =>
value === sequence[0] &&
sequence.every(
(candidate, offset) => args[index + offset] === candidate
)
)
expect(start).toBeGreaterThanOrEqual(0)
}
+22 -144
View File
@@ -16,11 +16,17 @@ import {
type ModelBridgePolicy
} from '../shared/model-bridge-contracts'
import {
assertAbsoluteManagedPath,
ensurePrivateDirectoryTree
assertAbsoluteManagedPath
} from './managed-paths'
export const OPENCODE_BWRAP_EXECUTABLE = '/usr/bin/bwrap'
const OPEN_CODE_ASK_CONFIG = JSON.stringify({
permission: 'ask',
agent: {
build: {
permission: 'ask'
}
}
})
export type OpenCodeLaunchProfile = {
executable: string
@@ -35,7 +41,6 @@ export function createOpenCodeLaunchProfile(input: {
manifest: RemoteRuntimeBundleManifest
bundleDirectory: string
workspaceDirectory: string
scratchDirectory: string
workMode: 'ask' | 'execute'
modelBridge?: {
agentExecutablePath: string
@@ -113,22 +118,6 @@ export function createOpenCodeLaunchProfile(input: {
'Model bridge socket or Agent executable escapes its managed directory'
)
}
const managedPaths = [
['Runtime bundle', bundleDirectory],
['Workspace', workspaceDirectory],
...(modelBridge === undefined
? []
: [
[
'Agent installation',
modelBridge.agentInstallationDirectory
] as const,
[
'Model bridge',
modelBridge.bridgeDirectory
] as const
])
] as const
const executablePath = resolve(
bundleDirectory,
...manifest.entrypoint.path.split('/')
@@ -163,117 +152,26 @@ export function createOpenCodeLaunchProfile(input: {
modelBridge.policy.model,
'--supports-image-input',
modelBridge.policy.supportsImageInput ? 'true' : 'false',
'--work-mode',
input.workMode,
'--opencode-entrypoint',
executablePath
]
}
if (input.workMode === 'execute') {
return {
...runtimeCommand,
processExecutable: runtimeCommand.executable,
cwd: workspaceDirectory,
env: executeEnvironment(process.env, workspaceDirectory),
workMode: 'execute'
}
}
const scratchDirectory = assertAbsoluteManagedPath(
resolve(input.scratchDirectory)
)
assertDisjointPaths([
...managedPaths,
['Runtime scratch', scratchDirectory]
])
const scratchPaths = {
home: join(scratchDirectory, 'home'),
temporary: join(scratchDirectory, 'tmp'),
cache: join(scratchDirectory, 'xdg-cache'),
config: join(scratchDirectory, 'xdg-config'),
data: join(scratchDirectory, 'xdg-data'),
state: join(scratchDirectory, 'xdg-state')
}
for (const path of Object.values(scratchPaths)) {
ensurePrivateDirectoryTree(path, scratchDirectory)
}
const environment: Readonly<Record<
(typeof OPENCODE_REMOTE_RUNTIME_ENVIRONMENT_NAMES)[number],
string
>> = {
HOME: scratchPaths.home,
LANG: 'C.UTF-8',
LC_ALL: 'C.UTF-8',
PATH: '/usr/bin:/bin',
TMPDIR: scratchPaths.temporary,
XDG_CACHE_HOME: scratchPaths.cache,
XDG_CONFIG_HOME: scratchPaths.config,
XDG_DATA_HOME: scratchPaths.data,
XDG_STATE_HOME: scratchPaths.state
}
const args = [
'--new-session',
'--unshare-all',
'--clearenv',
'--proc',
'/proc',
'--dev',
'/dev',
'--tmpfs',
'/run',
'--ro-bind',
'/usr',
'/usr',
'--ro-bind-try',
'/bin',
'/bin',
'--ro-bind-try',
'/lib',
'/lib',
'--ro-bind-try',
'/lib64',
'/lib64',
'--ro-bind',
bundleDirectory,
bundleDirectory,
'--ro-bind',
workspaceDirectory,
workspaceDirectory,
'--bind',
scratchDirectory,
scratchDirectory,
...Object.entries(environment).flatMap(([name, value]) => [
'--setenv',
name,
value
]),
'--chdir',
workspaceDirectory,
...(modelBridge === undefined
? []
: [
'--ro-bind',
modelBridge.agentInstallationDirectory,
modelBridge.agentInstallationDirectory,
'--ro-bind',
modelBridge.bridgeDirectory,
modelBridge.bridgeDirectory
]),
'--',
runtimeCommand.executable,
...runtimeCommand.args
] as const
const environment =
input.workMode === 'ask'
? {
...executeEnvironment(process.env, workspaceDirectory),
OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG
}
: executeEnvironment(process.env, workspaceDirectory)
return {
executable: OPENCODE_BWRAP_EXECUTABLE,
...runtimeCommand,
processExecutable: runtimeCommand.executable,
args,
cwd: scratchDirectory,
env: {
PATH: '/usr/bin:/bin',
LANG: 'C.UTF-8'
},
workMode: 'ask'
cwd: workspaceDirectory,
env: environment,
workMode: input.workMode
}
}
@@ -321,26 +219,6 @@ function usableEnvironmentValue(
return value !== undefined && value.length > 0 && !value.includes('\0')
}
function assertDisjointPaths(
paths: readonly (readonly [label: string, path: string])[]
): void {
for (let left = 0; left < paths.length; left += 1) {
for (let right = left + 1; right < paths.length; right += 1) {
const leftEntry = paths[left]!
const rightEntry = paths[right]!
if (
leftEntry[1] === rightEntry[1] ||
isStrictDescendant(leftEntry[1], rightEntry[1]) ||
isStrictDescendant(rightEntry[1], leftEntry[1])
) {
throw new Error(
`${leftEntry[0]} and ${rightEntry[0]} paths must not overlap`
)
}
}
}
}
function isStrictDescendant(parent: string, candidate: string): boolean {
const child = relative(parent, candidate)
return (
+6 -2
View File
@@ -497,7 +497,11 @@ describe('AgentProtocolServer connection bounds', () => {
'runtime/completePrompt',
'runtime/getAcpCursors',
'runtime/escalateCancellation',
'runtime/reconcilePrompt'
'runtime/reconcilePrompt',
'runtime/startPrompt',
'runtime/attachPrompt',
'runtime/pagePromptTranscript',
'runtime/ackPromptTranscript'
].map((method) => [method, () => null])
)
const harness = createHarness({
@@ -525,7 +529,7 @@ describe('AgentProtocolServer connection bounds', () => {
expect(daemonCapabilitiesSchema.parse(result)).toMatchObject({
capabilities: [
{ name: 'agent/control', version: 1, critical: true },
{ name: 'runtime/acp', version: 3, critical: true },
{ name: 'runtime/acp', version: 5, critical: true },
{
name: 'runtime/model-bridge',
version: 1,
+11 -4
View File
@@ -14,6 +14,10 @@ import {
jsonRpcNotificationSchema,
jsonRpcRequestSchema,
positiveAgentSequenceSchema,
RUNTIME_ACP_CAPABILITY_NAME,
RUNTIME_ACP_CAPABILITY_VERSION,
RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME,
RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION,
type DaemonCapabilities,
type AgentProtocolFailureCategory,
type FrameAcknowledgment,
@@ -239,15 +243,18 @@ export class AgentProtocolServer {
...(runtimes.length > 0
? [
{
name: 'runtime/acp',
version: autonomousPromptsImplemented ? 4 : 3,
name: RUNTIME_ACP_CAPABILITY_NAME,
version: autonomousPromptsImplemented
? RUNTIME_ACP_CAPABILITY_VERSION
: 3,
critical: true
},
...(modelBridgeImplemented
? [
{
name: 'runtime/model-bridge',
version: 1,
name: RUNTIME_MODEL_BRIDGE_CAPABILITY_NAME,
version:
RUNTIME_MODEL_BRIDGE_CAPABILITY_VERSION,
critical: true
}
]
+109 -5
View File
@@ -7,6 +7,7 @@ import {
import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts'
import type { ControllerLease } from './controller-registry'
import { AgentModelGatewayError } from './agent-model-gateway'
import { EventJournalCapacityError } from './event-journal'
import type { ProtocolMethodContext } from './protocol-server'
import type {
ModelBridgeBrokerDispatch,
@@ -43,8 +44,18 @@ class MemoryAcpJournal implements RuntimeAcpJournal {
}> = []
mainAckSequence = '0'
failAppend = false
capacityBlocked = false
appendAttempts = 0
appendAcpFrame(input: (typeof this.frames)[number]): { created: boolean } {
this.appendAttempts += 1
if (this.capacityBlocked) {
throw new EventJournalCapacityError(
'journal capacity',
'stream',
false
)
}
if (this.failAppend) {
throw new Error('journal capacity')
}
@@ -415,8 +426,6 @@ function harness(input: {
capabilities: { imageInput: false },
limits: {
maximumOutputTokens: 4_096,
maximumModelCalls: 100,
maximumTotalOutputTokens: 409_600,
requestTimeoutMilliseconds: 60_000
}
},
@@ -541,6 +550,52 @@ describe('RuntimeAcpBackend', () => {
expect(fixture.blobSink).not.toHaveBeenCalled()
})
it('allows more than 100 Agent-local model calls in one prompt', async () => {
const dispatch = vi.fn(async () => ({
response: {
status: 200,
headers: {},
bodyBase64: Buffer.from('{}').toString('base64')
},
acknowledgeDelivery: vi.fn(async () => undefined),
failDelivery: vi.fn()
}))
const fixture = harness({
agentOwned: true,
modelGateway: {
dispatch,
finalizePrompt: vi.fn()
}
})
await open(fixture)
await invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation()
)
for (let index = 0; index < 101; index += 1) {
const exchange = await fixture.bridgeDispatch!(
{
method: 'POST',
path: '/v1/responses',
headers: { 'content-type': 'application/json' },
bodyBase64: Buffer.from('{}').toString('base64')
},
{
requestId: `provider-request-${index}`,
signal: new AbortController().signal
}
)
if (!('acknowledgeDelivery' in exchange)) {
throw new Error('Expected an acknowledged model exchange')
}
await exchange.acknowledgeDelivery()
}
expect(dispatch).toHaveBeenCalledTimes(101)
})
it('runs open, prepare, ACP delivery, durable output, cursors, and close', async () => {
const fixture = harness()
const opened = await invoke(
@@ -1162,7 +1217,7 @@ describe('RuntimeAcpBackend', () => {
}
})
it('stops the process when its output quota is exceeded', async () => {
it('does not stop the process when cumulative output exceeds the display budget', async () => {
const fixture = harness({ maximumOutputBytes: 3 })
await open(fixture)
await invoke(
@@ -1172,9 +1227,58 @@ describe('RuntimeAcpBackend', () => {
)
await expect(
fixture.process.emit('stdout', 'four')
).rejects.toMatchObject({ code: 'output-quota' })
expect(fixture.process.stops).toEqual(['output-quota'])
).resolves.toBeUndefined()
expect(fixture.process.stops).toEqual([])
expect(fixture.outputFrames).toEqual(['four'])
})
it('waits for durable journal capacity instead of stopping Runtime output', async () => {
const fixture = harness()
await open(fixture)
await invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation()
)
fixture.journal.capacityBlocked = true
const output = fixture.process.emit('stdout', 'later')
await new Promise<void>((resolve) => setTimeout(resolve, 10))
expect(fixture.process.stops).toEqual([])
expect(fixture.outputFrames).toEqual([])
fixture.journal.capacityBlocked = false
await output
expect(fixture.process.stops).toEqual([])
expect(fixture.outputFrames).toEqual(['later'])
})
it('backs off while durable journal capacity remains unavailable', async () => {
vi.useFakeTimers()
try {
const fixture = harness()
await open(fixture)
await invoke(
fixture,
'runtime/preparePrompt',
fixture.preparation()
)
fixture.journal.capacityBlocked = true
const output = fixture.process.emit('stdout', 'later')
await vi.advanceTimersByTimeAsync(775)
expect(fixture.journal.appendAttempts).toBeLessThanOrEqual(6)
expect(fixture.process.stops).toEqual([])
fixture.journal.capacityBlocked = false
await vi.advanceTimersByTimeAsync(800)
await output
expect(fixture.outputFrames).toEqual(['later'])
await fixture.backend.dispose()
} finally {
vi.useRealTimers()
}
})
it('stops with output quota when the detached durable journal is exhausted', async () => {
+40 -57
View File
@@ -64,6 +64,7 @@ import {
type AgentModelGateway
} from './agent-model-gateway'
import { openCodeModelBridgeModelId } from './model-bridge-helper'
import { EventJournalCapacityError } from './event-journal'
const ZERO_CURSOR = '0'
const DEFAULT_MAXIMUM_BINDINGS = 128
@@ -82,6 +83,8 @@ const DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING = 1_000
const DEFAULT_DISPOSE_TIMEOUT_MS = 10_000
const OWNED_PROMPT_CANCEL_GRACE_MS = 1_000
const OWNED_PROMPT_START_TIMEOUT_MS = 2 * 60_000
const ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS = 25
const ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS = 1_000
export type RuntimeAcpBackendErrorCode =
| 'capacity'
@@ -317,8 +320,6 @@ type BindingState = {
activeOperationId?: string
terminalState?: 'completed' | 'failed' | 'cancelled' | 'interrupted'
inputBytes: number
outputBytes: number
maximumOutputBytes?: number
nextOutputSequence: bigint
pendingInput: number
pendingOutput: number
@@ -721,8 +722,6 @@ export class RuntimeAcpBackend {
existing.stopRequested = false
existing.terminalState = undefined
existing.inputBytes = 0
existing.outputBytes = 0
existing.maximumOutputBytes = undefined
existing.nextOutputSequence = 1n
existing.pendingInput = 0
existing.pendingOutput = 0
@@ -774,7 +773,6 @@ export class RuntimeAcpBackend {
stopRequested: false,
operations: new Map(),
inputBytes: 0,
outputBytes: 0,
nextOutputSequence: 1n,
pendingInput: 0,
pendingOutput: 0,
@@ -1234,11 +1232,6 @@ export class RuntimeAcpBackend {
}
}
binding.inputBytes = 0
binding.outputBytes = 0
binding.maximumOutputBytes = Math.min(
preparation.budget.maximumOutputBytes,
verified.manifest.limits.maximumPromptOutputBytes
)
if (!binding.operations.has(preparation.operationId)) {
binding.operations.set(preparation.operationId, {
preparationDigest,
@@ -1308,10 +1301,6 @@ export class RuntimeAcpBackend {
)
}
await this.#closeModelBridge(binding, true)
this.#options.modelGateway?.finalizePrompt(
binding.request.bindingId,
operation.operationId
)
if (binding.poisoned) {
throw new RuntimeAcpBackendError(
'Model response delivery was not proven before prompt completion',
@@ -1336,8 +1325,6 @@ export class RuntimeAcpBackend {
prepared.completion = completion
binding.activeOperationId = undefined
binding.inputBytes = 0
binding.outputBytes = 0
binding.maximumOutputBytes = undefined
if (binding.deadlineTimer !== undefined) {
clearTimeout(binding.deadlineTimer)
binding.deadlineTimer = undefined
@@ -1527,10 +1514,6 @@ export class RuntimeAcpBackend {
return
}
await this.#closeModelBridge(binding, true)
this.#options.modelGateway?.finalizePrompt(
binding.request.bindingId,
operationId
)
try {
await binding.process.completePrompt()
} catch {
@@ -1568,8 +1551,6 @@ export class RuntimeAcpBackend {
prepared.modelProfile = undefined
binding.activeOperationId = undefined
binding.inputBytes = 0
binding.outputBytes = 0
binding.maximumOutputBytes = undefined
if (binding.deadlineTimer !== undefined) {
clearTimeout(binding.deadlineTimer)
binding.deadlineTimer = undefined
@@ -1673,12 +1654,6 @@ export class RuntimeAcpBackend {
'closed'
)
}
if (binding.nextModelRound >= profile.limits.maximumModelCalls) {
throw new RuntimeAcpBackendError(
'Prompt model call limit reached',
'capacity'
)
}
const roundIndex = binding.nextModelRound
let exchanged
try {
@@ -1990,21 +1965,6 @@ export class RuntimeAcpBackend {
)
}
this.#assertBeforeDeadline(binding)
const budget = this.#activeBudget(binding)
const maximumOutputBytes = Math.min(
budget.maximumOutputBytes,
binding.maximumOutputBytes ?? budget.maximumOutputBytes
)
if (binding.outputBytes + output.data.byteLength > maximumOutputBytes) {
await this.#stopAndReconcile(binding, 'output-quota').catch(
() => undefined
)
throw new RuntimeAcpBackendError(
'Runtime output quota reached',
'output-quota'
)
}
binding.outputBytes += output.data.byteLength
for (
let offset = 0;
offset < output.data.byteLength;
@@ -2017,14 +1977,36 @@ export class RuntimeAcpBackend {
try {
await this.#enqueueOutput(binding, payload, async () => {
const sequence = binding.nextOutputSequence.toString()
this.#options.journal.appendAcpFrame({
controllerId: binding.controllerId,
bindingId: binding.request.bindingId,
channelEpoch: binding.openResult.channelEpoch,
direction: 'runtime-to-main',
sequence,
payload
})
let retryDelay = ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS
while (true) {
try {
this.#options.journal.appendAcpFrame({
controllerId: binding.controllerId,
bindingId: binding.request.bindingId,
channelEpoch: binding.openResult.channelEpoch,
direction: 'runtime-to-main',
sequence,
payload
})
break
} catch (error) {
if (!(error instanceof EventJournalCapacityError)) {
throw error
}
if (binding.state !== 'running') {
throw new RuntimeAcpBackendError(
'Runtime output arrived after the binding stopped',
'closed'
)
}
this.#assertBeforeDeadline(binding)
await delay(retryDelay)
retryDelay = Math.min(
ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS,
retryDelay * 2
)
}
}
binding.nextOutputSequence += 1n
if (binding.transportState === 'live') {
try {
@@ -2287,12 +2269,6 @@ export class RuntimeAcpBackend {
}
#finishProcess(binding: BindingState): void {
if (binding.activeOperationId !== undefined) {
this.#options.modelGateway?.finalizePrompt(
binding.request.bindingId,
binding.activeOperationId
)
}
if (binding.process !== undefined) {
this.#processIdentityBindings.delete(
processIdentityKey(binding.process.identity)
@@ -2750,6 +2726,13 @@ function processIdentityKey(identity: RuntimeAcpProcessIdentity): string {
].join('\0')
}
async function delay(milliseconds: number): Promise<void> {
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, milliseconds)
timer.unref?.()
})
}
function digestSecretBearingPreparation(
preparation: RemotePromptOperationPreparation
): string {
+2 -4
View File
@@ -37,7 +37,7 @@ afterEach(() => {
})
describe('production Runtime composition', () => {
it('loads metadata installed after startup and caches the registered Runtime identity', async () => {
it('loads Runtime metadata independently of Ask-only Host prerequisites', async () => {
const fixture = await createRuntimeBundleTestFixture()
const root = fixture.root
temporaryPaths.push(root)
@@ -62,7 +62,6 @@ describe('production Runtime composition', () => {
blobSink: async () => {},
agentExecutablePath,
reportError,
prerequisitesAvailable: async () => true,
verificationEnvironment: 'test',
filesystemPlatform: 'win32'
})
@@ -128,7 +127,7 @@ describe('production Runtime composition', () => {
events.close()
})
it('composes all autonomous ACP methods but advertises no Runtime when prerequisites fail', async () => {
it('composes all autonomous ACP methods when no Runtime is installed', async () => {
const root = temporaryDirectory()
const runtimeRoot = resolve(root, 'runtimes')
const stateDirectory = resolve(root, 'state')
@@ -155,7 +154,6 @@ describe('production Runtime composition', () => {
releaseKeyRegistry,
runtimeLock: {} as RemoteRuntimeLock,
registry,
prerequisitesAvailable: async () => false,
verificationEnvironment: 'test',
filesystemPlatform: 'win32'
})
+1 -18
View File
@@ -46,8 +46,6 @@ import {
} from './agent-model-gateway'
import type { AgentDiagnosticLog } from './diagnostic-log'
const BWRAP_EXECUTABLE = '/usr/bin/bwrap'
export type ProductionRuntimeProtocol = {
runtimes: ReturnType<typeof createVerifiedRuntimeCapabilitySource>
methods: RuntimeAcpBackend['methods']
@@ -85,7 +83,6 @@ export type ProductionRuntimeCompositionOptions = {
releaseKeyRegistry?: AgentReleaseKeyRegistry
runtimeLock?: RemoteRuntimeLock
registry?: RuntimeBundleRegistry
prerequisitesAvailable?: () => boolean | Promise<boolean>
launchProcess?: typeof launchDirectLinuxStdioProcessOwner
reconcileOrphanedProcesses?: (
installationId: string
@@ -222,9 +219,6 @@ export async function createProductionRuntimeProtocol(
})
return await source()
}
const prerequisites =
options.prerequisitesAvailable ??
productionRuntimePrerequisitesAvailable
const launchProcess =
options.launchProcess ?? launchDirectLinuxStdioProcessOwner
let backend: RuntimeAcpBackend
@@ -277,7 +271,6 @@ export async function createProductionRuntimeProtocol(
profileInput: {
bundleDirectory: launch.bundle.bundleDirectory,
workspaceDirectory: launch.workspace.workspaceDirectory,
scratchDirectory: launch.scratch,
workMode: launch.workMode,
...(launch.modelBridge === undefined ||
options.agentExecutablePath === undefined
@@ -328,8 +321,7 @@ export async function createProductionRuntimeProtocol(
options.agentExecutablePath === undefined ||
!(await executableAvailable(
resolve(options.agentExecutablePath)
)) ||
!(await prerequisites())
))
) {
return []
}
@@ -540,12 +532,3 @@ function bindingDirectoryDigest(
]))
.digest('hex')
}
async function productionRuntimePrerequisitesAvailable(): Promise<boolean> {
try {
await access(BWRAP_EXECUTABLE, constants.X_OK)
return true
} catch {
return false
}
}
+44 -20
View File
@@ -1544,7 +1544,7 @@ describe('AcpRemoteRuntime', () => {
})
})
it('bounds native subagent output per event and per request', async () => {
it('bounds native subagent output per event without a request-wide stop', async () => {
const client: { value?: AgentSideConnection } = {}
const server = fakeServer({
prompt: async ({ sessionId }) => {
@@ -1620,10 +1620,12 @@ describe('AcpRemoteRuntime', () => {
async () => 'once'
)
)
).rejects.toThrow('远端 Runtime 请求输出超过安全上限')
).resolves.toContainEqual(
expect.objectContaining({ type: 'done' })
)
})
it('rejects more than 100 distinct remote tool calls', async () => {
it('accepts more than 100 distinct remote tool calls', async () => {
const client: { value?: AgentSideConnection } = {}
const server = fakeServer({
prompt: async ({ sessionId }) => {
@@ -1634,7 +1636,7 @@ describe('AcpRemoteRuntime', () => {
sessionUpdate: 'tool_call',
toolCallId: `call-${index}`,
title: 'tool',
status: 'in_progress'
status: 'completed'
}
})
}
@@ -1643,17 +1645,18 @@ describe('AcpRemoteRuntime', () => {
})
client.value = server.client
await expect(
collect(
runtime(server).run(
request,
new AbortController().signal,
async () => 'once'
)
const events = await collect(
runtime(server).run(
request,
new AbortController().signal,
async () => 'once'
)
).rejects.toThrow(
'远端 Runtime 单次运行的工具调用超过 100 个'
)
expect(
events.filter((event) => event.type === 'tool')
).toHaveLength(101)
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('does not impose a default wall-clock deadline on remote prompts', async () => {
@@ -2235,7 +2238,7 @@ describe('AcpRemoteRuntime', () => {
])
})
it('cancels and truncates a flood when the consumer is slow', async () => {
it('backpressures a flood without cancelling the Runtime', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const client: { value?: AgentSideConnection } = {}
const cancel = vi.fn(async () => {})
@@ -2276,15 +2279,38 @@ describe('AcpRemoteRuntime', () => {
done: false
})
await new Promise((resolve) => setTimeout(resolve, 10))
await expect(stream.next()).rejects.toThrow('输出已截断')
expect(server.setInboundPaused).toHaveBeenCalledWith(true)
expect(server.setInboundPaused).not.toHaveBeenCalledWith(false)
expect(cancel).toHaveBeenCalledOnce()
const events = []
const firstUpdate = await stream.next()
expect(firstUpdate).toMatchObject({
value: { type: 'text' },
done: false
})
if (firstUpdate.done) {
throw new Error('Expected the first queued Runtime update')
}
events.push(firstUpdate.value)
expect(server.setInboundPaused).not.toHaveBeenCalledWith(false)
while (true) {
const next = await stream.next()
if (next.done) {
break
}
events.push(next.value)
}
expect(
events.filter((event) => event.type === 'text')
).toHaveLength(20)
expect(events.at(-1)).toMatchObject({ type: 'done' })
expect(server.setInboundPaused).toHaveBeenCalledWith(true)
expect(server.setInboundPaused).toHaveBeenCalledWith(false)
expect(cancel).not.toHaveBeenCalled()
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
state: 'outcome-unknown',
activePromptOperationId: request.requestId
state: 'ready',
activePromptOperationId: undefined
})
})
@@ -2504,8 +2530,6 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
capabilities: { imageInput: false },
limits: {
maximumOutputTokens: 4_096,
maximumModelCalls: 100,
maximumTotalOutputTokens: 409_600,
requestTimeoutMilliseconds: 60_000
}
}
+29 -72
View File
@@ -15,9 +15,6 @@ import {
import type {
AgentRuntimeStatus
} from '../../shared/contracts'
import {
maximumConversationToolActivities
} from '../../shared/assistant-contracts'
import {
assertRemotePromptAcceptanceMatchesPreparation,
REMOTE_SEMANTIC_TRANSCRIPT_LIMITS,
@@ -148,7 +145,6 @@ type ActivePrompt = {
updates: QueuedSessionUpdate[]
pendingUpdateBytes: number
inboundPaused: boolean
outputCharacters: number
toolCalls: Map<
string,
{
@@ -336,9 +332,9 @@ function usageEvent(
* Generic ACP runtime over an authenticated remote virtual channel.
*
* The class deliberately does not claim confinement from ACP itself. Ask
* permits only one-shot native reads, while the injected channel's read-only
* sandbox remains the filesystem boundary. Execute follows the user's full
* authorization for the selected SSH account.
* permits only one-shot native reads in addition to the OpenCode Ask
* permission profile. Execute follows the user's full authorization for the
* selected SSH account.
*/
export class AcpRemoteRuntime implements AgentRuntime {
readonly requiresToolApproval = false
@@ -1837,28 +1833,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
return
}
const bytes = utf8JsonBytes(notification.update)
const maximumItems = Math.max(
1,
this.options.maxPendingUpdates ?? DEFAULT_MAX_PENDING_UPDATES
)
const maximumBytes = Math.max(
1,
this.options.maxPendingUpdateBytes ??
DEFAULT_MAX_PENDING_UPDATE_BYTES
)
if (
prompt.updates.length >= maximumItems ||
prompt.pendingUpdateBytes + bytes > maximumBytes
) {
const overflow = new Error(
'远端 Runtime 待消费事件超过安全上限,输出已截断且请求状态未知'
)
prompt.interruption = overflow
prompt.interrupt?.(overflow)
prompt.wake?.()
prompt.wake = undefined
throw overflow
}
let consume: (() => void) | undefined
const consumed = new Promise<void>((resolve) => {
consume = resolve
@@ -1873,10 +1847,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
prompt.wake = undefined
if (
!prompt.inboundPaused &&
(
prompt.updates.length >= maximumItems ||
prompt.pendingUpdateBytes >= maximumBytes
)
this.pendingUpdateLimitReached(prompt)
) {
prompt.inboundPaused = true
await this.awaitOperation(
@@ -1898,7 +1869,11 @@ export class AcpRemoteRuntime implements AgentRuntime {
prompt.pendingUpdateBytes - queued.bytes
)
queued.consumed()
if (prompt.inboundPaused && !prompt.interruption) {
if (
prompt.inboundPaused &&
!prompt.interruption &&
!this.pendingUpdateLimitReached(prompt)
) {
prompt.inboundPaused = false
void this.awaitOperation(
prompt.context,
@@ -1909,6 +1884,22 @@ export class AcpRemoteRuntime implements AgentRuntime {
return queued.update
}
private pendingUpdateLimitReached(prompt: ActivePrompt): boolean {
const maximumItems = Math.max(
1,
this.options.maxPendingUpdates ?? DEFAULT_MAX_PENDING_UPDATES
)
const maximumBytes = Math.max(
1,
this.options.maxPendingUpdateBytes ??
DEFAULT_MAX_PENDING_UPDATE_BYTES
)
return (
prompt.updates.length >= maximumItems ||
prompt.pendingUpdateBytes >= maximumBytes
)
}
private discardUpdates(prompt: ActivePrompt): void {
for (const queued of prompt.updates.splice(0)) {
queued.consumed()
@@ -2030,19 +2021,13 @@ export class AcpRemoteRuntime implements AgentRuntime {
})
}
private limitEvent(
prompt: ActivePrompt,
event: RuntimePublicEvent
): RuntimePublicEvent {
private limitEvent(event: RuntimePublicEvent): RuntimePublicEvent {
const maximum =
this.options.maxEventCharacters ??
DEFAULT_MAX_EVENT_CHARACTERS
let characters = 0
if (event.type === 'text' || event.type === 'reasoning') {
characters = event.delta.length
if (characters > maximum) {
if (event.delta.length > maximum) {
event = { ...event, delta: `${event.delta.slice(0, maximum)}…` }
characters = maximum + 1
}
} else if (event.type === 'tool') {
const fieldMaximum = Math.max(1, Math.floor(maximum / 3))
@@ -2056,10 +2041,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
output: bounded(event.output),
error: bounded(event.error)
}
characters =
(event.input?.length ?? 0) +
(event.output?.length ?? 0) +
(event.error?.length ?? 0)
} else if (event.type === 'subagent') {
const fieldMaximum = Math.max(1, Math.floor(maximum / 2))
const bounded = (value: string | undefined): string | undefined =>
@@ -2071,10 +2052,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
output: bounded(event.output),
error: bounded(event.error)
}
characters =
(event.reason?.length ?? 0) +
(event.output?.length ?? 0) +
(event.error?.length ?? 0)
} else if (event.type === 'status') {
if (event.message.length > maximum) {
event = {
@@ -2082,15 +2059,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
message: `${event.message.slice(0, maximum)}…`
}
}
characters = event.message.length
}
prompt.outputCharacters += characters
if (
prompt.outputCharacters >
(this.options.maxRequestOutputBytes ??
DEFAULT_MAX_REQUEST_OUTPUT_BYTES)
) {
throw new Error('远端 Runtime 请求输出超过安全上限')
}
return event
}
@@ -2120,15 +2088,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
) {
const callId = update.toolCallId.slice(0, 256)
const previous = prompt.toolCalls.get(callId)
if (
!previous &&
prompt.toolCalls.size >=
maximumConversationToolActivities
) {
throw new Error(
'远端 Runtime 单次运行的工具调用超过 100 个'
)
}
const retainedMaximum =
this.options.maxEventCharacters ??
DEFAULT_MAX_EVENT_CHARACTERS
@@ -2359,7 +2318,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
updates: [],
pendingUpdateBytes: 0,
inboundPaused: false,
outputCharacters: 0,
toolCalls: new Map(),
open: true,
completed: false,
@@ -2559,7 +2517,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
)
const mapped = this.mapUpdate(prompt, notification.update)
if (mapped !== undefined) {
publicEvents.push(this.limitEvent(prompt, mapped))
publicEvents.push(this.limitEvent(mapped))
}
} else if (transcriptEvent.kind === 'prompt-terminal') {
const terminal = transcriptTerminal(
@@ -2764,7 +2722,6 @@ export class AcpRemoteRuntime implements AgentRuntime {
updates: [],
pendingUpdateBytes: 0,
inboundPaused: false,
outputCharacters: 0,
toolCalls: new Map(),
open: true,
completed: false,
@@ -2867,7 +2824,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
}
const event = this.mapUpdate(prompt, update)
if (event) {
yield this.limitEvent(prompt, event)
yield this.limitEvent(event)
}
}
await this.awaitOperation(
+186 -51
View File
@@ -965,6 +965,13 @@ describe('ContinueHostAdapter', () => {
protocol,
authentication,
supportsImageInput: true,
requestHeaders: {
'x-tenant-id': 'continue-tenant'
},
requestBody: {
temperature: 0.25,
metadata: { source: 'goodbuddy' }
},
...(authentication === 'api-key'
? { apiKey: 'private-key' }
: {})
@@ -1009,7 +1016,16 @@ describe('ContinueHostAdapter', () => {
apiBase: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
useResponsesApi,
capabilities: ['image_input']
capabilities: ['image_input'],
requestOptions: {
headers: {
'x-tenant-id': 'continue-tenant'
},
extraBodyProperties: {
temperature: 0.25,
metadata: { source: 'goodbuddy' }
}
}
}
],
mcpServers: [
@@ -1176,7 +1192,7 @@ describe('ContinueHostAdapter', () => {
expect(killed).toBe(true)
})
it('fails when the patched host reports dropped stream events', async () => {
it('completes from final history when the patched stream is truncated', async () => {
const distribution = await createDistribution()
let stateRequests = 0
vi.stubGlobal(
@@ -1185,11 +1201,23 @@ describe('ContinueHostAdapter', () => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: { history: [] },
isProcessing: stateRequests > 1,
session: {
history:
stateRequests === 1
? []
: [
{
message: {
role: 'assistant',
content: 'CONTINUE_OK'
}
}
]
},
isProcessing: false,
messageQueueLength: 0,
pendingPermission: null,
goodbuddyEventsOverflow: stateRequests > 1
goodbuddyEventsOverflow: stateRequests === 2
})
}
return Response.json({})
@@ -1224,27 +1252,15 @@ describe('ContinueHostAdapter', () => {
new AbortController().signal,
async () => 'deny'
)
).rejects.toThrow('流式事件超过安全限制')
).resolves.toMatchObject({
text: 'CONTINUE_OK',
streamTruncated: true
})
})
it.each([
{
label: 'event count',
limits: {
maximumStreamEvents: 1,
maximumStreamEventBytes: 10_000
}
},
{
label: 'event bytes',
limits: {
maximumStreamEvents: 10,
maximumStreamEventBytes: 60
}
}
])(
'enforces cumulative streamed $label across state polls',
async ({ limits }) => {
it(
'truncates cumulative streamed bytes without stopping the run',
async () => {
const distribution = await createDistribution()
let stateRequests = 0
vi.stubGlobal(
@@ -1253,8 +1269,20 @@ describe('ContinueHostAdapter', () => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: { history: [] },
isProcessing: stateRequests > 1,
session: {
history:
stateRequests < 3
? []
: [
{
message: {
role: 'assistant',
content: '1234567890CONTINUE_OK'
}
}
]
},
isProcessing: stateRequests === 2,
messageQueueLength: 0,
pendingPermission: null,
goodbuddyEvents:
@@ -1291,8 +1319,7 @@ describe('ContinueHostAdapter', () => {
}
},
{
...limits,
maximumToolCalls: 100,
maximumStreamEventBytes: 60,
terminateProcessTree: vi.fn().mockResolvedValue(undefined)
}
)
@@ -1308,14 +1335,18 @@ describe('ContinueHostAdapter', () => {
}
}
)
).rejects.toThrow('流式事件超过安全限制')
).resolves.toMatchObject({
text: '1234567890CONTINUE_OK',
streamedText: true,
streamTruncated: true
})
expect(forwarded).toEqual([
{ type: 'text', delta: '1234567890' }
])
}
)
it('enforces cumulative unique tool calls before forwarding a later batch', async () => {
it('stops forwarding later deltas after the patched stream overflows', async () => {
const distribution = await createDistribution()
let stateRequests = 0
vi.stubGlobal(
@@ -1324,20 +1355,112 @@ describe('ContinueHostAdapter', () => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: { history: [] },
isProcessing: stateRequests > 1,
session: {
history:
stateRequests < 4
? []
: [
{
message: {
role: 'assistant',
content: 'prefix missing suffix'
}
}
]
},
isProcessing: stateRequests < 4,
messageQueueLength: 0,
pendingPermission: null,
goodbuddyEvents:
stateRequests > 1
? [
{
type: 'tool',
callId: `call-${stateRequests}`,
name: 'Bash',
state: 'running'
}
]
stateRequests === 2
? [{ type: 'text', delta: 'prefix ' }]
: stateRequests === 3
? [{ type: 'text', delta: 'suffix' }]
: [],
goodbuddyEventsOverflow: stateRequests === 3
})
}
return Response.json({})
})
)
const forwarded: unknown[] = []
const adapter = new ContinueHostAdapter({
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: () => ({
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}),
modelProfile: {
id: randomUUID(),
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
})
await expect(
adapter.run(
'hello',
new AbortController().signal,
async () => 'deny',
{
onEvent: (event) => {
forwarded.push(event)
}
}
)
).resolves.toMatchObject({
text: 'prefix missing suffix',
streamedText: true,
streamTruncated: true
})
expect(forwarded).toEqual([
{ type: 'text', delta: 'prefix ' }
])
})
it('forwards more than 100 unique tool calls', async () => {
const distribution = await createDistribution()
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: {
history:
stateRequests === 1
? []
: [
{
message: {
role: 'assistant',
content: 'TOOLS_OK'
}
}
]
},
isProcessing: false,
messageQueueLength: 0,
pendingPermission: null,
goodbuddyEvents:
stateRequests === 2
? Array.from({ length: 101 }, (_, index) => ({
type: 'tool',
callId: `call-${index + 1}`,
name: 'Bash',
state: 'completed'
}))
: []
})
}
@@ -1369,9 +1492,7 @@ describe('ContinueHostAdapter', () => {
}
},
{
maximumStreamEvents: 10,
maximumStreamEventBytes: 10_000,
maximumToolCalls: 1,
maximumStreamEventBytes: 1_000_000,
terminateProcessTree: vi.fn().mockResolvedValue(undefined)
}
)
@@ -1387,11 +1508,11 @@ describe('ContinueHostAdapter', () => {
}
}
)
).rejects.toThrow('工具调用超过 100 个')
expect(forwarded).toHaveLength(1)
expect(forwarded[0]).toMatchObject({
).resolves.toMatchObject({ text: 'TOOLS_OK' })
expect(forwarded).toHaveLength(101)
expect(forwarded.at(-1)).toMatchObject({
type: 'tool',
tool: { callId: 'call-2' }
tool: { callId: 'call-101' }
})
})
@@ -1461,9 +1582,7 @@ describe('ContinueHostAdapter', () => {
}
},
{
maximumStreamEvents: 10,
maximumStreamEventBytes: 10_000,
maximumToolCalls: 10,
terminateProcessTree
}
)
@@ -2014,8 +2133,10 @@ describe('ContinueHostAdapter', () => {
temporaryDirectories.push(root)
const requestPaths: string[] = []
const requestBodies: unknown[] = []
const tenantHeaders: Array<string | string[] | undefined> = []
const server = createServer(async (request, response) => {
requestPaths.push(request.url ?? '')
tenantHeaders.push(request.headers['x-tenant-id'])
let body = ''
for await (const chunk of request) {
body += chunk
@@ -2060,7 +2181,14 @@ describe('ContinueHostAdapter', () => {
baseUrl: `http://127.0.0.1:${address.port}/v1`,
modelName: 'probe-model',
protocol,
authentication: 'none'
authentication: 'none',
requestHeaders: {
'x-tenant-id': 'continue-native'
},
requestBody: {
temperature: 0.25,
metadata: { source: 'goodbuddy' }
}
}
})
const controller = new AbortController()
@@ -2074,6 +2202,13 @@ describe('ContinueHostAdapter', () => {
.catch(() => undefined)
expect(requestPaths).toContain(expectedPath)
expect(requestPaths).not.toContain(unexpectedPath)
expect(tenantHeaders).toContain('continue-native')
expect(requestBodies).toContainEqual(
expect.objectContaining({
temperature: 0.25,
metadata: { source: 'goodbuddy' }
})
)
if (protocol === 'openai-chat-completions') {
const chatRequest = requestBodies.find(
(body): body is { messages: Array<{ role?: unknown }> } =>
+36 -56
View File
@@ -26,9 +26,6 @@ import type {
RuntimeNativeSnapshot,
RuntimeSettings
} from '../../shared/contracts'
import {
maximumConversationToolActivities
} from '../../shared/assistant-contracts'
import {
continueConfigurationPresetSchema,
runtimeNativeInventoryLimits,
@@ -69,9 +66,7 @@ const maximumConfiguredMcpServers =
runtimeNativeInventoryLimits.mcpServers
const maximumConfiguredRules = runtimeNativeInventoryLimits.rules
const maximumConfiguredPrompts = runtimeNativeInventoryLimits.prompts
const maximumStreamEvents = 5_000
const maximumStreamEventBytes = 2 * 1024 * 1024
const maximumToolCalls = maximumConversationToolActivities
const defaultControlRequestTimeoutMs = 30_000
const knowledgeMcpName = 'goodbuddy-knowledge'
const customMcpName = 'goodbuddy-custom-mcp'
@@ -142,7 +137,7 @@ const continueHostQuestionSchema = z
const stateSchema = z.object({
session: z.object({
history: z.array(z.unknown()).max(5_000),
history: z.array(z.unknown()),
usage: sessionUsageSchema.optional()
}),
isProcessing: z.boolean(),
@@ -152,12 +147,11 @@ const stateSchema = z.object({
toolName: z.string().min(1).max(128),
toolArgs: z.record(z.string(), z.unknown()),
requestId: z.string().min(1).max(256),
toolCallPreview: z.array(z.unknown()).max(100).optional()
toolCallPreview: z.array(z.unknown()).optional()
})
.nullable(),
goodbuddyEvents: z
.array(continueHostStreamEventSchema)
.max(maximumStreamEvents)
.optional(),
goodbuddyEventsOverflow: z.boolean().optional(),
goodbuddyQuestion: continueHostQuestionSchema.nullable().optional()
@@ -199,6 +193,7 @@ export type ContinueHostTool = {
export type ContinueHostRunResult = {
text: string
streamedText?: true
streamTruncated?: true
usage?: ContinueHostUsage
tools?: ContinueHostTool[]
}
@@ -249,9 +244,7 @@ export type ContinueHostAdapterOptions = {
export type ContinueHostAdapterDependencies = {
terminateProcessTree: typeof terminateProcessTreeAndWait
maximumStreamEvents: number
maximumStreamEventBytes: number
maximumToolCalls: number
controlRequestTimeoutMs: number
}
@@ -713,9 +706,6 @@ function extractContinueTools(
if (!callId || typeof name !== 'string' || !name.trim()) {
continue
}
if (!tools.has(callId) && tools.size >= 100) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
const status = state.status
const normalizedState =
status === 'done' || status === 'completed'
@@ -834,9 +824,7 @@ export class ContinueHostAdapter {
) {
this.dependencies = {
terminateProcessTree: terminateProcessTreeAndWait,
maximumStreamEvents,
maximumStreamEventBytes,
maximumToolCalls,
controlRequestTimeoutMs: defaultControlRequestTimeoutMs,
...dependencies
}
@@ -985,7 +973,7 @@ export class ContinueHostAdapter {
patched = replaceExactly(
patched,
streamCallbacksMarker,
'a={onContent:u=>{if(!u)return;let l=String(u);e.goodbuddyEventsBytes+=Buffer.byteLength(l);l.length<=1e5&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push({type:"text",delta:l}):e.goodbuddyEventsOverflow=!0},onContentComplete:u=>{},onToolStart:(u,l,c)=>{if(!c)return;let d={type:"tool",callId:c,name:u,state:"running",input:(()=>{try{return JSON.stringify(l).slice(0,4e3)}catch{return"[无法序列化]"}})()};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(d));e.goodbuddyEvents.length<5e3&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(d):e.goodbuddyEventsOverflow=!0},onToolResult:(u,l,c,d)=>{if(!d)return;let p={type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed",output:String(u).slice(0,16e3)};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(p));e.goodbuddyEvents.length<5e3&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(p):e.goodbuddyEventsOverflow=!0},onToolError:(u,l,c)=>{if(!c)return;let d={type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(d));e.goodbuddyEvents.length<5e3&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(d):e.goodbuddyEventsOverflow=!0},onToolPermissionRequest:'
'a={onContent:u=>{if(!u)return;let l=String(u);e.goodbuddyEventsBytes+=Buffer.byteLength(l);l.length<=1e5&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push({type:"text",delta:l}):e.goodbuddyEventsOverflow=!0},onContentComplete:u=>{},onToolStart:(u,l,c)=>{if(!c)return;let d={type:"tool",callId:c,name:u,state:"running",input:(()=>{try{return JSON.stringify(l).slice(0,4e3)}catch{return"[无法序列化]"}})()};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(d));e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(d):e.goodbuddyEventsOverflow=!0},onToolResult:(u,l,c,d)=>{if(!d)return;let p={type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed",output:String(u).slice(0,16e3)};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(p));e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(p):e.goodbuddyEventsOverflow=!0},onToolError:(u,l,c)=>{if(!c)return;let d={type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(d));e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(d):e.goodbuddyEventsOverflow=!0},onToolPermissionRequest:'
)
patched = replaceExactly(
patched,
@@ -1387,6 +1375,22 @@ export class ContinueHostAdapter {
? '${{ secrets.ANTHROPIC_API_KEY }}'
: '${{ secrets.OPENAI_API_KEY }}'
}
const requestHeaders =
this.options.modelProfile.requestHeaders ?? {}
const requestBody = this.options.modelProfile.requestBody ?? {}
if (
Object.keys(requestHeaders).length > 0 ||
Object.keys(requestBody).length > 0
) {
modelConfig.requestOptions = {
...(Object.keys(requestHeaders).length > 0
? { headers: requestHeaders }
: {}),
...(Object.keys(requestBody).length > 0
? { extraBodyProperties: requestBody }
: {})
}
}
const configured = this.options.configPath.trim()
? await loadContinueConfig(this.options.configPath.trim())
: {}
@@ -1584,7 +1588,7 @@ export class ContinueHostAdapter {
let observedTools: ContinueHostTool[] = []
const reportedQuestionIds = new Set<string>()
let streamedText = false
let streamEventCount = 0
let streamTruncated = false
let streamEventBytes = 0
const observedToolCallIds = new Set<string>()
try {
@@ -1635,38 +1639,29 @@ export class ContinueHostAdapter {
signal: executionSignal
})
)
if (state.goodbuddyEventsOverflow) {
throw new Error('Continue 宿主流式事件超过安全限制')
}
const streamEvents = state.goodbuddyEvents ?? []
streamTruncated ||= state.goodbuddyEventsOverflow === true
let streamEvents = state.goodbuddyEvents ?? []
const batchStreamEventBytes = Buffer.byteLength(
JSON.stringify(streamEvents)
)
const nextStreamEventCount =
streamEventCount + streamEvents.length
const nextStreamEventBytes =
streamEventBytes + batchStreamEventBytes
if (
streamTruncated ||
nextStreamEventBytes >
this.dependencies.maximumStreamEventBytes
) {
streamTruncated = true
streamEvents = []
} else {
streamEventBytes = nextStreamEventBytes
}
const nextToolCallIds = new Set(observedToolCallIds)
for (const event of streamEvents) {
if (event.type === 'tool') {
nextToolCallIds.add(event.callId)
}
}
if (
nextStreamEventCount >
this.dependencies.maximumStreamEvents ||
nextStreamEventBytes >
this.dependencies.maximumStreamEventBytes
) {
throw new Error('Continue 宿主流式事件超过安全限制')
}
if (
nextToolCallIds.size > this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
streamEventCount = nextStreamEventCount
streamEventBytes = nextStreamEventBytes
for (const callId of nextToolCallIds) {
observedToolCallIds.add(callId)
}
@@ -1677,11 +1672,6 @@ export class ContinueHostAdapter {
for (const tool of historyTools) {
observedToolCallIds.add(tool.callId)
}
if (
observedToolCallIds.size > this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
observedTools = mergeContinueTools(
observedTools,
historyTools
@@ -1744,12 +1734,6 @@ export class ContinueHostAdapter {
}
const pending = state.pendingPermission
if (pending && !handledPermissionIds.has(pending.requestId)) {
if (
handledPermissionIds.size >=
this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
handledPermissionIds.add(pending.requestId)
const pendingCallId =
observedTools.find(
@@ -1761,13 +1745,6 @@ export class ContinueHostAdapter {
if (
!observedTools.some((tool) => tool.callId === pendingCallId)
) {
if (
!observedToolCallIds.has(pendingCallId) &&
observedToolCallIds.size >=
this.dependencies.maximumToolCalls
) {
throw new Error('Continue 单次运行的工具调用超过 100 个')
}
observedToolCallIds.add(pendingCallId)
observedTools = [
...observedTools,
@@ -1829,6 +1806,9 @@ export class ContinueHostAdapter {
return {
text,
...(streamedText ? { streamedText: true as const } : {}),
...(streamTruncated
? { streamTruncated: true as const }
: {}),
...(usage ? { usage } : {}),
...(observedTools.length > 0
? { tools: observedTools }
+10 -6
View File
@@ -1134,7 +1134,7 @@ describe('ContinueAgentRuntime', () => {
).rejects.toThrow('已失效或不存在')
})
it('fails instead of silently dropping an overflowing stream queue', async () => {
it('drains more than 1,000 queued stream events without stopping the run', async () => {
mocks.runHost.mockImplementation(
async (
_prompt,
@@ -1160,11 +1160,15 @@ describe('ContinueAgentRuntime', () => {
new AbortController().signal
)
await expect(async () => {
for await (const _event of stream) {
void _event
}
}).rejects.toThrow('流式事件积压超过安全限制')
const events: RuntimeEvent[] = []
for await (const event of stream) {
events.push(event)
}
expect(
events.filter((event) => event.type === 'text')
).toHaveLength(1_001)
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('aborts the host run when stream consumption ends early', async () => {
+21 -7
View File
@@ -64,7 +64,6 @@ export type ContinueRuntimeOptions = {
// The prompt reaches the Continue host through a local HTTP POST body, so no
// platform command-line limit applies to it.
const MAX_CONTINUE_PROMPT_CHARACTERS = 128_000
const MAX_QUEUED_STREAM_EVENTS = 1_000
const scopedReadToolNameSet = new Set<string>(scopedReadToolNames)
function continueToolFailureMessage(tool: ContinueHostTool): string {
@@ -548,6 +547,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
}
let result: ContinueHostRunResult
const emittedTools = new Map<string, ContinueHostTool>()
let emittedText = ''
const requestQuestionIds = new Set<string>()
try {
const host = this.getHostAdapter(
@@ -574,9 +574,6 @@ export class ContinueAgentRuntime implements AgentRuntime {
const hostController = new AbortController()
const hostSignal = AbortSignal.any([signal, hostController.signal])
const onEvent = (event: ContinueHostStreamEvent): void => {
if (queuedEvents.length >= MAX_QUEUED_STREAM_EVENTS) {
throw new Error('Continue 流式事件积压超过安全限制')
}
queuedEvents.push(event)
wakeStream?.()
wakeStream = undefined
@@ -623,6 +620,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
emittedTools.set(event.tool.callId, event.tool)
}
if (event.type === 'text') {
emittedText += event.delta
yield {
requestId: request.requestId,
type: 'text',
@@ -742,11 +740,27 @@ export class ContinueAgentRuntime implements AgentRuntime {
yield finalEvent
}
}
if (!result.streamedText) {
if (result.streamTruncated) {
yield {
requestId: request.requestId,
type: 'text',
delta: result.text
type: 'status',
message:
'Continue 输出较长,已截断部分流式展示;任务已继续完成'
}
}
if (!result.streamedText || result.streamTruncated) {
const remainingText =
result.streamTruncated
? result.text.startsWith(emittedText)
? result.text.slice(emittedText.length)
: ''
: result.text
if (remainingText) {
yield {
requestId: request.requestId,
type: 'text',
delta: remainingText
}
}
}
if (result.usage) {
+84 -1
View File
@@ -114,6 +114,8 @@ describe('createAgentRuntime model compatibility', () => {
authentication: 'api-key' as const,
supportsImageInput: true,
imageGenerationQuality: 'auto' as const,
requestHeaders: { 'x-tenant-id': 'harness-tenant' },
requestBody: { temperature: 0.2 },
apiKey: 'gateway-key'
}
const deepseekHarnessLauncher = vi
@@ -135,8 +137,14 @@ describe('createAgentRuntime model compatibility', () => {
available: false
})
expect(deepseekHarnessLauncher).toHaveBeenCalledWith(
expect.objectContaining({ supportsImageInput: true })
expect.objectContaining({
supportsImageInput: true,
requestHeaders: { 'x-tenant-id': 'harness-tenant' }
})
)
expect(
deepseekHarnessLauncher.mock.calls[0]?.[0]
).not.toHaveProperty('requestBody')
await runtime.dispose()
})
@@ -152,6 +160,81 @@ describe('createAgentRuntime model compatibility', () => {
await runtime.dispose()
})
it('forwards the selected profile request customization to direct chats', async () => {
const profile = {
id: '00000000-0000-4000-8000-000000000041',
name: 'Tenant model',
baseUrl: 'https://gateway.example/v1',
modelName: 'tenant-model',
protocol: 'openai-chat-completions' as const,
authentication: 'none' as const,
supportsImageInput: false,
maximumOutputTokens: 48_000,
imageGenerationQuality: 'auto' as const,
requestHeaders: {
'x-tenant-id': 'tenant-a'
},
requestBody: {
temperature: 0.2
}
}
const fetcher = vi.fn<typeof fetch>(async () =>
new Response(
[
`data: ${JSON.stringify({
choices: [
{
delta: { content: 'OK' },
finish_reason: 'stop'
}
]
})}`,
'',
'data: [DONE]',
'',
''
].join('\n'),
{
status: 200,
headers: { 'content-type': 'text/event-stream' }
}
)
)
vi.stubGlobal('fetch', fetcher)
const runtime = createAgentRuntime(
process.cwd(),
settings({
modelProfiles: [profile],
defaultModelProfileId: profile.id
})
)
try {
for await (const _event of runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ee',
conversationId: 'customized-direct-chat',
prompt: 'hello'
},
new AbortController().signal
)) {
void _event
}
const init = fetcher.mock.calls[0]?.[1]
expect(new Headers(init?.headers).get('x-tenant-id')).toBe(
'tenant-a'
)
expect(JSON.parse(String(init?.body))).toMatchObject({
model: 'tenant-model',
temperature: 0.2
})
} finally {
await runtime.dispose()
vi.unstubAllGlobals()
}
})
it('forwards the selected profile image capability to direct runtimes', async () => {
const visionSettings = settings({
supportsImageInput: true
+11 -1
View File
@@ -104,7 +104,9 @@ function resolveContextCompression(
>,
authentication: summaryProfile.authentication,
contextWindowTokens: summaryProfile.contextWindowTokens,
maximumOutputTokens: summaryProfile.maximumOutputTokens
maximumOutputTokens: summaryProfile.maximumOutputTokens,
requestHeaders: summaryProfile.requestHeaders,
requestBody: summaryProfile.requestBody
}
}
: {})
@@ -127,6 +129,8 @@ export function createDefaultModelRuntime(
model: settings.modelName,
protocol: settings.modelProtocol,
authentication: settings.modelAuthentication,
requestHeaders: currentProfile?.requestHeaders,
requestBody: currentProfile?.requestBody,
supportsImageInput: settings.supportsImageInput,
maximumOutputTokens: currentProfile?.maximumOutputTokens,
defaultWorkspace: settings.workspacePath || defaultWorkspace,
@@ -149,6 +153,8 @@ export function createModelProfileRuntime(
model: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
requestHeaders: profile.requestHeaders,
requestBody: profile.requestBody,
supportsImageInput: profile.supportsImageInput,
maximumOutputTokens: profile.maximumOutputTokens,
imageGenerationQuality:
@@ -211,6 +217,7 @@ export function createAgentRuntime(
baseUrl: profile.baseUrl,
model: profile.modelName,
supportsImageInput: profile.supportsImageInput === true,
requestHeaders: profile.requestHeaders,
launch: capabilities.deepseekHarnessLauncher,
credentialRefs: {
GOODBUDDY_HARNESS_MODEL_API_KEY: profile.apiKey
@@ -346,10 +353,13 @@ export function createAgentRuntime(
settings?.modelProtocol ??
defaultRuntimeSettings.modelProtocol,
authentication: modelAuthentication,
requestHeaders: defaultModelProfile?.requestHeaders,
requestBody: defaultModelProfile?.requestBody,
supportsImageInput:
defaultModelProfile?.supportsImageInput ??
settings?.supportsImageInput ??
defaultRuntimeSettings.supportsImageInput,
maximumOutputTokens: defaultModelProfile?.maximumOutputTokens,
imageGenerationQuality:
defaultModelProfile?.imageGenerationQuality ??
settings?.imageGenerationQuality ??
@@ -33,6 +33,8 @@ import {
import { GOODBUDDY_HARNESS_MAX_STEP_TOKENS } from './goodbuddy-harness-control-plane'
import { DshNpmExtensionInstaller } from './dsh-extension-marketplace'
import { DEEPSEEK_HARNESS_MAX_FRAME_BYTES } from './deepseek-harness-control-protocol'
import { createOpenAIChatCompletionsUrl } from './openai-endpoint'
import { createModelRequestProbe } from '../../../tests/support/model-request-probe'
const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY'
const SKILL_CALL_ID = 'e2e-skill-call'
@@ -329,6 +331,7 @@ function createInProcessLaunch(
provider: 'goodbuddy',
model: options.model,
supportsImageInput: options.supportsImageInput,
requestHeaders: options.requestHeaders,
harnessVersion: '0.1.0-rc.8',
credentialRefs: options.credentialRefs,
skillPackages: options.skillPackages,
@@ -891,6 +894,79 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => {
60_000
)
it.runIf(liveModelEnabled)(
'forwards a custom model header through the real DeepSeek Harness provider',
async () => {
if (!liveApiKey) {
throw new Error(
'GOODBUDDY_DSH_API_KEY is required for live DSH model E2E'
)
}
const root = await realpath(
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-header-model-'))
)
const workspace = join(root, 'workspace')
const dshHome = join(root, 'dsh-home')
await Promise.all([mkdir(workspace), mkdir(dshHome)])
const probe = await createModelRequestProbe({
upstreamUrl: createOpenAIChatCompletionsUrl(liveBaseUrl),
headerName: 'x-goodbuddy-e2e'
})
const inProcess = createInProcessLaunch(dshHome)
const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: workspace,
baseUrl: probe.baseUrl,
model: liveModel,
requestHeaders: {
'x-goodbuddy-e2e': 'harness-header-present'
},
launch: (options) => inProcess.launch(options),
credentialRefs: {
[CREDENTIAL_REF]: liveApiKey
},
initializationTimeoutMs: 20_000,
promptTimeoutMs: 120_000,
shutdownTimeoutMs: 5_000
})
try {
const events = await collect(
runtime.run(
{
requestId: 'request-live-custom-header',
conversationId: 'live-custom-header',
prompt:
'Reply with exactly DSH_CUSTOM_HEADER_E2E_OK and nothing else.',
workMode: 'ask'
},
new AbortController().signal
)
)
expect(
events
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
)
.join('')
).toContain('DSH_CUSTOM_HEADER_E2E_OK')
expect(probe.observations).toEqual([
{
headerValue: 'harness-header-present',
bodyFields: {}
}
])
} finally {
await runtime.dispose()
await Promise.allSettled(
inProcess.hosts.map((host) => host.dispose())
)
await probe.close()
await rm(root, { recursive: true, force: true })
}
},
180_000
)
it.runIf(liveModelEnabled)(
'lets a real model use Main-brokered Web Search and Fetch in Ask',
async () => {
@@ -1,6 +1,9 @@
import { isAbsolute } from 'node:path'
import { z } from 'zod'
import { isDeepSeekHarnessCompatibleBaseUrl } from '../../shared/deepseek-harness-compatibility'
import {
modelRequestHeadersSchema
} from '../../shared/model-request-customization'
import {
runtimeExtensionConfigurationSchema,
runtimeExtensionIdSchema
@@ -9,7 +12,7 @@ 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_CONTROL_VERSION = 3
export const DEEPSEEK_HARNESS_HOST_VERSION =
bundledDeepSeekHarnessVersion
export const DEEPSEEK_HARNESS_CREDENTIAL_REF =
@@ -84,6 +87,7 @@ export const controlledHarnessHostConfigSchema = z
provider: z.literal('goodbuddy'),
model: z.string().min(1).max(128),
supportsImageInput: z.boolean(),
requestHeaders: modelRequestHeadersSchema,
harnessVersion: z.literal(DEEPSEEK_HARNESS_HOST_VERSION),
credentialRefs: z
.tuple([z.literal(DEEPSEEK_HARNESS_CREDENTIAL_REF)])
@@ -48,6 +48,7 @@ function setup(
maxEventCharacters?: number
maxRequestOutputCharacters?: number
supportsImageInput?: boolean
requestHeaders?: Record<string, string>
advertisedImageInput?: boolean
initializationTimeoutMs?: number
useDefaultInitializationTimeout?: boolean
@@ -247,6 +248,7 @@ function setup(
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test',
supportsImageInput: options.supportsImageInput,
requestHeaders: options.requestHeaders,
launch,
loadAcpSdk: async () => sdk,
...(options.useDefaultInitializationTimeout
@@ -639,7 +641,9 @@ describe('DeepSeekHarnessRuntime', () => {
})
it('uses ACP stdio, maps conversations to sessions, and streams text', async () => {
const harness = setup()
const harness = setup({
requestHeaders: { 'x-tenant-id': 'harness-tenant' }
})
const first = collect(
harness.runtime.run(
request('one'),
@@ -678,6 +682,7 @@ describe('DeepSeekHarnessRuntime', () => {
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test',
supportsImageInput: false,
requestHeaders: { 'x-tenant-id': 'harness-tenant' },
credentialRefs: [],
skillPackages: [],
extensionPackages: []
@@ -1,5 +1,6 @@
import type {
AgentRuntimeStatus,
ModelRequestHeaders,
RuntimeNativeSnapshot,
RuntimeNativeTool
} from '../../shared/contracts'
@@ -173,6 +174,7 @@ export type DeepSeekHarnessLaunchOptions = {
baseUrl: string
model: string
supportsImageInput: boolean
requestHeaders?: ModelRequestHeaders
credentialRefs: readonly string[]
skillPackages: readonly RuntimeSkillPackage[]
extensionPackages: readonly ControlledHarnessExtensionPackage[]
@@ -183,6 +185,7 @@ export type DeepSeekHarnessRuntimeOptions = {
baseUrl: string
model: string
supportsImageInput?: boolean
requestHeaders?: ModelRequestHeaders
launch: (
options: DeepSeekHarnessLaunchOptions
) => Promise<DeepSeekHarnessChild>
@@ -821,6 +824,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
model: this.options.model,
supportsImageInput:
this.options.supportsImageInput === true,
requestHeaders: this.options.requestHeaders ?? {},
credentialRefs: Object.keys(
this.options.credentialRefs ?? {}
),
@@ -54,6 +54,7 @@ async function fixture() {
baseUrl: 'http://gateway.example/openai/v1?api-version=1',
model: 'qwen-plus',
supportsImageInput: false,
requestHeaders: { 'x-tenant-id': 'harness-tenant' },
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
skillPackages: [],
extensionPackages: []
@@ -63,6 +64,7 @@ async function fixture() {
describe('DeepSeek Harness utility launcher', () => {
it('accepts only strict control messages and secret-free config', () => {
expect(DEEPSEEK_HARNESS_CONTROL_VERSION).toBe(3)
expect(
parseHarnessControlMessage({
protocol: DEEPSEEK_HARNESS_CONTROL_PROTOCOL,
@@ -106,6 +108,9 @@ describe('DeepSeek Harness utility launcher', () => {
baseUrl: 'http://gateway.example/openai/v1?api-version=1',
model: 'qwen-plus',
supportsImageInput: false,
requestHeaders: {
'x-tenant-id': 'harness-tenant'
},
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF]
}
})
@@ -288,6 +288,7 @@ export function createDeepSeekHarnessUtilityLauncher(
provider: 'goodbuddy',
model: options.model,
supportsImageInput: options.supportsImageInput,
requestHeaders: options.requestHeaders ?? {},
harnessVersion: DEEPSEEK_HARNESS_HOST_VERSION,
credentialRefs: [DEEPSEEK_HARNESS_CREDENTIAL_REF],
skillPackages: canonicalSkillPackages,
@@ -18,6 +18,7 @@ import {
import type { KnowledgeService } from '../knowledge/knowledge-service'
import { AssistantDatabase } from '../assistant/assistant-database'
import {
browserToolNames,
KnowledgeMcpGateway,
type MagicNotesDatabase
} from './knowledge-mcp-gateway'
@@ -207,6 +208,81 @@ afterEach(async () => {
})
describe('KnowledgeMcpGateway', () => {
it('exposes the assigned browser through a request-scoped conversation', async () => {
const { service } = createService()
const browserService = {
getOrigin: vi.fn(() => undefined),
navigate: vi.fn(async () => ({
url: 'https://example.com/',
origin: 'https://example.com'
})),
snapshot: vi.fn(),
click: vi.fn(),
type: vi.fn(),
select: vi.fn(),
back: vi.fn(),
screenshot: vi.fn(),
releaseConversation: vi.fn(async () => undefined)
}
const gateway = new KnowledgeMcpGateway(service, {
browserService
})
gateways.push(gateway)
await gateway.start()
const token = gateway.grant(
'browser-request',
[],
new AbortController().signal,
'none',
undefined,
'browser-conversation'
)!
expect(gateway.getAvailableToolNames(token)).toEqual(browserToolNames)
const client = new Client({
name: 'browser-loopback-test',
version: '1.0.0'
})
await client.connect(
new StreamableHTTPClientTransport(
new URL(gateway.getEndpoint()!),
{
requestInit: {
headers: { Authorization: `Bearer ${token}` }
}
}
)
)
try {
const listed = await client.listTools()
expect(listed.tools.map((tool) => tool.name)).toEqual(
browserToolNames
)
await expect(
client.callTool({
name: 'browser_navigate',
arguments: { url: 'https://example.com' }
})
).resolves.toMatchObject({
content: [
{
type: 'text',
text: JSON.stringify({
url: 'https://example.com/',
origin: 'https://example.com'
})
}
]
})
expect(browserService.navigate).toHaveBeenCalledWith(
'browser-conversation',
'https://example.com',
expect.any(AbortSignal)
)
} finally {
await client.close()
}
})
it('exposes GoodBuddy config reads in Ask and apply only in Execute', async () => {
const { service } = createService()
const configService = {
+117 -23
View File
@@ -64,6 +64,14 @@ import {
normalizeMcpToolSchema
} from './mcp-tool-utils'
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
import {
BrowserModelTools,
browserToolNames,
type BrowserToolName,
type BrowserToolService
} from '../browser/browser-model-tools'
import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver'
import { safeToolErrorDetail } from './approval-summary'
const MAX_REQUEST_BODY_BYTES = 64 * 1024
const MAX_RESULT_BYTES = 128 * 1024
@@ -79,6 +87,7 @@ const MAX_CAPABILITY_TTL_MS = 15 * 60_000
const customMcpJsonSchemaValidator = new AjvJsonSchemaValidator()
export {
browserToolNames,
knowledgeToolNames,
magicNoteReadToolNames,
magicNoteWriteToolNames,
@@ -86,6 +95,10 @@ export {
scopedReadToolNames
}
export type GoodBuddyBuiltinToolName =
| ScopedDataToolName
| BrowserToolName
const {
knowledge_list: knowledgeListTool,
knowledge_search: knowledgeSearchTool
@@ -171,6 +184,7 @@ type Capability = {
configAccess: MagicNotesCapabilityAccess
configWorkspacePath?: string
authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer
browserConversationId?: string
expiresAt: number
signal: AbortSignal
brokerController: AbortController
@@ -216,6 +230,7 @@ export type KnowledgeMcpGatewayOptions = {
now?: () => number
magicNotesDatabase?: MagicNotesDatabase
configService?: GoodBuddyConfigService
browserService?: BrowserToolService
launchEnvironmentProvider?: LaunchEnvironmentProvider
}
@@ -341,6 +356,7 @@ export class KnowledgeMcpGateway {
private readonly maximumBodyBytes: number
private readonly magicNotesDatabase?: MagicNotesDatabase
private readonly configService?: GoodBuddyConfigService
private readonly browserService?: BrowserToolService
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
private server?: Server
private endpoint?: string
@@ -363,6 +379,7 @@ export class KnowledgeMcpGateway {
this.now = options.now ?? Date.now
this.magicNotesDatabase = options.magicNotesDatabase
this.configService = options.configService
this.browserService = options.browserService
this.launchEnvironmentProvider = options.launchEnvironmentProvider
}
@@ -414,7 +431,8 @@ export class KnowledgeMcpGateway {
access: MagicNotesCapabilityAccess
workspacePath: string
authorizeApply?: GoodBuddyConfigApplyAuthorizer
}
},
browserConversationId?: string
): string | undefined {
const effectiveMagicNotesAccess = this.magicNotesDatabase
? magicNotesAccess
@@ -422,10 +440,14 @@ export class KnowledgeMcpGateway {
const effectiveConfigAccess = this.configService
? config?.access ?? 'none'
: 'none'
const effectiveBrowserConversationId = this.browserService
? browserConversationId
: undefined
if (
authorizedLibraryIds.length === 0 &&
effectiveMagicNotesAccess === 'none' &&
effectiveConfigAccess === 'none'
effectiveConfigAccess === 'none' &&
!effectiveBrowserConversationId
) {
return undefined
}
@@ -435,6 +457,7 @@ export class KnowledgeMcpGateway {
libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]),
magicNotesAccess: effectiveMagicNotesAccess,
configAccess: effectiveConfigAccess,
browserConversationId: effectiveBrowserConversationId,
...(effectiveConfigAccess !== 'none'
? {
configWorkspacePath: config?.workspacePath,
@@ -683,7 +706,7 @@ export class KnowledgeMcpGateway {
return libraries
}
getAvailableToolNames(token: string): ScopedDataToolName[] {
getAvailableToolNames(token: string): GoodBuddyBuiltinToolName[] {
const capability = this.getCapability(token)
return [
...(capability.libraryIds.length > 0
@@ -700,6 +723,9 @@ export class KnowledgeMcpGateway {
: []),
...(capability.configAccess === 'write'
? goodbuddyConfigWriteToolNames
: []),
...(capability.browserConversationId
? browserToolNames
: [])
]
}
@@ -1413,7 +1439,7 @@ export class KnowledgeMcpGateway {
private createDownstreamMcpSession(
token: string,
availableScopedTools: ReadonlySet<ScopedDataToolName>
availableTools: ReadonlySet<GoodBuddyBuiltinToolName>
): DownstreamMcpSession {
const mcp = new McpProtocolServer(
{
@@ -1461,9 +1487,11 @@ export class KnowledgeMcpGateway {
token,
extra.signal
)
const scopedTools = [...availableScopedTools].flatMap(
const scopedTools = [...availableTools].flatMap(
(name): Tool[] => {
const definition = scopedDataToolByName.get(name)
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
if (!definition) {
return []
}
@@ -1489,10 +1517,34 @@ export class KnowledgeMcpGateway {
]
}
)
const capability = this.getCapability(token)
const browserTools = capability.browserConversationId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId
})
: undefined
const browserDefinitions = browserTools
? browserTools.listTools().map(
(definition): Tool => ({
name: definition.name,
title: definition.displayName,
description: definition.description,
inputSchema: definition.inputSchema as Tool['inputSchema'],
annotations: {
readOnlyHint:
definition.name === 'browser_snapshot' ||
definition.name === 'browser_screenshot',
destructiveHint: false
}
})
)
: []
session.listedTools = true
return {
tools: [
...scopedTools,
...browserDefinitions,
...[...customBindings.values()].map(
(binding) => binding.exposedTool
)
@@ -1505,27 +1557,69 @@ export class KnowledgeMcpGateway {
async (call, extra) => {
const name = call.params.name
const input = call.params.arguments ?? {}
if (availableScopedTools.has(name as ScopedDataToolName)) {
if (availableTools.has(name as ScopedDataToolName)) {
const definition = scopedDataToolByName.get(
name as ScopedDataToolName
)
if (!definition) {
throw new Error('GoodBuddy 工具不存在')
}
const parsedInput = definition.inputSchema.parse(input)
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
await this.callScopedTool(
token,
name as ScopedDataToolName,
parsedInput
if (definition) {
const parsedInput = definition.inputSchema.parse(input)
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
await this.callScopedTool(
token,
name as ScopedDataToolName,
parsedInput
)
)
}
]
}
}
const capability = this.getCapability(token)
const browserTools = capability.browserConversationId
? new BrowserModelTools({
service: this.browserService!,
conversationId: capability.browserConversationId
})
: undefined
if (browserTools?.ownsTool(name)) {
try {
const result = await browserTools.callTool(
name,
input,
extra.signal
)
return {
content: result.parts.map((part) =>
part.type === 'text'
? { type: 'text' as const, text: part.text }
: {
type: 'image' as const,
data: part.data,
mimeType: part.mimeType
}
)
}
]
} catch (error) {
const detail =
safeToolErrorDetail(error, 2_000) ??
'内置浏览器工具执行失败'
return {
isError: true,
content: [
{
type: 'text' as const,
text:
error instanceof BrowserStaleReferenceError
? `${detail}。请调用 browser_snapshot 获取新快照,然后使用新引用重试。`
: detail
}
]
}
}
}
}
const binding = (
@@ -1643,12 +1737,12 @@ export class KnowledgeMcpGateway {
})
return
}
const availableScopedTools = new Set(
const availableTools = new Set(
this.getAvailableToolNames(token)
)
session = this.createDownstreamMcpSession(
token,
availableScopedTools
availableTools
)
createdSession = true
this.downstreamMcpSessions.set(session.registryKey, session)
@@ -1,649 +0,0 @@
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import type {
ModelCallIdentity,
PrepareModelCall
} from '../../shared/model-call-operation-contracts'
import {
ModelCallCapacityError,
ModelCallConflictError,
ModelCallOperationStore,
ModelCallStateError
} from './model-call-operation-store'
const directories: string[] = []
const digestA = `sha256:${'a'.repeat(64)}`
const digestB = `sha256:${'b'.repeat(64)}`
const profileDigest = `sha256:${'c'.repeat(64)}`
const policy = {
modelProfileDigest: profileDigest
}
const identity: ModelCallIdentity = {
callOperationId: 'call-1',
requestId: 'request-1',
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
promptSequence: 0,
roundIndex: 0,
provider: 'openai',
profile: 'work',
model: 'gpt-model',
protocol: 'responses-v1'
}
const prepared: PrepareModelCall = {
identity,
requestDigest: digestA,
...policy
}
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function createPath(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-model-calls-'))
directories.push(directory)
return join(directory, 'model-calls.sqlite')
}
function operation(callOperationId: string, roundIndex: number): PrepareModelCall {
return {
identity: {
...identity,
callOperationId,
promptOperationId: `prompt-${callOperationId}`,
roundIndex: 0
},
requestDigest: roundIndex % 2 === 0 ? digestA : digestB,
...policy
}
}
function completeCall(
store: ModelCallOperationStore,
callOperationId: string
): void {
store.beginDispatch(callOperationId)
store.complete(callOperationId, { status: 'completed' })
store.markResponseDelivered(callOperationId)
}
describe('ModelCallOperationStore', () => {
it('durably preserves prepared and dispatched crash states in WAL mode', async () => {
const path = await createPath()
const store = new ModelCallOperationStore(path)
store.prepare(prepared)
store.prepare({
identity: {
...identity,
callOperationId: 'call-2',
promptOperationId: 'prompt-2',
roundIndex: 0
},
requestDigest: digestB,
...policy
})
expect(store.beginDispatch('call-2').permitted).toBe(true)
store.close()
const check = new DatabaseSync(path)
expect(
(check.prepare('PRAGMA journal_mode').get() as { journal_mode: string })
.journal_mode
).toBe('wal')
check.close()
const reopened = new ModelCallOperationStore(path)
expect(reopened.get('call-1')?.status).toBe('prepared')
expect(reopened.get('call-1')?.dispatchMetadata).toBeUndefined()
expect(reopened.get('call-2')?.status).toBe('dispatched')
expect(reopened.beginDispatch('call-2').permitted).toBe(false)
expect(reopened.listUnresolved().map((record) => record.status)).toEqual([
'prepared',
'dispatched'
])
reopened.close()
})
it('idempotently prepares the same digest and conflicts on changed identity or digest', async () => {
const store = new ModelCallOperationStore(await createPath())
expect(store.prepare(prepared).created).toBe(true)
expect(store.prepare(prepared).created).toBe(false)
expect(() =>
store.prepare({ ...prepared, requestDigest: digestB })
).toThrow(ModelCallConflictError)
expect(() =>
store.prepare({
...prepared,
identity: { ...identity, model: 'different-model' }
})
).toThrow(ModelCallConflictError)
store.close()
})
it('grants only one dispatch permit across concurrent store instances', async () => {
const path = await createPath()
const first = new ModelCallOperationStore(path)
const second = new ModelCallOperationStore(path)
first.prepare(prepared)
const claims = await Promise.all([
Promise.resolve().then(() => first.beginDispatch('call-1')),
Promise.resolve().then(() => second.beginDispatch('call-1'))
])
expect(claims.filter((claim) => claim.permitted)).toHaveLength(1)
expect(claims.every((claim) => claim.record.status === 'dispatched')).toBe(
true
)
first.close()
second.close()
})
it('blocks replay after an outcome becomes unknown, including after reopen', async () => {
const path = await createPath()
const store = new ModelCallOperationStore(path)
store.prepare(prepared)
store.beginDispatch('call-1', {
providerIdempotencyKey: 'provider-key-1'
})
store.markOutcomeUnknown('call-1', {
status: 'outcome-unknown',
providerRequestId: 'provider-request-1',
reason: {
code: 'connection-lost',
retryable: false
}
})
expect(store.beginDispatch('call-1').permitted).toBe(false)
store.close()
const reopened = new ModelCallOperationStore(path)
expect(reopened.beginDispatch('call-1').permitted).toBe(false)
expect(reopened.listUnresolved()).toHaveLength(1)
expect(reopened.listUnresolved()[0]?.status).toBe('outcome-unknown')
reopened.close()
})
it('makes identical completion idempotent and rejects changed evidence', async () => {
const store = new ModelCallOperationStore(await createPath(), {
now: () => 42
})
store.prepare(prepared)
store.beginDispatch('call-1')
const evidence = {
status: 'completed' as const,
providerRequestId: 'request-provider-1',
providerResponseId: 'response-provider-1',
result: { finishReason: 'stop', outputDigest: digestB },
usage: { inputTokens: 10, outputTokens: 5 }
}
const completed = store.complete('call-1', evidence)
expect(store.complete('call-1', evidence)).toEqual(completed)
expect(() =>
store.complete('call-1', {
...evidence,
providerResponseId: 'different-response'
})
).toThrow(ModelCallConflictError)
expect(store.listUnresolved()).toEqual([])
store.close()
})
it('strictly rejects invalid transitions and different terminal outcomes', async () => {
const store = new ModelCallOperationStore(await createPath())
store.prepare(prepared)
expect(() =>
store.complete('call-1', { status: 'completed' })
).toThrow(ModelCallStateError)
store.beginDispatch('call-1')
store.failDefinitive('call-1', {
status: 'failed-definitive',
error: {
code: 'authentication',
retryable: false
}
})
expect(() =>
store.complete('call-1', { status: 'completed' })
).toThrow(ModelCallStateError)
expect(() =>
store.markOutcomeUnknown('call-1', {
status: 'outcome-unknown',
reason: {
code: 'uncertain',
retryable: false
}
})
).toThrow(ModelCallStateError)
store.close()
})
it('guards closed stores and makes close and dispose idempotent', async () => {
const store = new ModelCallOperationStore(await createPath())
store.prepare(prepared)
store.close()
expect(() => store.get('call-1')).toThrow(ModelCallStateError)
expect(() => store.prepare(prepared)).toThrow(ModelCallStateError)
expect(() => store.beginDispatch('call-1')).toThrow(ModelCallStateError)
expect(() => store.listUnresolved()).toThrow(ModelCallStateError)
expect(() => store.close()).not.toThrow()
expect(() => store.dispose()).not.toThrow()
})
it('fails safely when persisted JSON is corrupt', async () => {
const path = await createPath()
const store = new ModelCallOperationStore(path)
store.prepare(prepared)
store.close()
const corrupt = new DatabaseSync(path)
corrupt.exec(`
PRAGMA ignore_check_constraints = ON;
UPDATE model_call_operations SET identity_json = '{broken';
`)
corrupt.close()
const reopened = new ModelCallOperationStore(path)
expect(() => reopened.get('call-1')).toThrow(ModelCallStateError)
reopened.close()
})
it('enforces the hard record cap without dispatching prepared calls', async () => {
const store = new ModelCallOperationStore(await createPath(), {
maximumRecords: 2
})
store.prepare(operation('call-1', 0))
store.prepare(operation('call-2', 1))
expect(() => store.prepare(operation('call-3', 2))).toThrow(
ModelCallCapacityError
)
expect(store.get('call-1')?.status).toBe('prepared')
expect(store.get('call-2')?.status).toBe('prepared')
expect(store.get('call-3')).toBeUndefined()
store.close()
})
it('automatically prunes only definitive terminal rows for capacity', async () => {
const store = new ModelCallOperationStore(await createPath(), {
maximumRecords: 3
})
store.prepare(operation('call-1', 0))
completeCall(store, 'call-1')
store.prepare(operation('call-2', 1))
store.beginDispatch('call-2')
store.failDefinitive('call-2', {
status: 'failed-definitive',
error: { code: 'provider-rejected', retryable: false }
})
store.prepare(operation('call-3', 2))
store.beginDispatch('call-3')
store.prepare(operation('call-4', 3))
expect(store.get('call-1')).toBeUndefined()
expect(store.get('call-2')?.status).toBe('failed-definitive')
expect(store.get('call-3')?.status).toBe('dispatched')
expect(store.get('call-4')?.status).toBe('prepared')
store.prepare(operation('call-5', 4))
expect(store.get('call-2')).toBeUndefined()
expect(store.get('call-3')?.status).toBe('dispatched')
expect(store.get('call-4')?.status).toBe('prepared')
expect(store.get('call-5')?.status).toBe('prepared')
store.close()
})
it('never prunes dispatched or outcome-unknown rows for capacity', async () => {
const store = new ModelCallOperationStore(await createPath(), {
maximumRecords: 2
})
store.prepare(operation('call-1', 0))
store.beginDispatch('call-1')
store.prepare(operation('call-2', 1))
store.beginDispatch('call-2')
store.markOutcomeUnknown('call-2', {
status: 'outcome-unknown',
reason: { code: 'transport-uncertain', retryable: false }
})
expect(() => store.prepare(operation('call-3', 2))).toThrow(
ModelCallCapacityError
)
expect(store.get('call-1')?.status).toBe('dispatched')
expect(store.get('call-2')?.status).toBe('outcome-unknown')
store.close()
})
it('reapplies capacity and terminal pruning after reopen', async () => {
const path = await createPath()
const first = new ModelCallOperationStore(path, { maximumRecords: 3 })
for (let index = 1; index <= 3; index += 1) {
first.prepare(operation(`call-${index}`, index))
}
completeCall(first, 'call-1')
completeCall(first, 'call-2')
first.close()
const reopened = new ModelCallOperationStore(path, { maximumRecords: 2 })
reopened.prepare(operation('call-4', 4))
expect(reopened.get('call-1')).toBeUndefined()
expect(reopened.get('call-2')).toBeUndefined()
expect(reopened.get('call-3')?.status).toBe('prepared')
expect(reopened.get('call-4')?.status).toBe('prepared')
reopened.close()
})
it('prunes terminal records deterministically with bounded options', async () => {
let now = 0
const store = new ModelCallOperationStore(await createPath(), {
now: () => ++now
})
store.prepare(operation('call-1', 0))
completeCall(store, 'call-1')
store.prepare(operation('call-2', 1))
completeCall(store, 'call-2')
store.prepare(operation('call-3', 2))
store.beginDispatch('call-3')
store.markOutcomeUnknown('call-3', {
status: 'outcome-unknown',
reason: { code: 'transport-uncertain', retryable: false }
})
expect(store.pruneTerminal(1, 6)).toBe(1)
expect(store.get('call-1')).toBeUndefined()
expect(store.get('call-2')?.status).toBe('completed')
expect(store.get('call-3')?.status).toBe('outcome-unknown')
expect(() => store.pruneTerminal(1_001)).toThrow()
store.close()
})
it('atomically grants one strictly monotonic next round across stores', async () => {
const path = await createPath()
const first = new ModelCallOperationStore(path)
const second = new ModelCallOperationStore(path)
first.prepare(prepared)
completeCall(first, 'call-1')
const next = {
identity: {
...identity,
callOperationId: 'call-round-1-a',
roundIndex: 1
},
requestDigest: digestB,
...policy
}
const competing = {
...next,
identity: {
...next.identity,
callOperationId: 'call-round-1-b'
}
}
const results = await Promise.allSettled([
Promise.resolve().then(() => first.prepare(next)),
Promise.resolve().then(() => second.prepare(competing))
])
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1)
first.close()
second.close()
})
it('retains replay authority after delivered records are pruned and reopened', async () => {
const path = await createPath()
const first = new ModelCallOperationStore(path)
first.prepare(prepared)
completeCall(first, 'call-1')
first.finalizePrompt({
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
promptSequence: 0
})
expect(first.pruneTerminal(1)).toBe(1)
expect(first.get('call-1')).toBeUndefined()
first.close()
const reopened = new ModelCallOperationStore(path)
expect(() => reopened.prepare(prepared)).toThrow(ModelCallConflictError)
expect(
reopened.prepare({
...prepared,
identity: {
...prepared.identity,
callOperationId: 'call-next-prompt',
requestId: 'request-2',
promptOperationId: 'prompt-2',
promptSequence: 1,
roundIndex: 0
},
requestDigest: digestB
}).created
).toBe(true)
expect(() =>
reopened.prepare({
...prepared,
identity: {
...prepared.identity,
callOperationId: 'call-skipped-prompt',
requestId: 'request-3',
promptOperationId: 'prompt-3',
promptSequence: 3,
roundIndex: 0
}
})
).toThrow(ModelCallConflictError)
reopened.close()
})
it('compacts finalized prompt authorities below the hard authority cap', async () => {
const store = new ModelCallOperationStore(await createPath(), {
maximumAuthorities: 1
})
for (let promptSequence = 0; promptSequence < 3; promptSequence += 1) {
const callOperationId = `call-prompt-${promptSequence}`
const promptOperationId = `prompt-${promptSequence}`
store.prepare({
...prepared,
identity: {
...identity,
callOperationId,
requestId: `request-${promptSequence}`,
promptOperationId,
promptSequence,
roundIndex: 0
}
})
completeCall(store, callOperationId)
store.finalizePrompt({
bindingId: identity.bindingId,
promptOperationId,
promptSequence
})
}
expect(() =>
store.prepare({
...prepared,
identity: {
...identity,
callOperationId: 'replayed-finalized-prompt',
promptOperationId: 'prompt-1',
promptSequence: 1,
roundIndex: 0
}
})
).toThrow(ModelCallConflictError)
store.close()
})
it('rejects skipped, lower, and profile-swapped rounds', async () => {
const store = new ModelCallOperationStore(await createPath())
store.prepare(prepared)
completeCall(store, 'call-1')
expect(() =>
store.prepare({
...prepared,
identity: {
...identity,
callOperationId: 'call-skipped',
roundIndex: 2
}
})
).toThrow(ModelCallStateError)
expect(() =>
store.prepare({
...prepared,
identity: {
...identity,
callOperationId: 'call-profile-swap',
roundIndex: 1,
profile: 'other'
},
modelProfileDigest: digestB
})
).toThrow(ModelCallConflictError)
expect(() =>
store.prepare({
...prepared,
identity: {
...identity,
callOperationId: 'call-lower',
roundIndex: 0
}
})
).toThrow(ModelCallConflictError)
store.close()
})
it('permits sequential rounds without a call-count or cumulative-token quota', async () => {
const store = new ModelCallOperationStore(await createPath())
for (let roundIndex = 0; roundIndex < 12; roundIndex += 1) {
const callOperationId = `call-unbounded-${roundIndex}`
store.prepare({
...prepared,
identity: {
...identity,
callOperationId,
requestId: `request-${roundIndex}`,
roundIndex
}
})
completeCall(store, callOperationId)
}
expect(store.get('call-unbounded-11')).toMatchObject({
status: 'completed',
responseDeliveredAt: expect.any(Number)
})
store.close()
})
it('requires explicit response delivery before the next round', async () => {
const store = new ModelCallOperationStore(await createPath(), {
now: () => 42
})
store.prepare(prepared)
store.beginDispatch('call-1')
store.complete('call-1', { status: 'completed' })
const next = {
...prepared,
identity: {
...identity,
callOperationId: 'call-2',
roundIndex: 1
},
requestDigest: digestB
}
expect(() => store.prepare(next)).toThrow(ModelCallStateError)
expect(store.pruneTerminal(1)).toBe(0)
const pending = store.listStartupRecords().records
expect(pending).toMatchObject([{ status: 'completed' }])
expect(pending[0]).not.toHaveProperty('responseDeliveredAt')
const delivered = store.markResponseDelivered('call-1')
expect(delivered.responseDeliveredAt).toBe(42)
expect(store.markResponseDelivered('call-1')).toEqual(delivered)
expect(store.prepare(next).created).toBe(true)
store.close()
})
it('paginates startup and status-filtered records without starvation', async () => {
let now = 0
const store = new ModelCallOperationStore(await createPath(), {
now: () => ++now
})
for (const id of ['call-a', 'call-b', 'call-c']) {
store.prepare(operation(id, 0))
store.beginDispatch(id)
}
store.complete('call-b', { status: 'completed' })
completeCall(store, 'call-c')
const first = store.listStartupRecords({ limit: 1 })
expect(first.records).toHaveLength(1)
expect(first.nextCursor).toBeDefined()
const second = store.listStartupRecords({
limit: 1,
cursor: first.nextCursor
})
expect([
...first.records,
...second.records
].map((record) => record.identity.callOperationId)).toEqual([
'call-a',
'call-b'
])
expect(
store.list({
statuses: ['completed'],
delivery: 'pending-response',
limit: 10
}).records.map((record) => record.identity.callOperationId)
).toEqual(['call-b'])
store.close()
})
it('includes prepared records in startup reconciliation', async () => {
const store = new ModelCallOperationStore(await createPath())
store.prepare(operation('call-prepared', 0))
expect(store.listStartupRecords().records).toMatchObject([
{
identity: { callOperationId: 'call-prepared' },
status: 'prepared'
}
])
store.close()
})
it('uses the verified private SQLite path opener before initialization', async () => {
const path = await createPath()
const store = new ModelCallOperationStore(path)
store.close()
const databaseStats = await stat(path)
expect(databaseStats.isFile()).toBe(true)
if (process.platform !== 'win32') {
expect(databaseStats.mode & 0o777).toBe(0o600)
}
expect(() => new ModelCallOperationStore(':memory:')).toThrow(
'SQLite database must be a filesystem path'
)
const invalidPath = join(
join(path, '..'),
'invalid-capacity-must-not-exist.sqlite'
)
expect(
() =>
new ModelCallOperationStore(invalidPath, {
maximumRecords: 0
})
).toThrow()
await expect(stat(invalidPath)).rejects.toMatchObject({ code: 'ENOENT' })
})
})
File diff suppressed because it is too large Load Diff
+393 -38
View File
@@ -464,6 +464,59 @@ describe('ModelAgentRuntime', () => {
expect(fetcher).toHaveBeenCalledOnce()
})
it('merges custom headers and body fields into a direct model request without overriding runtime fields', async () => {
const fetcher = vi.fn<typeof fetch>(async (_input, init) => {
const body = JSON.parse(init?.body as string) as {
messages: Array<{ content: string }>
}
const marker =
/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec(
body.messages[0]?.content ?? ''
)?.[0]
return Response.json({
choices: [
{
message: {
role: 'assistant',
content: marker
}
}
]
})
})
const runtime = new ModelAgentRuntime({
apiKey: 'runtime-key',
baseUrl: 'https://model.example/v1',
model: 'runtime-model',
protocol: 'openai-chat-completions',
authentication: 'api-key',
requestHeaders: {
'x-tenant-id': 'tenant-a',
authorization: 'Bearer custom-key'
},
requestBody: {
temperature: 0.2,
model: 'custom-model',
stream: true
},
fetcher
})
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
const init = fetcher.mock.calls[0]?.[1]
const headers = new Headers(init?.headers)
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
expect(headers.get('x-tenant-id')).toBe('tenant-a')
expect(headers.get('authorization')).toBe('Bearer runtime-key')
expect(body).toMatchObject({
model: 'runtime-model',
stream: false,
temperature: 0.2
})
})
it('uses the Anthropic messages endpoint and streams text deltas', async () => {
const fetcher = vi.fn<typeof fetch>(async () => {
return new Response(createEventStream('真实模型回答', '先分析问题'), {
@@ -569,6 +622,8 @@ describe('ModelAgentRuntime', () => {
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
requestHeaders: { 'x-model-route': 'summary-test' },
requestBody: { temperature: 0.1 },
fetcher,
contextCompression: {
settings: {
@@ -622,6 +677,14 @@ describe('ModelAgentRuntime', () => {
}
expect(fetcher).toHaveBeenCalledTimes(2)
for (const [, init] of fetcher.mock.calls) {
expect(new Headers(init?.headers).get('x-model-route')).toBe(
'summary-test'
)
expect(JSON.parse(String(init?.body))).toMatchObject({
temperature: 0.1
})
}
const summaryBody = JSON.parse(
fetcher.mock.calls[0]![1]!.body as string
) as { max_tokens: number; system: string; messages: unknown[] }
@@ -1367,6 +1430,216 @@ describe('ModelAgentRuntime', () => {
await expect(stream.next()).rejects.toThrow('upstream unavailable')
})
it('retries a nested ECONNRESET before streaming output', async () => {
vi.useFakeTimers()
try {
const connectionReset = Object.assign(
new Error(
'Client network socket disconnected before secure TLS connection was established'
),
{ code: 'ECONNRESET', port: 443 }
)
const fetcher = vi
.fn<typeof fetch>()
.mockRejectedValueOnce(
new TypeError('fetch failed', { cause: connectionReset })
)
.mockResolvedValueOnce(
new Response(createEventStream('重试后成功'), {
status: 200,
headers: { 'content-type': 'text/event-stream' }
})
)
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher
})
const events: RuntimeEvent[] = []
const consume = async (): Promise<void> => {
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
events.push(event)
}
}
const completed = consume()
await vi.runAllTimersAsync()
await completed
expect(fetcher).toHaveBeenCalledTimes(2)
expect(events).toContainEqual(
expect.objectContaining({
type: 'status',
message: '模型网络请求失败,正在重试(1/3)'
})
)
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
delta: '重试后成功'
})
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
} finally {
vi.useRealTimers()
}
})
it('does not retry a transient stream failure after publishing output', async () => {
const connectionReset = Object.assign(
new Error('socket disconnected'),
{ code: 'ECONNRESET' }
)
let sentOutput = false
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (sentOutput) {
controller.error(connectionReset)
return
}
sentOutput = true
controller.enqueue(
new TextEncoder().encode(
[
'event: content_block_delta',
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"已经显示"}}',
'',
''
].join('\n')
)
)
}
})
const fetcher = vi.fn<typeof fetch>(async () =>
new Response(body, {
status: 200,
headers: { 'content-type': 'text/event-stream' }
})
)
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher
})
const events: RuntimeEvent[] = []
const consume = async (): Promise<void> => {
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
events.push(event)
}
}
await expect(consume()).rejects.toThrow('socket disconnected')
expect(fetcher).toHaveBeenCalledOnce()
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
delta: '已经显示'
})
)
expect(events).not.toContainEqual(
expect.objectContaining({
message: expect.stringContaining('正在重试')
})
)
})
it('does not retry a reset whose dispatch outcome is unknown', async () => {
const fetcher = vi.fn<typeof fetch>(async () => {
throw Object.assign(new Error('socket disconnected'), {
code: 'ECONNRESET'
})
})
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher
})
const events: RuntimeEvent[] = []
const consume = async (): Promise<void> => {
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
events.push(event)
}
}
await expect(consume()).rejects.toThrow('socket disconnected')
expect(fetcher).toHaveBeenCalledOnce()
expect(events).not.toContainEqual(
expect.objectContaining({
message: expect.stringContaining('正在重试')
})
)
})
it('cancels immediately during model retry backoff', async () => {
const controller = new AbortController()
const fetcher = vi.fn<typeof fetch>(async () => {
throw Object.assign(new Error('connect timeout'), {
code: 'UND_ERR_CONNECT_TIMEOUT'
})
})
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
model: 'sonnet-5',
protocol: 'anthropic-messages',
authentication: 'api-key',
fetcher
})
const stream = runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
controller.signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status', message: 'sonnet-5 正在思考' }
})
await expect(stream.next()).resolves.toMatchObject({
value: {
type: 'status',
message: '模型网络请求失败,正在重试(1/3)'
}
})
const pendingRetry = stream.next()
controller.abort(new Error('用户取消'))
await expect(pendingRetry).rejects.toThrow('用户取消')
expect(fetcher).toHaveBeenCalledOnce()
})
it('parses CRLF event separators split across response chunks', async () => {
const payload = createEventStream('split CRLF').replaceAll('\n', '\r\n')
const splitAt = payload.indexOf('\r\n\r\n') + 3
@@ -1418,6 +1691,16 @@ describe('ModelAgentRuntime', () => {
it('aborts a model request that exceeds the runtime timeout', async () => {
vi.useFakeTimers()
try {
const fetcher = vi.fn<typeof fetch>(
async (_input, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true }
)
})
)
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
baseUrl: 'https://bigtoken.ai',
@@ -1425,35 +1708,27 @@ describe('ModelAgentRuntime', () => {
protocol: 'anthropic-messages',
authentication: 'api-key',
requestTimeoutMs: 50,
fetcher: vi.fn<typeof fetch>(
async (_input, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true }
)
})
)
fetcher
})
const stream = runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
const result = stream.next()
const assertion = expect(result).rejects.toThrow(
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
void _event
}
}
const assertion = expect(consume()).rejects.toThrow(
'模型接口请求超时'
)
await vi.advanceTimersByTimeAsync(50)
await vi.runAllTimersAsync()
await assertion
expect(fetcher).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
@@ -1489,23 +1764,23 @@ describe('ModelAgentRuntime', () => {
)
})
})
const stream = runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
const result = stream.next()
const assertion = expect(result).rejects.toThrow(
const consume = async (): Promise<void> => {
for await (const _event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)) {
void _event
}
}
const assertion = expect(consume()).rejects.toThrow(
'模型接口请求超时'
)
await vi.advanceTimersByTimeAsync(50)
await vi.runAllTimersAsync()
await assertion
expect(responseSignal?.aborted).toBe(true)
} finally {
@@ -1693,6 +1968,75 @@ describe('ModelAgentRuntime', () => {
expect(toolProvider.listTools).not.toHaveBeenCalled()
})
it('retries transient network failures for direct-model tool rounds', async () => {
vi.useFakeTimers()
try {
const fetcher = vi
.fn<typeof fetch>()
.mockRejectedValueOnce(
Object.assign(new Error('connect timeout'), {
code: 'UND_ERR_CONNECT_TIMEOUT'
})
)
.mockResolvedValueOnce(
Response.json({
choices: [
{
message: {
role: 'assistant',
content: '工具轮次重试成功'
}
}
]
})
)
const runtime = new ModelAgentRuntime({
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider: createToolProvider()
})
const events: RuntimeEvent[] = []
const consume = async (): Promise<void> => {
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: '使用工具能力回答',
workMode: 'execute'
},
new AbortController().signal,
async () => 'once'
)) {
events.push(event)
}
}
const completed = consume()
await vi.runAllTimersAsync()
await completed
expect(fetcher).toHaveBeenCalledTimes(2)
expect(events).toContainEqual(
expect.objectContaining({
type: 'status',
message: '模型网络请求失败,正在重试(1/3)'
})
)
expect(events).toContainEqual(
expect.objectContaining({
type: 'text',
delta: '工具轮次重试成功'
})
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
} finally {
vi.useRealTimers()
}
})
it('streams OpenAI-compatible reasoning deltas before the answer', async () => {
const stream = [
`data: ${JSON.stringify({
@@ -4924,6 +5268,11 @@ describe('ModelAgentRuntime', () => {
protocol: 'openai-images-generations',
authentication: 'api-key',
imageGenerationQuality: 'medium',
requestHeaders: { 'x-image-route': 'internal' },
requestBody: {
background: 'transparent',
quality: 'high'
},
fetcher
})
@@ -4941,8 +5290,14 @@ describe('ModelAgentRuntime', () => {
model: 'gpt-image-2',
n: 1,
quality: 'medium',
background: 'transparent',
response_format: 'b64_json'
})
expect(
new Headers(fetcher.mock.calls[0]?.[1]?.headers).get(
'x-image-route'
)
).toBe('internal')
})
it('generates a bounded image through the BigToken-compatible endpoint', async () => {
+426 -192
View File
@@ -6,9 +6,15 @@ import type {
ImageGenerationQuality,
ModelAuthentication,
ModelProtocol,
ModelRequestBody,
ModelRequestHeaders,
RuntimeConversationCompactInput
} from '../../shared/contracts'
import { defaultAnthropicMaximumOutputTokens } from '../../shared/contracts'
import {
defaultAnthropicMaximumOutputTokens,
mergeModelRequestBody,
mergeModelRequestHeaders
} from '../../shared/contracts'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import type { BrowserToolService } from '../browser/browser-model-tools'
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
@@ -188,6 +194,16 @@ const maxStreamBlockBytes = 1024 * 1024
const maxToolArgumentBytes = 128 * 1024
const maxToolContextBytes = 1024 * 1024
const defaultModelRequestTimeoutMs = 10 * 60_000
const modelRequestRetryDelaysMs = [500, 1_000, 2_000] as const
const retryablePreDispatchNetworkErrorCodes = new Set([
'EAI_AGAIN',
'ECONNREFUSED',
'EHOSTDOWN',
'EHOSTUNREACH',
'ENETDOWN',
'ENETUNREACH',
'UND_ERR_CONNECT_TIMEOUT'
])
const noModelTools: ModelToolProviderLike = {
listTools: async () => [],
@@ -224,6 +240,8 @@ export type ModelRuntimeOptions = {
model: string
protocol: ModelProtocol
authentication: ModelAuthentication
requestHeaders?: ModelRequestHeaders
requestBody?: ModelRequestBody
supportsImageInput?: boolean
imageGenerationQuality?: ImageGenerationQuality
skillInstructions?: string
@@ -247,6 +265,8 @@ export type ModelRuntimeOptions = {
authentication: ModelAuthentication
contextWindowTokens?: number
maximumOutputTokens?: number
requestHeaders?: ModelRequestHeaders
requestBody?: ModelRequestBody
}
}
maximumOutputTokens?: number
@@ -573,6 +593,72 @@ function normalizeRequestError(
throw error
}
function isRetryableModelRequestError(error: unknown): boolean {
const seen = new Set<unknown>()
let current = error
for (let depth = 0; depth < 8; depth += 1) {
if (
current === null ||
(typeof current !== 'object' &&
typeof current !== 'function') ||
seen.has(current)
) {
break
}
seen.add(current)
const record = current as Record<string, unknown>
if (
typeof record.code === 'string' &&
retryablePreDispatchNetworkErrorCodes.has(
record.code.toLocaleUpperCase()
)
) {
return true
}
if (typeof record.message === 'string') {
if (
record.code === 'ECONNRESET' &&
/before secure TLS connection was established/iu.test(
record.message
)
) {
return true
}
}
current = record.cause
}
return false
}
function waitForModelRetry(
delayMs: number,
signal: AbortSignal
): Promise<void> {
signal.throwIfAborted()
return new Promise<void>((resolve, reject) => {
let settled = false
const finish = (callback: () => void): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
signal.removeEventListener('abort', abort)
callback()
}
const abort = (): void => {
finish(() => reject(signal.reason))
}
const timeout = setTimeout(() => {
finish(resolve)
}, delayMs)
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) {
abort()
}
})
}
function parseGeneratedImage(value: unknown): {
data: string
mimeType: 'image/png' | 'image/jpeg' | 'image/webp'
@@ -1678,35 +1764,47 @@ export class ModelAgentRuntime implements AgentRuntime {
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'content-type': 'application/json'
}
const headers = mergeModelRequestHeaders(
this.options.requestHeaders ?? {},
{
'content-type': 'application/json'
}
)
if (
this.options.authentication === 'api-key' &&
this.options.apiKey
) {
if (this.options.protocol === 'anthropic-messages') {
headers['anthropic-version'] = '2023-06-01'
headers['x-api-key'] = this.options.apiKey
headers.set('anthropic-version', '2023-06-01')
headers.set('x-api-key', this.options.apiKey)
} else {
headers.authorization = `Bearer ${this.options.apiKey}`
headers.set('authorization', `Bearer ${this.options.apiKey}`)
}
} else if (this.options.protocol === 'anthropic-messages') {
headers['anthropic-version'] = '2023-06-01'
headers.set('anthropic-version', '2023-06-01')
}
return headers
return Object.fromEntries(headers.entries())
}
private createRequestBody(
runtimeBody: Record<string, unknown>
): Record<string, unknown> {
return mergeModelRequestBody(
this.options.requestBody ?? {},
runtimeBody
)
}
private createImageGenerationRequest(
prompt: string
): Record<string, unknown> {
return {
return this.createRequestBody({
model: this.options.model,
prompt: prompt.slice(0, 100_000),
n: 1,
quality: this.options.imageGenerationQuality ?? 'auto',
response_format: 'b64_json'
}
})
}
private async fetchWithTimeout(
@@ -1734,6 +1832,71 @@ export class ModelAgentRuntime implements AgentRuntime {
}
}
private async *retryModelStream<T>(
createAttempt: () => AsyncGenerator<RuntimeEvent, T, void>,
signal: AbortSignal,
requestId: string
): AsyncGenerator<RuntimeEvent, T, void> {
for (
let retryCount = 0;
retryCount <= modelRequestRetryDelaysMs.length;
retryCount += 1
) {
signal.throwIfAborted()
const attempt = createAttempt()
let step: IteratorResult<RuntimeEvent, T> | undefined
let outputPublished = false
let failed = false
let failure: unknown
try {
step = await attempt.next()
while (!step.done) {
if (
step.value.type === 'text' ||
step.value.type === 'reasoning'
) {
outputPublished = true
}
yield step.value
step = await attempt.next()
}
} catch (error) {
failed = true
failure = error
} finally {
if (!step?.done) {
await attempt.return(undefined as T).catch(() => undefined)
}
}
if (!failed) {
if (!step?.done) {
throw new Error('模型请求流未返回最终结果')
}
return step.value
}
if (
outputPublished ||
signal.aborted ||
retryCount >= modelRequestRetryDelaysMs.length ||
!isRetryableModelRequestError(failure)
) {
throw failure
}
yield {
requestId,
type: 'status',
message:
`模型网络请求失败,正在重试(${retryCount + 1}/` +
`${modelRequestRetryDelaysMs.length})`
}
await waitForModelRetry(
modelRequestRetryDelaysMs[retryCount]!,
signal
)
}
throw new Error('模型请求重试状态无效')
}
async getStatus(): Promise<AgentRuntimeStatus> {
const imageGeneration = this.capability === 'image-generation'
return {
@@ -1772,19 +1935,21 @@ export class ModelAgentRuntime implements AgentRuntime {
body: JSON.stringify(
imageGeneration
? this.createImageGenerationRequest(prompt)
: this.options.protocol === 'openai-responses'
? {
model: this.options.model,
max_output_tokens: 64,
stream: false,
input: prompt
}
: {
model: this.options.model,
max_tokens: 64,
stream: false,
messages: [{ role: 'user', content: prompt }]
}
: this.createRequestBody(
this.options.protocol === 'openai-responses'
? {
model: this.options.model,
max_output_tokens: 64,
stream: false,
input: prompt
}
: {
model: this.options.model,
max_tokens: 64,
stream: false,
messages: [{ role: 'user', content: prompt }]
}
)
)
})
const responseText = await readBoundedResponseText(response, {
@@ -1972,7 +2137,9 @@ export class ModelAgentRuntime implements AgentRuntime {
'openai-images-generations'
>,
authentication: this.options.authentication,
maximumOutputTokens: this.options.maximumOutputTokens
maximumOutputTokens: this.options.maximumOutputTokens,
requestHeaders: this.options.requestHeaders,
requestBody: this.options.requestBody
}
const summaryRuntime = new ModelAgentRuntime({
...summaryModel,
@@ -2577,6 +2744,29 @@ export class ModelAgentRuntime implements AgentRuntime {
anthropic: boolean,
signal: AbortSignal,
requestId: string
): AsyncGenerator<RuntimeEvent, ModelToolResponse, void> {
return yield* this.retryModelStream(
() =>
this.requestToolModelOnce(
messages,
tools,
system,
anthropic,
signal,
requestId
),
signal,
requestId
)
}
private async *requestToolModelOnce(
messages: Array<Record<string, unknown>>,
tools: ModelToolDefinition[],
system: string,
anthropic: boolean,
signal: AbortSignal,
requestId: string
): AsyncGenerator<RuntimeEvent, ModelToolResponse, void> {
const responses = this.options.protocol === 'openai-responses'
const streamOpenAIChat = !responses && !anthropic
@@ -2603,38 +2793,40 @@ export class ModelAgentRuntime implements AgentRuntime {
}
}))
const body = JSON.stringify(
responses
? {
model: this.options.model,
stream: true,
instructions: system,
input: messages,
...(providerTools.length > 0
? { tools: providerTools }
: {})
}
: anthropic
this.createRequestBody(
responses
? {
model: this.options.model,
max_tokens: this.anthropicProtocolMaximumTokens,
stream: true,
system,
messages,
...(providerTools.length > 0
? { tools: providerTools }
: {})
}
: {
model: this.options.model,
stream: true,
stream_options: {
include_usage: true
},
messages,
instructions: system,
input: messages,
...(providerTools.length > 0
? { tools: providerTools }
: {})
}
: anthropic
? {
model: this.options.model,
max_tokens: this.anthropicProtocolMaximumTokens,
stream: true,
system,
messages,
...(providerTools.length > 0
? { tools: providerTools }
: {})
}
: {
model: this.options.model,
stream: true,
stream_options: {
include_usage: true
},
messages,
...(providerTools.length > 0
? { tools: providerTools }
: {})
}
)
)
if (Buffer.byteLength(body) > 2 * 1024 * 1024) {
throw new Error('模型工具请求上下文超过 2MB 安全限制')
@@ -2867,6 +3059,143 @@ export class ModelAgentRuntime implements AgentRuntime {
}
}
private async *requestTextModelOnce(
request: AgentExecutionRequest,
signal: AbortSignal,
system: string,
messages:
| AnthropicApiMessage[]
| Array<Record<string, unknown>>
): AsyncGenerator<
RuntimeEvent,
{ answer: string; usage: ModelUsageAccumulator },
void
> {
const anthropic = this.options.protocol === 'anthropic-messages'
const responses = this.options.protocol === 'openai-responses'
const modelRequest = await this.fetchWithTimeout(
this.getEndpoint(),
{
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(
this.createRequestBody(
responses
? {
model: this.options.model,
stream: true,
instructions: system,
input: messages
}
: anthropic
? {
model: this.options.model,
max_tokens: this.anthropicProtocolMaximumTokens,
stream: true,
system,
messages
}
: {
model: this.options.model,
stream: true,
stream_options: {
include_usage: true
},
messages
}
)
)
},
signal
)
const response = modelRequest.response
let answer = ''
const usage = {
reported: false
} satisfies ModelUsageAccumulator
try {
if (!response.ok) {
const responseText = await readBoundedResponseText(response, {
maxBytes: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
let detail: string | undefined
try {
detail = getErrorMessage(
responseText.trim()
? JSON.parse(responseText)
: undefined
)
} catch {
detail = undefined
}
throw new Error(
detail ?? `模型接口请求失败(HTTP ${response.status})`
)
}
let receivedStop = false
for await (const block of readBoundedSseBlocks(response)) {
const parsed = parseStreamBlock(block, this.options.protocol)
if (parsed.usage) {
applyUsageUpdate(usage, parsed.usage)
}
if (parsed.reasoningDelta) {
yield {
requestId: request.requestId,
type: 'reasoning',
delta: parsed.reasoningDelta
}
}
const { delta } = parsed
if (delta) {
answer += delta
yield {
requestId: request.requestId,
type: 'text',
delta
}
}
if (parsed.stopped) {
receivedStop = true
break
}
}
if (!receivedStop) {
throw new Error('模型接口流式响应意外中断')
}
if (!answer) {
throw new Error('模型接口返回了空内容')
}
return { answer, usage }
} catch (error) {
return normalizeRequestError(error, modelRequest.timedOut())
} finally {
modelRequest.clear()
}
}
private async *requestTextModel(
request: AgentExecutionRequest,
signal: AbortSignal,
system: string,
messages:
| AnthropicApiMessage[]
| Array<Record<string, unknown>>
): AsyncGenerator<
RuntimeEvent,
{ answer: string; usage: ModelUsageAccumulator },
void
> {
return yield* this.retryModelStream(
() =>
this.requestTextModelOnce(request, signal, system, messages),
signal,
request.requestId
)
}
private async *compactAgentRunContext(
request: AgentExecutionRequest,
baseMessages: readonly Record<string, unknown>[],
@@ -3700,152 +4029,57 @@ export class ModelAgentRuntime implements AgentRuntime {
[],
anthropic || responses ? system : ''
)
const modelRequest = await this.fetchWithTimeout(
this.getEndpoint(),
const modelResponse = yield* this.requestTextModel(
request,
signal,
system,
messages
)
const { answer, usage } = modelResponse
const completedHistory = [
...(identifiedRequest.history ??
this.conversations.get(request.conversationId) ??
[]),
{
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(
responses
? {
model: this.options.model,
stream: true,
instructions: system,
input: messages
}
: anthropic
? {
model: this.options.model,
max_tokens: this.anthropicProtocolMaximumTokens,
stream: true,
system,
messages
}
: {
model: this.options.model,
stream: true,
stream_options: {
include_usage: true
},
messages
}
)
id: request.currentUserMessageId,
role: 'user',
content: request.prompt
},
{
id: request.currentAssistantMessageId,
role: 'assistant',
content: answer
}
] satisfies ConversationMessage[]
this.saveConversation(request.conversationId, completedHistory)
const usageEvent = createUsageEvent(
request.requestId,
anthropic ? 'anthropic' : 'openai',
this.options.model,
usage
)
const contextMetricsEvent = this.createContextMetricsEvent(
request.requestId,
usage,
estimatedRequestTokens + estimateTextTokens(answer)
)
if (contextMetricsEvent) {
yield contextMetricsEvent
}
if (usageEvent) {
yield usageEvent
}
yield* this.finalizeConversationContext(
identifiedRequest,
completedHistory,
contextMetricsEvent?.contextTokens ??
estimatedRequestTokens + estimateTextTokens(answer),
signal
)
const response = modelRequest.response
let answer = ''
const usage = {
reported: false
} satisfies ModelUsageAccumulator
try {
if (!response.ok) {
const responseText = await readBoundedResponseText(response, {
maxBytes: 128 * 1024,
missingBodyMessage: '模型接口未返回响应内容',
tooLargeMessage: '模型接口响应超过安全限制'
})
let detail: string | undefined
try {
detail = getErrorMessage(
responseText.trim()
? JSON.parse(responseText)
: undefined
)
} catch {
detail = undefined
}
throw new Error(
detail ?? `模型接口请求失败(HTTP ${response.status})`
)
}
let receivedStop = false
for await (const block of readBoundedSseBlocks(response)) {
const parsed = parseStreamBlock(block, this.options.protocol)
if (parsed.usage) {
applyUsageUpdate(usage, parsed.usage)
}
if (parsed.reasoningDelta) {
yield {
requestId: request.requestId,
type: 'reasoning',
delta: parsed.reasoningDelta
}
}
const { delta } = parsed
if (delta) {
answer += delta
yield {
requestId: request.requestId,
type: 'text',
delta
}
}
if (parsed.stopped) {
receivedStop = true
break
}
}
if (!receivedStop) {
throw new Error('模型接口流式响应意外中断')
}
if (!answer) {
throw new Error('模型接口返回了空内容')
}
const completedHistory = [
...(identifiedRequest.history ??
this.conversations.get(request.conversationId) ??
[]),
{
id: request.currentUserMessageId,
role: 'user',
content: request.prompt
},
{
id: request.currentAssistantMessageId,
role: 'assistant',
content: answer
}
] satisfies ConversationMessage[]
this.saveConversation(request.conversationId, completedHistory)
const usageEvent = createUsageEvent(
request.requestId,
anthropic ? 'anthropic' : 'openai',
this.options.model,
usage
)
const contextMetricsEvent = this.createContextMetricsEvent(
request.requestId,
usage,
estimatedRequestTokens + estimateTextTokens(answer)
)
if (contextMetricsEvent) {
yield contextMetricsEvent
}
if (usageEvent) {
yield usageEvent
}
yield* this.finalizeConversationContext(
identifiedRequest,
completedHistory,
contextMetricsEvent?.contextTokens ??
estimatedRequestTokens + estimateTextTokens(answer),
signal
)
yield {
requestId: request.requestId,
type: 'done'
}
} catch (error) {
return normalizeRequestError(error, modelRequest.timedOut())
} finally {
modelRequest.clear()
yield {
requestId: request.requestId,
type: 'done'
}
}
+5 -1
View File
@@ -8,7 +8,8 @@ import {
magicNoteWriteToolNames,
maximumScopedToolCount,
scopedDataToolByName,
scopedReadToolNames
scopedReadToolNames,
type ScopedDataToolName
} from '../../shared/scoped-data-tools'
import type { ResolvedMcpServer } from '../capabilities/capability-service'
import { createMcpTransport } from '../capabilities/mcp-client-transport'
@@ -639,6 +640,9 @@ export class ModelToolProvider implements ModelToolProviderLike {
const available = new Set(
this.knowledgeGateway.getAvailableToolNames(
context.knowledgeCapabilityToken
).filter(
(name): name is ScopedDataToolName =>
scopedDataToolByName.has(name as ScopedDataToolName)
)
)
const tools = [...available].flatMap(
+255 -43
View File
@@ -86,6 +86,7 @@ function dependencies(
deps: Partial<OpenCodeRuntimeDependencies>
spawnMock: ReturnType<typeof vi.fn>
detectBinary: ReturnType<typeof vi.fn>
validateBinary: ReturnType<typeof vi.fn>
createClient: ReturnType<typeof vi.fn>
checkServerHealth: ReturnType<typeof vi.fn>
} {
@@ -94,12 +95,17 @@ function dependencies(
path: 'opencode',
detail: 'OpenCode CLI 已就绪'
})
const validateBinary = vi.fn().mockResolvedValue({
path: 'opencode',
detail: 'OpenCode CLI 已就绪'
})
const createClient = vi.fn(() => fakeClient())
const checkServerHealth = vi.fn().mockResolvedValue(true)
return {
deps: {
spawn: spawnMock as unknown as typeof spawn,
detectBinary,
validateBinary,
createClient: createClient as unknown as typeof createOpencodeClient,
checkServerHealth,
platform: 'linux',
@@ -108,6 +114,7 @@ function dependencies(
},
spawnMock,
detectBinary,
validateBinary,
createClient,
checkServerHealth
}
@@ -290,6 +297,126 @@ async function collectRun(
}
describe('OpenCodeRuntime embedded launcher', () => {
it('checks embedded availability without starting OpenCode', async () => {
const child = fakeChild()
const {
deps,
spawnMock,
detectBinary,
validateBinary,
createClient,
checkServerHealth
} = dependencies(child)
const runtime = new OpenCodeRuntime(options(), deps)
await expect(runtime.getStatus()).resolves.toMatchObject({
available: true,
detail: 'OpenCode 已配置,将在首次使用时启动'
})
expect(detectBinary).toHaveBeenCalledWith(
'opencode',
'',
undefined
)
expect(validateBinary).toHaveBeenCalledWith(
'opencode',
'opencode'
)
expect(spawnMock).not.toHaveBeenCalled()
expect(createClient).not.toHaveBeenCalled()
expect(checkServerHealth).not.toHaveBeenCalled()
await runtime.dispose()
})
it('reports an unavailable embedded binary without starting OpenCode', async () => {
const child = fakeChild()
const {
deps,
spawnMock,
detectBinary,
validateBinary,
createClient,
checkServerHealth
} = dependencies(child)
detectBinary.mockResolvedValueOnce({
path: '/missing/opencode',
detail: 'OpenCode CLI 已就绪'
})
validateBinary.mockResolvedValueOnce({
detail: 'OpenCode CLI 未找到'
})
const runtime = new OpenCodeRuntime(
options({
binaryPath: '/missing/opencode',
bundledBinaryPath: '/bundled/opencode'
}),
deps
)
await expect(runtime.getStatus()).resolves.toEqual({
id: 'opencode',
label: 'OpenCode',
available: false,
supportsToolExecution: true,
detail: 'OpenCode CLI 未找到'
})
expect(spawnMock).not.toHaveBeenCalled()
expect(detectBinary).toHaveBeenCalledWith(
'opencode',
'/missing/opencode',
'/bundled/opencode'
)
expect(validateBinary).toHaveBeenCalledWith(
'opencode',
'/missing/opencode'
)
expect(createClient).not.toHaveBeenCalled()
expect(checkServerHealth).not.toHaveBeenCalled()
await runtime.dispose()
})
it('reports a missing embedded model credential without starting OpenCode', async () => {
const child = fakeChild()
const {
deps,
spawnMock,
detectBinary,
validateBinary,
createClient,
checkServerHealth
} = dependencies(child)
const runtime = new OpenCodeRuntime(
options({
modelProfile: {
id: '00000000-0000-4000-8000-000000000011',
name: 'Independent model',
baseUrl: 'https://model.example',
modelName: 'private-model',
protocol: 'anthropic-messages',
authentication: 'api-key'
}
}),
deps
)
await expect(runtime.getStatus()).resolves.toEqual({
id: 'opencode',
label: 'OpenCode',
available: false,
supportsToolExecution: true,
detail: 'OpenCode 独立模型连接尚未配置 API Key'
})
expect(detectBinary).not.toHaveBeenCalled()
expect(validateBinary).not.toHaveBeenCalled()
expect(spawnMock).not.toHaveBeenCalled()
expect(createClient).not.toHaveBeenCalled()
expect(checkServerHealth).not.toHaveBeenCalled()
await runtime.dispose()
})
it('uses the detected binary and passes an absolute config path only through env', async () => {
const serverChild = fakeChild(314)
const killerChild = fakeChild(315)
@@ -329,7 +456,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
}
)
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true,
detail:
'由 GoodBuddy 以当前用户权限管理本机 OpenCode 进程'
@@ -476,7 +603,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
deps
)
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
@@ -553,7 +680,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
deps
)
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
@@ -626,7 +753,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
)
try {
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
const environment = (
@@ -678,9 +805,20 @@ describe('OpenCodeRuntime embedded launcher', () => {
join(tmpdir(), 'goodbuddy-opencode-routing-')
)
const requestPaths: string[] = []
const server = createServer((request, response) => {
const tenantHeaders: Array<string | string[] | undefined> = []
const requestBodies: Array<Record<string, unknown>> = []
const server = createServer(async (request, response) => {
requestPaths.push(request.url ?? '')
request.resume()
tenantHeaders.push(request.headers['x-tenant-id'])
let body = ''
for await (const chunk of request) {
body += String(chunk)
}
if (body) {
requestBodies.push(
JSON.parse(body) as Record<string, unknown>
)
}
response.writeHead(400, {
'content-type': 'application/json'
})
@@ -730,7 +868,13 @@ describe('OpenCodeRuntime embedded launcher', () => {
modelName: 'probe-model',
protocol,
authentication: 'api-key',
apiKey: 'local-probe-key'
apiKey: 'local-probe-key',
requestHeaders: {
'x-tenant-id': 'opencode-native'
},
requestBody: {
goodbuddy_unsupported_probe: 'must-not-send'
}
}
}),
{ startupTimeoutMs: 20_000 }
@@ -765,6 +909,12 @@ describe('OpenCodeRuntime embedded launcher', () => {
}
expect(requestPaths).toContain(expectedPath)
expect(requestPaths).not.toContain(unexpectedPath)
expect(tenantHeaders).toContain('opencode-native')
expect(requestBodies).not.toContainEqual(
expect.objectContaining({
goodbuddy_unsupported_probe: 'must-not-send'
})
)
} finally {
clearTimeout(timeout)
await runtime.dispose()
@@ -820,6 +970,12 @@ describe('OpenCodeRuntime embedded launcher', () => {
modelName: 'custom-model',
protocol,
authentication,
requestHeaders: {
'x-tenant-id': 'opencode-tenant'
},
requestBody: {
temperature: 0.25
},
...(authentication === 'api-key'
? { apiKey: 'private-key' }
: {})
@@ -828,7 +984,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
deps
)
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
@@ -854,7 +1010,10 @@ describe('OpenCodeRuntime embedded launcher', () => {
expect(config.provider?.[providerId]).toMatchObject({
npm: providerPackage,
options: {
baseURL: 'https://model.example/v1'
baseURL: 'https://model.example/v1',
headers: {
'x-tenant-id': 'opencode-tenant'
}
},
models: {
'custom-model': {
@@ -873,6 +1032,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
config.provider?.[providerId]?.options
).not.toHaveProperty('apiKey')
}
expect(JSON.stringify(config)).not.toContain('temperature')
await runtime.dispose()
}
)
@@ -898,7 +1058,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
'https://telemetry.invalid'
try {
const runtime = new OpenCodeRuntime(options(), deps)
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
@@ -963,7 +1123,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
.mockResolvedValueOnce(true)
const runtime = new OpenCodeRuntime(options(), deps)
await expect(runtime.getStatus()).resolves.toMatchObject({
await expect(runtime.testConnection()).resolves.toMatchObject({
available: true
})
expect(checkServerHealth).toHaveBeenCalledTimes(2)
@@ -990,7 +1150,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
})
const runtime = new OpenCodeRuntime(options(), deps)
let settled = false
const statusPromise = runtime.getStatus().finally(() => {
const statusPromise = runtime.testConnection().finally(() => {
settled = true
})
@@ -1020,7 +1180,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
stderrOf(child).write(secret)
const runtime = new OpenCodeRuntime(options(), deps)
const status = await runtime.getStatus()
const status = await runtime.testConnection()
expect(status).toMatchObject({
available: false,
@@ -1055,7 +1215,7 @@ describe('OpenCodeRuntime embedded launcher', () => {
})
const runtime = new OpenCodeRuntime(options(), deps)
const status = await runtime.getStatus()
const status = await runtime.testConnection()
expect(status.detail).toBe('OpenCode Server 启动前退出(code 9)')
expect(status.detail).not.toContain(secret)
@@ -2035,7 +2195,7 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await runtime.dispose()
})
it('fails before emitting text that exceeds the aggregate output budget', async () => {
it('truncates oversized text display without stopping the run', async () => {
const setup = runClient([
{
id: 'first-text',
@@ -2056,29 +2216,50 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
field: 'text',
delta: 'b'.repeat(600_000)
}
},
{
id: 'idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(setup.client)
const stream = runtime.run(
{
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
conversationId: 'conversation-1',
prompt: 'test',
workMode: 'execute'
},
new AbortController().signal
)
const events = await collectRun(runtime, 'execute')
const text = events
.flatMap((event) => event.type === 'text' ? [event.delta] : [])
.join('')
await stream.next()
const firstText = await stream.next()
expect(firstText.value).toMatchObject({
type: 'text',
delta: expect.stringMatching(/^a+$/u)
expect(Buffer.byteLength(text)).toBe(1024 * 1024)
expect(events).toContainEqual({
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
type: 'status',
message:
'OpenCode 输出较长,已停止继续展示文本;任务仍在继续'
})
await expect(stream.next()).rejects.toThrow(
'文本与推理输出超过 1 MB 安全限制'
)
expect(setup.session.abort).toHaveBeenCalled()
expect(events.at(-1)).toMatchObject({ type: 'done' })
expect(setup.session.abort).not.toHaveBeenCalled()
await runtime.dispose()
})
it('completes more than 100 distinct tool calls in one run', async () => {
const setup = runClient([
...Array.from({ length: 101 }, (_, index) =>
completedToolEvent(`call-${index + 1}`)
),
{
id: 'idle',
type: 'session.idle',
properties: { sessionID: 'session-1' }
}
])
const runtime = embeddedRuntime(setup.client)
const events = await collectRun(runtime, 'execute')
expect(
events.filter((event) => event.type === 'tool')
).toHaveLength(101)
expect(events.at(-1)).toMatchObject({ type: 'done' })
await runtime.dispose()
})
@@ -2184,6 +2365,8 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
read: false,
write: false,
bash: false,
'goodbuddy-data-*': false,
'goodbuddy-custom-*': false,
[knowledgeToolId]: true
}
}),
@@ -2264,7 +2447,7 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
await runtime.dispose()
})
it('serializes overlapping embedded MCP registration and discovery', async () => {
it('runs embedded conversations concurrently with request-scoped MCP registrations', async () => {
const setup = runClient([
{
id: 'idle',
@@ -2357,13 +2540,19 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
'conversation-two',
'second-token'
)
await new Promise((resolve) => setTimeout(resolve, 10))
expect(mcpAdd).toHaveBeenCalledTimes(1)
resolveFirstAdd()
await first
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(2))
await second
try {
await vi.waitFor(
() => expect(mcpAdd).toHaveBeenCalledTimes(2),
{ timeout: 500 }
)
await vi.waitFor(
() => expect(setup.session.promptAsync).toHaveBeenCalledOnce(),
{ timeout: 500 }
)
} finally {
resolveFirstAdd()
}
await Promise.all([first, second])
expect(
mcpAdd.mock.calls.map(
([input]) =>
@@ -2483,6 +2672,8 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
write: false,
bash: false,
task: false,
'goodbuddy-data-*': false,
'goodbuddy-custom-*': false,
skill: true
}
}),
@@ -2557,11 +2748,30 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
title: 'GoodBuddy 对话',
directory: process.cwd(),
permission: [
{ permission: '*', pattern: '*', action: 'allow' }
{ permission: '*', pattern: '*', action: 'allow' },
{
permission: 'goodbuddy-data-*',
pattern: '*',
action: 'deny'
},
{
permission: 'goodbuddy-custom-*',
pattern: '*',
action: 'deny'
}
]
},
{ signal: expect.any(AbortSignal) }
)
expect(session.promptAsync).toHaveBeenCalledWith(
expect.objectContaining({
tools: {
'goodbuddy-data-*': false,
'goodbuddy-custom-*': false
}
}),
expect.anything()
)
expect(permissionReply).toHaveBeenCalledOnce()
expect(permissionReply).toHaveBeenCalledWith(
{
@@ -2926,7 +3136,9 @@ describe('OpenCodeRuntime embedded permission mediation', () => {
read: false,
write: false,
bash: false,
task: false
task: false,
'goodbuddy-data-*': false,
'goodbuddy-custom-*': false
}
}),
expect.anything()
+159 -96
View File
@@ -22,9 +22,6 @@ import type {
AgentRuntimeStatus,
SubagentEvent
} from '../../shared/contracts'
import {
maximumConversationToolActivities
} from '../../shared/assistant-contracts'
import {
boundedRuntimeIdentifierSchema,
runtimeNativeInventoryLimits,
@@ -78,7 +75,6 @@ const MAX_PERMISSION_PATTERNS = 32
const MAX_PERMISSION_PATTERN_LENGTH = 1_024
const MAX_PERMISSION_PATTERNS_BYTES = 8 * 1_024
const MAX_PERMISSION_METADATA_BYTES = 8 * 1_024
const MAX_TOOL_CALLS_PER_RUN = maximumConversationToolActivities
const MAX_EXECUTION_OUTPUT_BYTES = 1024 * 1024
const MAX_QUESTION_REQUEST_BYTES = 32 * 1_024
const MAX_QUESTIONS_PER_REQUEST = 4
@@ -99,6 +95,15 @@ const TEMPORARY_MCP_PREFIXES = [
'goodbuddy-data-',
'goodbuddy-custom-'
] as const
const temporaryMcpToolOverrides = Object.fromEntries(
TEMPORARY_MCP_PREFIXES.map((prefix) => [`${prefix}*`, false])
)
const temporaryMcpDenyRules: PermissionRuleset =
TEMPORARY_MCP_PREFIXES.map((prefix) => ({
permission: `${prefix}*`,
pattern: '*',
action: 'deny'
}))
const OPENCODE_INTERNAL_TOOL_IDS = new Set(['invalid'])
const OPENCODE_BUILTIN_TOOL_KINDS: Readonly<
Partial<Record<string, RuntimeNativeTool['kind']>>
@@ -130,6 +135,7 @@ type OpenCodeProviderConfig = {
options: {
apiKey?: string
baseURL: string
headers?: Record<string, string>
}
models: Record<
string,
@@ -209,8 +215,13 @@ function createOpenCodeProviderConfig(
const options: {
apiKey?: string
baseURL: string
headers?: Record<string, string>
} = {
baseURL: provider.baseURL
baseURL: provider.baseURL,
...(profile.requestHeaders &&
Object.keys(profile.requestHeaders).length > 0
? { headers: profile.requestHeaders }
: {})
}
if (profile.authentication === 'api-key' && profile.apiKey) {
options.apiKey = profile.apiKey
@@ -466,6 +477,11 @@ export type OpenCodeRuntimeDependencies = {
configuredPath: string,
bundledPath?: string
) => Promise<{ path?: string; detail: string }>
validateBinary: (
runtime: 'opencode',
configuredPath: string,
bundledPath?: string
) => Promise<{ path?: string; detail: string }>
createClient: typeof createOpencodeClient
checkServerHealth: (
url: string,
@@ -684,6 +700,19 @@ async function defaultDetectBinary(
})
}
async function defaultValidateBinary(
runtime: 'opencode',
configuredPath: string
): Promise<{ path?: string; detail: string }> {
return detectRuntimeBinary({
binaryPath: configuredPath,
validation: 'canonical-file',
allowAutomaticDiscovery: false,
binaryNames: [runtime],
label: 'OpenCode CLI'
})
}
async function defaultCheckServerHealth(
url: string,
authorization: string,
@@ -719,7 +748,6 @@ export class OpenCodeRuntime implements AgentRuntime {
upstreamQuestionId: string
}
>()
private embeddedRunTail: Promise<void> = Promise.resolve()
private readonly conversationRunTails = new Map<string, Promise<void>>()
private readonly dependencies: OpenCodeRuntimeDependencies
@@ -730,6 +758,7 @@ export class OpenCodeRuntime implements AgentRuntime {
this.dependencies = {
spawn,
detectBinary: defaultDetectBinary,
validateBinary: defaultValidateBinary,
createClient: createOpencodeClient,
checkServerHealth: defaultCheckServerHealth,
platform: process.platform,
@@ -751,36 +780,6 @@ export class OpenCodeRuntime implements AgentRuntime {
return this.usesEmbeddedPermissionMediation()
}
private async acquireEmbeddedRun(
signal: AbortSignal
): Promise<() => void> {
signal.throwIfAborted()
const previous = this.embeddedRunTail
let release!: () => void
const current = new Promise<void>((resolve) => {
release = resolve
})
this.embeddedRunTail = previous.then(
() => current,
() => current
)
let abort!: () => void
const aborted = new Promise<never>((_resolve, reject) => {
abort = () => reject(signal.reason)
})
signal.addEventListener('abort', abort, { once: true })
try {
await Promise.race([previous, aborted])
signal.throwIfAborted()
return release
} catch (error) {
release()
throw error
} finally {
signal.removeEventListener('abort', abort)
}
}
private async acquireConversationRun(
conversationId: string,
signal: AbortSignal
@@ -874,15 +873,37 @@ export class OpenCodeRuntime implements AgentRuntime {
}
}
private async launchEmbedded(signal?: AbortSignal): Promise<OpenCodeServer> {
if (signal?.aborted) {
throw new Error('OpenCode Server 启动已取消')
private async inspectEmbeddedConfiguration(
validatePath = false
): Promise<{
path?: string
detail: string
}> {
if (
this.options.modelProfile?.authentication === 'api-key' &&
!this.options.modelProfile.apiKey
) {
throw new Error('OpenCode 独立模型连接尚未配置 API Key')
}
const detection = await this.dependencies.detectBinary(
'opencode',
this.options.binaryPath,
this.options.bundledBinaryPath
)
if (!validatePath || !detection.path) {
return detection
}
return this.dependencies.validateBinary(
'opencode',
detection.path
)
}
private async launchEmbedded(signal?: AbortSignal): Promise<OpenCodeServer> {
if (signal?.aborted) {
throw new Error('OpenCode Server 启动已取消')
}
const detection = await this.inspectEmbeddedConfiguration()
const binaryPath = detection.path
if (!binaryPath) {
throw new Error(detection.detail)
@@ -895,12 +916,6 @@ export class OpenCodeRuntime implements AgentRuntime {
throw new Error('OpenCode Server 启动已取消')
}
if (
this.options.modelProfile?.authentication === 'api-key' &&
!this.options.modelProfile.apiKey
) {
throw new Error('OpenCode 独立模型连接尚未配置 API Key')
}
const skillIds = this.getNativeSkillIds()
const registration = await this.createSkillRegistration()
try {
@@ -1206,6 +1221,38 @@ export class OpenCodeRuntime implements AgentRuntime {
}
async getStatus(): Promise<AgentRuntimeStatus> {
if (this.usesEmbeddedPermissionMediation() && !this.client) {
try {
const detection =
await this.inspectEmbeddedConfiguration(true)
return {
id: 'opencode',
label: 'OpenCode',
available: Boolean(detection.path),
supportsToolExecution: this.supportsToolExecution,
detail: detection.path
? 'OpenCode 已配置,将在首次使用时启动'
: detection.detail
}
} catch (error) {
return {
id: 'opencode',
label: 'OpenCode',
available: false,
supportsToolExecution: this.supportsToolExecution,
detail:
error instanceof Error ? error.message : 'OpenCode 不可用'
}
}
}
return this.checkConnection()
}
async testConnection(): Promise<AgentRuntimeStatus> {
return this.checkConnection()
}
private async checkConnection(): Promise<AgentRuntimeStatus> {
try {
const client = await this.getClient()
const response = await this.controlRequest(
@@ -1835,12 +1882,8 @@ export class OpenCodeRuntime implements AgentRuntime {
const executionSignal = deadline
? AbortSignal.any([signal, deadline.signal])
: signal
let releaseEmbedded: (() => void) | undefined
let releaseConversation: (() => void) | undefined
try {
releaseEmbedded = this.usesEmbeddedPermissionMediation()
? await this.acquireEmbeddedRun(executionSignal)
: undefined
releaseConversation = await this.acquireConversationRun(
request.conversationId,
executionSignal
@@ -1856,7 +1899,6 @@ export class OpenCodeRuntime implements AgentRuntime {
clearTimeout(deadlineTimer)
}
releaseConversation?.()
releaseEmbedded?.()
}
}
@@ -2054,6 +2096,9 @@ export class OpenCodeRuntime implements AgentRuntime {
request.workMode === 'execute'
? [
...executePermissionRules,
...(this.usesEmbeddedPermissionMediation()
? temporaryMcpDenyRules
: []),
...nativeSkillPermissionRules,
...knowledgeToolIds.map((toolId) => ({
permission: toolId,
@@ -2075,7 +2120,7 @@ export class OpenCodeRuntime implements AgentRuntime {
...readOnlyPermissionRules,
...nativeSkillPermissionRules
]
let disabledTools: Record<string, boolean> | undefined
let toolOverrides: Record<string, boolean> | undefined
if (request.workMode !== 'execute') {
const tools = await this.controlRequest(
'读取工具清单',
@@ -2089,15 +2134,23 @@ export class OpenCodeRuntime implements AgentRuntime {
if (tools.error || !tools.data) {
throw new Error('OpenCode 无法确认工具已禁用,已阻止只读请求')
}
disabledTools = {
toolOverrides = {
...Object.fromEntries(
tools.data.map((toolId) => [toolId, false])
),
...temporaryMcpToolOverrides,
...Object.fromEntries(
knowledgeToolIds.map((toolId) => [toolId, true])
),
...(nativeSkillIds.length > 0 ? { skill: true } : {})
}
} else if (this.usesEmbeddedPermissionMediation()) {
toolOverrides = {
...temporaryMcpToolOverrides,
...Object.fromEntries(
knowledgeToolIds.map((toolId) => [toolId, true])
)
}
}
const session = await this.getSessionId(
client,
@@ -2165,12 +2218,28 @@ export class OpenCodeRuntime implements AgentRuntime {
const reasoningPartIds = new Set<string>()
const reportedQuestionIds = new Map<string, string>()
let aggregateOutputBytes = 0
const consumeOutputBudget = (delta: string): void => {
aggregateOutputBytes += Buffer.byteLength(delta)
if (aggregateOutputBytes > MAX_EXECUTION_OUTPUT_BYTES) {
throw new Error(
'OpenCode 单次运行的文本与推理输出超过 1 MB 安全限制'
)
let outputTruncated = false
const retainOutputDelta = (
delta: string
): { delta: string; truncated: boolean } => {
if (outputTruncated) {
return { delta: '', truncated: false }
}
const bytes = Buffer.from(delta)
const remaining =
MAX_EXECUTION_OUTPUT_BYTES - aggregateOutputBytes
if (bytes.byteLength <= remaining) {
aggregateOutputBytes += bytes.byteLength
return { delta, truncated: false }
}
outputTruncated = true
aggregateOutputBytes = MAX_EXECUTION_OUTPUT_BYTES
return {
delta: bytes
.subarray(0, Math.max(0, remaining))
.toString('utf8')
.replace(/\uFFFD+$/u, ''),
truncated: true
}
}
let hasResponseTextAfterFailure = false
@@ -2228,8 +2297,8 @@ export class OpenCodeRuntime implements AgentRuntime {
nativeSkillIds.length > 0
? undefined
: this.options.skillInstructions || undefined,
...(disabledTools
? { tools: disabledTools }
...(toolOverrides
? { tools: toolOverrides }
: {}),
parts: [
{ type: 'text' as const, text: promptText },
@@ -2276,7 +2345,6 @@ export class OpenCodeRuntime implements AgentRuntime {
'thinking'
].includes(event.properties.field)
if (reasoning || event.properties.field === 'text') {
consumeOutputBudget(event.properties.delta)
if (
!reasoning &&
/\S/u.test(event.properties.delta) &&
@@ -2286,10 +2354,21 @@ export class OpenCodeRuntime implements AgentRuntime {
) {
hasResponseTextAfterFailure = true
}
yield {
requestId: request.requestId,
type: reasoning ? 'reasoning' : 'text',
delta: event.properties.delta
const retained = retainOutputDelta(event.properties.delta)
if (retained.delta) {
yield {
requestId: request.requestId,
type: reasoning ? 'reasoning' : 'text',
delta: retained.delta
}
}
if (retained.truncated) {
yield {
requestId: request.requestId,
type: 'status',
message:
'OpenCode 输出较长,已停止继续展示文本;任务仍在继续'
}
}
}
}
@@ -2307,12 +2386,6 @@ export class OpenCodeRuntime implements AgentRuntime {
throw new Error('OpenCode 工具调用 ID 格式无效')
}
const toolName = part.tool.slice(0, 200)
if (
!toolStates.has(callId) &&
toolStates.size >= MAX_TOOL_CALLS_PER_RUN
) {
throw new Error('OpenCode 单次运行的工具调用超过 100 个')
}
const state =
part.state.status === 'error' ? 'failed' : part.state.status
if (state === 'failed') {
@@ -2372,11 +2445,21 @@ export class OpenCodeRuntime implements AgentRuntime {
event.properties.sessionID === sessionId &&
event.properties.delta
) {
consumeOutputBudget(event.properties.delta)
yield {
requestId: request.requestId,
type: 'reasoning',
delta: event.properties.delta
const retained = retainOutputDelta(event.properties.delta)
if (retained.delta) {
yield {
requestId: request.requestId,
type: 'reasoning',
delta: retained.delta
}
}
if (retained.truncated) {
yield {
requestId: request.requestId,
type: 'status',
message:
'OpenCode 输出较长,已停止继续展示文本;任务仍在继续'
}
}
}
@@ -2469,14 +2552,6 @@ export class OpenCodeRuntime implements AgentRuntime {
properties.id.length <= MAX_PERMISSION_NAME_LENGTH &&
!repliedPermissionIds.has(properties.id)
) {
if (
repliedPermissionIds.size >= MAX_TOOL_CALLS_PER_RUN
) {
throw new Error(
'OpenCode 单次运行的权限请求超过 100 个',
{ cause: error }
)
}
const permissionId = properties.id
repliedPermissionIds.add(properties.id)
const rejection = await this.controlRequest(
@@ -2505,21 +2580,12 @@ export class OpenCodeRuntime implements AgentRuntime {
if (repliedPermissionIds.has(permissionRequest.id)) {
continue
}
if (repliedPermissionIds.size >= MAX_TOOL_CALLS_PER_RUN) {
throw new Error('OpenCode 单次运行的权限请求超过 100 个')
}
repliedPermissionIds.add(permissionRequest.id)
const callId = (
permissionRequest.tool?.callID ?? permissionRequest.id
)
const toolName = permissionRequest.permission.slice(0, 200)
if (
!toolStates.has(callId) &&
toolStates.size >= MAX_TOOL_CALLS_PER_RUN
) {
throw new Error('OpenCode 单次运行的工具调用超过 100 个')
}
toolStates.set(callId, { name: toolName, state: 'pending' })
yield {
requestId: request.requestId,
@@ -2772,10 +2838,8 @@ export class OpenCodeRuntime implements AgentRuntime {
const executionSignal = deadline
? AbortSignal.any([signal, deadline.signal])
: signal
let releaseEmbedded: (() => void) | undefined
let releaseConversation: (() => void) | undefined
try {
releaseEmbedded = await this.acquireEmbeddedRun(executionSignal)
releaseConversation = await this.acquireConversationRun(
request.conversationId,
executionSignal
@@ -2932,7 +2996,6 @@ export class OpenCodeRuntime implements AgentRuntime {
clearTimeout(deadlineTimer)
}
releaseConversation?.()
releaseEmbedded?.()
}
}
-683
View File
@@ -1,683 +0,0 @@
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import { ModelCallOperationStore } from './model-call-operation-store'
import {
createModelCallOperationId,
createResolvedModelProfileDigest,
RemoteModelGateway,
type RemoteModelGatewayDispatchContext
} from './remote-model-gateway'
const stores: ModelCallOperationStore[] = []
const storePaths = new Map<ModelCallOperationStore, string>()
const anthropicProfile: ResolvedModelProfile = {
id: '00000000-0000-4000-8000-000000000001',
name: 'Anthropic',
baseUrl: 'https://provider.example',
modelName: 'claude-test',
protocol: 'anthropic-messages',
authentication: 'api-key',
apiKey: 'main-only-secret'
}
const context: RemoteModelGatewayDispatchContext = {
requestId: 'request-1',
bindingId: 'binding-1',
promptOperationId: 'prompt-1',
promptSequence: 0,
roundIndex: 0,
modelProfileDigest: createResolvedModelProfileDigest(anthropicProfile),
modelProfile: anthropicProfile
}
const request = {
method: 'POST',
path: '/v1/messages',
headers: {
accept: 'application/json',
'content-type': 'application/json'
},
bodyBase64: Buffer.from(
JSON.stringify({ model: 'claude-test', messages: [] })
).toString('base64')
} as const
afterEach(() => {
vi.restoreAllMocks()
for (const store of stores.splice(0)) {
store.close()
}
})
async function createStore(): Promise<ModelCallOperationStore> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-gateway-'))
const path = join(directory, 'operations.sqlite')
const store = new ModelCallOperationStore(path)
stores.push(store)
storePaths.set(store, path)
return store
}
describe('RemoteModelGateway', () => {
it('prepares and claims before one authenticated provider fetch', async () => {
const store = await createStore()
const order: string[] = []
const prepare = vi.spyOn(store, 'prepare')
prepare.mockImplementation(function (input) {
order.push('prepare')
return ModelCallOperationStore.prototype.prepare.call(store, input)
})
const begin = vi.spyOn(store, 'beginDispatch')
begin.mockImplementation(function (id, metadata) {
order.push('begin')
return ModelCallOperationStore.prototype.beginDispatch.call(
store,
id,
metadata
)
})
const complete = vi.spyOn(store, 'complete')
let sentHeaders: Headers | undefined
let sentBody: Record<string, unknown> | undefined
const fetcher = vi.fn(async (url: URL, init?: RequestInit) => {
order.push('fetch')
expect(url.toString()).toBe(
'https://provider.example/v1/messages'
)
expect(init?.method).toBe('POST')
expect(init?.redirect).toBe('error')
sentHeaders = new Headers(init?.headers)
sentBody = JSON.parse(String(init?.body)) as Record<string, unknown>
return new Response(
JSON.stringify({
id: 'message-1',
stop_reason: 'end_turn',
usage: {
input_tokens: 11,
output_tokens: 7,
cache_read_input_tokens: 3
}
}),
{
status: 200,
headers: {
'content-type': 'application/json',
'x-request-id': 'request-provider-1',
authorization: 'must-not-return'
}
}
)
})
const gateway = new RemoteModelGateway({ store, fetcher })
const result = await gateway.dispatch(
context,
request,
new AbortController().signal
)
expect(order).toEqual(['prepare', 'begin', 'fetch'])
expect(fetcher).toHaveBeenCalledOnce()
expect(sentHeaders?.get('x-api-key')).toBe('main-only-secret')
expect(sentHeaders?.get('anthropic-version')).toBe('2023-06-01')
expect(sentBody).toEqual({
model: 'claude-test',
messages: []
})
expect(result.status).toBe(200)
expect(result.headers).toEqual({
'content-type': 'application/json',
'x-request-id': 'request-provider-1'
})
expect(JSON.stringify(result)).not.toContain('main-only-secret')
const completed = complete.mock.results[0]?.value
expect(completed?.status).toBe('completed')
expect(completed?.terminalEvidence).toMatchObject({
status: 'completed',
providerRequestId: 'request-provider-1',
providerResponseId: 'message-1',
result: {
finishReason: 'end_turn',
outputDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/u)
},
usage: {
inputTokens: 11,
outputTokens: 7,
cachedInputTokens: 3
}
})
expect(JSON.stringify(completed)).not.toContain('main-only-secret')
expect(JSON.stringify(completed)).not.toContain('"messages"')
})
it('does not clamp Runtime-selected provider output parameters', async () => {
const store = await createStore()
let sentBody: Record<string, unknown> | undefined
const gateway = new RemoteModelGateway({
store,
fetcher: async (_url, init) => {
sentBody = JSON.parse(String(init?.body)) as Record<
string,
unknown
>
return new Response('{}')
}
})
const requestWithProviderMaximum = {
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [],
max_tokens: 1_000_000
})
).toString('base64')
}
await gateway.dispatch(
context,
requestWithProviderMaximum,
new AbortController().signal
)
expect(sentBody).toMatchObject({ max_tokens: 1_000_000 })
})
it('does not persist endpoints, credentials, or provider bodies', async () => {
const store = await createStore()
const gateway = new RemoteModelGateway({
store,
fetcher: async () =>
new Response(JSON.stringify({ private_response_field: 'secret' }))
})
await gateway.dispatch(context, request, new AbortController().signal)
store.close()
const databaseBytes = await readFile(storePaths.get(store)!)
const persisted = databaseBytes.toString('utf8')
expect(persisted).not.toContain('https://provider.example')
expect(persisted).not.toContain('main-only-secret')
expect(persisted).not.toContain('"messages"')
expect(persisted).not.toContain('private_response_field')
})
it('completes the ledger for a full HTTP 4xx response', async () => {
const store = await createStore()
const complete = vi.spyOn(store, 'complete')
const gateway = new RemoteModelGateway({
store,
fetcher: async () =>
new Response(JSON.stringify({ error: { type: 'invalid_request' } }), {
status: 400,
headers: { 'content-type': 'application/json' }
})
})
const result = await gateway.dispatch(
context,
request,
new AbortController().signal
)
expect(result.status).toBe(400)
expect(complete).toHaveBeenCalledOnce()
expect(complete.mock.results[0]?.value.status).toBe('completed')
})
it('permits only one fetch for duplicate canonical dispatches', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(context, request, new AbortController().signal)
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({
code: 'already-dispatched'
})
expect(fetcher).toHaveBeenCalledOnce()
})
it('uses profile-independent round identity and rejects a profile swap', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(context, request, new AbortController().signal)
const swappedProfile = {
...anthropicProfile,
id: '00000000-0000-4000-8000-000000000002',
modelName: 'claude-other'
}
await expect(
gateway.dispatch(
{
...context,
modelProfile: swappedProfile,
modelProfileDigest:
createResolvedModelProfileDigest(swappedProfile)
},
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({ model: 'claude-other', messages: [] })
).toString('base64')
},
new AbortController().signal
)
).rejects.toThrow('different identity or evidence')
expect(fetcher).toHaveBeenCalledOnce()
})
it('validates the Main profile snapshot digest before ledger preparation', async () => {
const store = await createStore()
const prepare = vi.spyOn(store, 'prepare')
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await expect(
gateway.dispatch(
{ ...context, modelProfileDigest: `sha256:${'f'.repeat(64)}` },
request,
new AbortController().signal
)
).rejects.toMatchObject({ code: 'model-profile-digest-mismatch' })
expect(prepare).not.toHaveBeenCalled()
expect(fetcher).not.toHaveBeenCalled()
})
it('fails closed on a different request for the same bound call', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(context, request, new AbortController().signal)
await expect(
gateway.dispatch(
context,
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [{ role: 'user', content: 'different' }]
})
).toString('base64')
},
new AbortController().signal
)
).rejects.toThrow('different identity or evidence')
expect(fetcher).toHaveBeenCalledOnce()
})
it('rejects illegal paths, headers, and protocol mismatches before prepare', async () => {
const store = await createStore()
const prepare = vi.spyOn(store, 'prepare')
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
for (const invalidRequest of [
{ ...request, path: 'https://attacker.example/v1/messages' },
{
...request,
headers: { authorization: 'Bearer remote-secret' }
},
{ ...request, path: '/v1/responses' },
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({ model: 'untrusted-model' })
).toString('base64')
},
{ ...request, path: '/v1/responses' }
]) {
await expect(
gateway.dispatch(
context,
invalidRequest as typeof request,
new AbortController().signal
)
).rejects.toThrow()
}
expect(prepare).not.toHaveBeenCalled()
expect(fetcher).not.toHaveBeenCalled()
expect(store.listUnresolved()).toEqual([])
})
it('enforces the trusted profile image-input policy before prepare', async () => {
const store = await createStore()
const prepare = vi.spyOn(store, 'prepare')
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
const imageRequest = {
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [
{
role: 'user',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'AA=='
}
}
]
}
]
})
).toString('base64')
}
await expect(
gateway.dispatch(
context,
imageRequest,
new AbortController().signal
)
).rejects.toMatchObject({ code: 'request-policy-mismatch' })
expect(prepare).not.toHaveBeenCalled()
expect(fetcher).not.toHaveBeenCalled()
await expect(
gateway.dispatch(
{
...context,
modelProfile: {
...anthropicProfile,
supportsImageInput: true
},
modelProfileDigest: createResolvedModelProfileDigest({
...anthropicProfile,
supportsImageInput: true
})
},
imageRequest,
new AbortController().signal
)
).resolves.toMatchObject({ status: 200 })
expect(fetcher).toHaveBeenCalledOnce()
})
it.each([
{ background: true },
{ store: true },
{ service_tier: 'priority' },
{ web_search_options: {} },
{
tools: [
{
type: 'web_search_20250305',
name: 'web_search'
}
]
}
])(
'rejects independently billed provider capabilities before prepare',
async (providerCapability) => {
const store = await createStore()
const prepare = vi.spyOn(store, 'prepare')
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await expect(
gateway.dispatch(
context,
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [],
...providerCapability
})
).toString('base64')
},
new AbortController().signal
)
).rejects.toMatchObject({ code: 'request-policy-mismatch' })
expect(prepare).not.toHaveBeenCalled()
expect(fetcher).not.toHaveBeenCalled()
}
)
it('allows ordinary client-defined tools within the token policy', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await expect(
gateway.dispatch(
context,
{
...request,
bodyBase64: Buffer.from(
JSON.stringify({
model: 'claude-test',
messages: [],
tools: [
{
name: 'read_file',
description: 'Read a file',
input_schema: { type: 'object' }
}
]
})
).toString('base64')
},
new AbortController().signal
)
).resolves.toMatchObject({ status: 200 })
expect(fetcher).toHaveBeenCalledOnce()
})
it.each([
{
name: 'network failure',
expectedCode: 'network-error',
run: async () => {
throw new Error('socket exposed sensitive upstream details')
}
},
{
name: 'timeout',
expectedCode: 'timeout',
run: async (_url: URL, init?: RequestInit) =>
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true }
)
}),
timeoutMs: 5
}
])(
'marks $name after dispatch as outcome unknown',
async ({ expectedCode, run, timeoutMs }) => {
const store = await createStore()
const gateway = new RemoteModelGateway({
store,
fetcher: run,
...(timeoutMs ? { timeoutMs } : {})
})
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({
code: expectedCode,
message: expect.not.stringContaining('sensitive')
})
expect(store.listUnresolved()).toHaveLength(1)
expect(store.listUnresolved()[0]).toMatchObject({
status: 'outcome-unknown',
terminalEvidence: {
status: 'outcome-unknown',
reason: { code: expectedCode, retryable: false }
}
})
}
)
it('marks caller cancellation after dispatch as outcome unknown', async () => {
const store = await createStore()
const controller = new AbortController()
const gateway = new RemoteModelGateway({
store,
fetcher: async (_url, init) =>
await new Promise<Response>((_resolve, reject) => {
controller.abort()
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true }
)
if (init?.signal?.aborted) {
reject(init.signal.reason)
}
})
})
await expect(
gateway.dispatch(context, request, controller.signal)
).rejects.toMatchObject({ code: 'cancelled' })
expect(store.listUnresolved()[0]?.status).toBe('outcome-unknown')
})
it('marks an oversized response as outcome unknown', async () => {
const store = await createStore()
const gateway = new RemoteModelGateway({
store,
fetcher: async () =>
new Response(Buffer.alloc(768 * 1024 + 1), {
headers: {
'content-length': String(768 * 1024 + 1)
}
})
})
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({ code: 'response-too-large' })
expect(store.listUnresolved()[0]).toMatchObject({
status: 'outcome-unknown',
terminalEvidence: {
reason: {
code: 'response-too-large',
retryable: false
}
}
})
})
it('keeps completion pending until delivery ACK and then permits the next round', async () => {
const store = await createStore()
const fetcher = vi.fn(async () => new Response('{}'))
const gateway = new RemoteModelGateway({ store, fetcher })
await gateway.dispatch(context, request, new AbortController().signal)
const nextContext = { ...context, roundIndex: 1 }
await expect(
gateway.dispatch(nextContext, request, new AbortController().signal)
).rejects.toThrow('not been completed and delivered')
expect(fetcher).toHaveBeenCalledOnce()
const pending = store.listStartupRecords().records
expect(pending).toMatchObject([{ status: 'completed' }])
expect(pending[0]).not.toHaveProperty('responseDeliveredAt')
gateway.markResponseDelivered(context)
await gateway.dispatch(
nextContext,
request,
new AbortController().signal
)
expect(fetcher).toHaveBeenCalledTimes(2)
})
it('permits sequential responses without a cumulative output allowance', async () => {
const store = await createStore()
const gateway = new RemoteModelGateway({
store,
fetcher: async () => new Response('{}')
})
for (let roundIndex = 0; roundIndex < 12; roundIndex += 1) {
const round = { ...context, roundIndex }
await gateway.dispatch(
round,
request,
new AbortController().signal
)
gateway.markResponseDelivered(round)
}
expect(store.get(
createModelCallOperationId({ ...context, roundIndex: 11 })
)).toMatchObject({
status: 'completed',
responseDeliveredAt: expect.any(Number)
})
})
it('marks schema-invalid bridge responses unknown before terminal completion', async () => {
const store = await createStore()
const response = new Response('{}')
const gateway = new RemoteModelGateway({
store,
fetcher: async () =>
({
status: 999,
headers: response.headers,
body: response.body
}) as Response
})
await expect(
gateway.dispatch(context, request, new AbortController().signal)
).rejects.toMatchObject({ code: 'invalid-bridge-response' })
expect(store.listStartupRecords().records).toMatchObject([
{
status: 'outcome-unknown',
terminalEvidence: {
reason: { code: 'invalid-bridge-response' }
}
}
])
})
it('injects OpenAI bearer auth without returning or persisting it', async () => {
const store = await createStore()
const profile: ResolvedModelProfile = {
...anthropicProfile,
baseUrl: 'https://openai.example/v1',
protocol: 'openai-responses'
}
let authorization: string | null = null
const gateway = new RemoteModelGateway({
store,
fetcher: async (_url, init) => {
authorization = new Headers(init?.headers).get('authorization')
return new Response('{}')
}
})
const result = await gateway.dispatch(
{
...context,
modelProfile: profile,
modelProfileDigest: createResolvedModelProfileDigest(profile)
},
{ ...request, path: '/v1/responses' },
new AbortController().signal
)
expect(authorization).toBe('Bearer main-only-secret')
expect(JSON.stringify(result)).not.toContain('main-only-secret')
})
})
-632
View File
@@ -1,632 +0,0 @@
import { createHash } from 'node:crypto'
import {
modelCallIdentitySchema,
modelCallResultMetadataSchema,
modelCallUsageMetadataSchema,
type ModelCallResultMetadata,
type ModelCallUsageMetadata
} from '../../shared/model-call-operation-contracts'
import {
REMOTE_MODEL_GATEWAY_LIMITS,
remoteModelGatewayRequestSchema,
remoteModelGatewayResponseSchema,
type RemoteModelGatewayRequest,
type RemoteModelGatewayResponse
} from '../../shared/remote-model-gateway-contracts'
import type { ResolvedModelProfile } from '../runtime-settings-store'
import { createAnthropicMessagesUrl } from './anthropic-endpoint'
import { ModelCallOperationStore } from './model-call-operation-store'
import {
createOpenAIChatCompletionsUrl,
createOpenAIResponsesUrl
} from './openai-endpoint'
import {
BoundedResponseTooLargeError,
readBoundedResponseBytes
} from './bounded-response'
import {
assertTextModelRequestPolicy,
ModelRequestPolicyError
} from '../../shared/model-request-policy'
const DEFAULT_TIMEOUT_MS = 60_000
const RESPONSE_HEADER_NAMES = [
'content-type',
'openai-request-id',
'request-id',
'x-request-id'
] as const
export type RemoteModelGatewayDispatchContext = {
requestId: string
bindingId: string
promptOperationId: string
promptSequence: number
roundIndex: number
modelProfileDigest: string
modelProfile: ResolvedModelProfile
}
export type RemoteModelGatewayOptions = {
store: ModelCallOperationStore
fetcher?: (
input: URL,
init: RequestInit
) => Promise<Response>
timeoutMs?: number
}
export class RemoteModelGatewayError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.name = 'RemoteModelGatewayError'
this.code = code
}
}
/**
* Main-process-only, one-shot provider forwarder. Remote runtimes choose only
* a protocol-specific relative path and a bounded payload. Provider location,
* credentials, stable operation identity, and dispatch ownership stay in Main.
*/
export class RemoteModelGateway {
readonly #store: ModelCallOperationStore
readonly #fetcher: NonNullable<RemoteModelGatewayOptions['fetcher']>
readonly #timeoutMs: number
constructor(options: RemoteModelGatewayOptions) {
this.#store = options.store
this.#fetcher = options.fetcher ?? fetch
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
if (
!Number.isSafeInteger(timeoutMs) ||
timeoutMs < 1 ||
timeoutMs > 300_000
) {
throw new RangeError('Remote model gateway timeout is invalid')
}
this.#timeoutMs = timeoutMs
}
async dispatch(
context: RemoteModelGatewayDispatchContext,
request: RemoteModelGatewayRequest,
signal: AbortSignal
): Promise<RemoteModelGatewayResponse> {
signal.throwIfAborted()
const prepared = prepareDispatch(context, request)
this.#store.prepare({
identity: prepared.identity,
requestDigest: prepared.requestDigest,
modelProfileDigest: prepared.modelProfileDigest
})
const claim = this.#store.beginDispatch(
prepared.identity.callOperationId
)
if (!claim.permitted) {
throw new RemoteModelGatewayError(
'already-dispatched',
'Remote model request was already dispatched'
)
}
const timeoutController = new AbortController()
const timeoutError = new RemoteModelGatewayError(
'timeout',
'Remote model request timed out'
)
const timer = setTimeout(
() => timeoutController.abort(timeoutError),
this.#timeoutMs
)
const requestSignal = AbortSignal.any([
signal,
timeoutController.signal
])
let response: Response
try {
response = await this.#fetcher(prepared.url, {
method: 'POST',
redirect: 'error',
headers: prepared.headers,
body: prepared.body,
signal: requestSignal
})
} catch {
clearTimeout(timer)
const code =
timeoutController.signal.aborted
? 'timeout'
: signal.aborted
? 'cancelled'
: 'network-error'
return this.#markUnknownAndThrow(
prepared.identity.callOperationId,
code
)
}
let responseBody: Uint8Array
try {
responseBody = await readBoundedResponseBytes(response, {
maxBytes:
REMOTE_MODEL_GATEWAY_LIMITS.maximumResponseBodyBytes,
tooLargeMessage: 'Remote model response is too large',
truncatedMessage: 'Remote model response was truncated'
})
} catch (error) {
clearTimeout(timer)
const code =
timeoutController.signal.aborted
? 'timeout'
: signal.aborted
? 'cancelled'
: error instanceof BoundedResponseTooLargeError
? 'response-too-large'
: 'response-read-failed'
return this.#markUnknownAndThrow(
prepared.identity.callOperationId,
code
)
} finally {
clearTimeout(timer)
}
if (timeoutController.signal.aborted || signal.aborted) {
const code = timeoutController.signal.aborted
? 'timeout'
: 'cancelled'
return this.#markUnknownAndThrow(
prepared.identity.callOperationId,
code
)
}
const responseHeaders = selectResponseHeaders(response.headers)
const providerRequestId =
safeProviderIdentifier(
response.headers.get('x-request-id')
) ??
safeProviderIdentifier(
response.headers.get('openai-request-id')
) ??
safeProviderIdentifier(response.headers.get('request-id'))
let bridgeResponse: RemoteModelGatewayResponse
try {
bridgeResponse = remoteModelGatewayResponseSchema.parse({
status: response.status,
headers: responseHeaders,
bodyBase64: Buffer.from(responseBody).toString('base64')
})
} catch {
return this.#markUnknownAndThrow(
prepared.identity.callOperationId,
'invalid-bridge-response'
)
}
const parsedMetadata = parseResponseMetadata(responseBody)
this.#store.complete(prepared.identity.callOperationId, {
status: 'completed',
...(providerRequestId ? { providerRequestId } : {}),
...(parsedMetadata.providerResponseId
? { providerResponseId: parsedMetadata.providerResponseId }
: {}),
result: {
outputDigest: sha256(responseBody),
...(parsedMetadata.result ?? {})
},
...(parsedMetadata.usage ? { usage: parsedMetadata.usage } : {})
})
return bridgeResponse
}
markResponseDelivered(
context: Pick<
RemoteModelGatewayDispatchContext,
'bindingId' | 'promptOperationId' | 'roundIndex'
>
): void {
this.#store.markResponseDelivered(
createModelCallOperationId(context)
)
}
finalizePrompt(context: {
bindingId: string
promptOperationId: string
promptSequence: number
}): void {
this.#store.finalizePrompt(context)
}
#markUnknownAndThrow(
callOperationId: string,
code: string
): never {
this.#store.markOutcomeUnknown(callOperationId, {
status: 'outcome-unknown',
reason: {
code,
retryable: false
}
})
throw new RemoteModelGatewayError(
code,
remoteModelGatewayErrorMessage(code)
)
}
}
function prepareDispatch(
context: RemoteModelGatewayDispatchContext,
request: RemoteModelGatewayRequest
): {
identity: ReturnType<typeof modelCallIdentitySchema.parse>
requestDigest: string
modelProfileDigest: string
url: URL
headers: Record<string, string>
body: Uint8Array
} {
const parsedRequest = remoteModelGatewayRequestSchema.parse(request)
const profile = context.modelProfile
const modelProfileDigest = createResolvedModelProfileDigest(profile)
if (modelProfileDigest !== context.modelProfileDigest) {
throw new RemoteModelGatewayError(
'model-profile-digest-mismatch',
'Trusted model profile snapshot does not match its digest'
)
}
const url = providerUrl(profile, parsedRequest.path)
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password
) {
throw new RemoteModelGatewayError(
'invalid-provider-endpoint',
'Configured model provider endpoint is invalid'
)
}
if (profile.authentication === 'api-key' && !profile.apiKey) {
throw new RemoteModelGatewayError(
'missing-provider-credential',
'Configured model provider credential is unavailable'
)
}
const body = normalizeProviderRequestBody(
profile,
Buffer.from(parsedRequest.bodyBase64, 'base64')
)
const normalizedBodyBase64 = body.toString('base64')
const canonicalRequest = JSON.stringify({
profileId: profile.id,
protocol: profile.protocol,
authentication: profile.authentication,
model: profile.modelName,
url: url.toString(),
request: {
method: parsedRequest.method,
path: parsedRequest.path,
headers: Object.fromEntries(
Object.entries(parsedRequest.headers).sort(([left], [right]) =>
left.localeCompare(right)
)
),
bodyBase64: normalizedBodyBase64
}
})
const requestDigest = sha256(canonicalRequest)
const callOperationId = createModelCallOperationId(context)
const identity = modelCallIdentitySchema.parse({
callOperationId,
requestId: context.requestId,
bindingId: context.bindingId,
promptOperationId: context.promptOperationId,
promptSequence: context.promptSequence,
roundIndex: context.roundIndex,
provider:
profile.protocol === 'anthropic-messages'
? 'anthropic'
: 'openai',
profile: profile.id,
model: profile.modelName,
protocol: profile.protocol
})
const headers: Record<string, string> = {
...parsedRequest.headers
}
if (profile.protocol === 'anthropic-messages') {
headers['anthropic-version'] = '2023-06-01'
if (profile.authentication === 'api-key') {
headers['x-api-key'] = profile.apiKey!
}
} else if (profile.authentication === 'api-key') {
headers.authorization = `Bearer ${profile.apiKey!}`
}
return {
identity,
requestDigest,
modelProfileDigest,
url,
headers,
body
}
}
export function createModelCallOperationId(
context: Pick<
RemoteModelGatewayDispatchContext,
'bindingId' | 'promptOperationId' | 'roundIndex'
>
): string {
return `model-call-${createHash('sha256')
.update(
JSON.stringify({
bindingId: context.bindingId,
promptOperationId: context.promptOperationId,
roundIndex: context.roundIndex
})
)
.digest('hex')}`
}
export function createResolvedModelProfileDigest(
profile: ResolvedModelProfile
): string {
return sha256(
JSON.stringify({
id: profile.id,
name: profile.name,
baseUrl: profile.baseUrl,
modelName: profile.modelName,
protocol: profile.protocol,
authentication: profile.authentication,
supportsImageInput: profile.supportsImageInput === true,
contextWindowTokens: profile.contextWindowTokens ?? null,
maximumOutputTokens: profile.maximumOutputTokens ?? null,
imageGenerationQuality: profile.imageGenerationQuality ?? null
})
)
}
function normalizeProviderRequestBody(
profile: ResolvedModelProfile,
body: Buffer
): Buffer {
let value: unknown
try {
value = JSON.parse(
new TextDecoder('utf-8', { fatal: true }).decode(body)
) as unknown
} catch {
throw requestPolicyError()
}
if (
profile.protocol !== 'anthropic-messages' &&
profile.protocol !== 'openai-chat-completions' &&
profile.protocol !== 'openai-responses'
) {
throw requestPolicyError()
}
try {
assertTextModelRequestPolicy(
{
protocol: profile.protocol,
model: profile.modelName,
supportsImageInput: profile.supportsImageInput === true
},
value
)
} catch (error) {
if (!(error instanceof ModelRequestPolicyError)) {
throw error
}
throw requestPolicyError()
}
const request = { ...value }
return Buffer.from(JSON.stringify(request), 'utf8')
}
function requestPolicyError(): RemoteModelGatewayError {
return new RemoteModelGatewayError(
'request-policy-mismatch',
'Remote model request does not match the trusted model policy'
)
}
function providerUrl(
profile: ResolvedModelProfile,
path: string
): URL {
if (profile.protocol === 'anthropic-messages') {
if (path !== '/v1/messages') {
throw protocolPathError()
}
return createAnthropicMessagesUrl(profile.baseUrl)
}
if (profile.protocol === 'openai-chat-completions') {
if (
path !== '/chat/completions' &&
path !== '/v1/chat/completions'
) {
throw protocolPathError()
}
return createOpenAIChatCompletionsUrl(profile.baseUrl)
}
if (profile.protocol === 'openai-responses') {
if (path !== '/responses' && path !== '/v1/responses') {
throw protocolPathError()
}
return createOpenAIResponsesUrl(profile.baseUrl)
}
throw protocolPathError()
}
function protocolPathError(): RemoteModelGatewayError {
return new RemoteModelGatewayError(
'protocol-path-mismatch',
'Remote model request path does not match the configured protocol'
)
}
function selectResponseHeaders(
headers: Headers
): Record<string, string> {
const selected: Record<string, string> = {}
for (const name of RESPONSE_HEADER_NAMES) {
const value = headers.get(name)
const encodedBytes =
value === null ? 0 : new TextEncoder().encode(value).byteLength
if (
value !== null &&
encodedBytes > 0 &&
!/[\r\n]/u.test(value) &&
encodedBytes <= REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes
) {
selected[name] = value
}
}
return selected
}
function parseResponseMetadata(body: Uint8Array): {
providerResponseId?: string
result?: ModelCallResultMetadata
usage?: ModelCallUsageMetadata
} {
let parsed: unknown
try {
parsed = JSON.parse(new TextDecoder().decode(body)) as unknown
} catch {
return {}
}
if (!isRecord(parsed)) {
return {}
}
const providerResponseId = safeProviderIdentifier(parsed.id)
const finishReason = safeFinishReason(
isRecordArray(parsed.choices) &&
isRecord(parsed.choices[0])
? parsed.choices[0].finish_reason
: parsed.stop_reason
)
const resultCandidate = {
...(finishReason ? { finishReason } : {})
}
const result = modelCallResultMetadataSchema.safeParse(resultCandidate)
const usage = parseUsage(parsed.usage)
return {
...(providerResponseId ? { providerResponseId } : {}),
...(result.success && Object.keys(result.data).length > 0
? { result: result.data }
: {}),
...(usage ? { usage } : {})
}
}
function parseUsage(value: unknown): ModelCallUsageMetadata | undefined {
if (!isRecord(value)) {
return undefined
}
const promptDetails = isRecord(value.prompt_tokens_details)
? value.prompt_tokens_details
: undefined
const inputDetails = isRecord(value.input_tokens_details)
? value.input_tokens_details
: undefined
const completionDetails = isRecord(value.completion_tokens_details)
? value.completion_tokens_details
: undefined
const outputDetails = isRecord(value.output_tokens_details)
? value.output_tokens_details
: undefined
const candidate = {
inputTokens: safeTokenCount(value.input_tokens ?? value.prompt_tokens),
outputTokens: safeTokenCount(
value.output_tokens ?? value.completion_tokens
),
cachedInputTokens: safeTokenCount(
value.cache_read_input_tokens ??
inputDetails?.cached_tokens ??
promptDetails?.cached_tokens
),
reasoningTokens: safeTokenCount(
outputDetails?.reasoning_tokens ??
completionDetails?.reasoning_tokens
)
}
const compact = Object.fromEntries(
Object.entries(candidate).filter((entry) => entry[1] !== undefined)
)
const result = modelCallUsageMetadataSchema.safeParse(compact)
return result.success && Object.keys(result.data).length > 0
? result.data
: undefined
}
function safeTokenCount(value: unknown): number | undefined {
return typeof value === 'number' &&
Number.isSafeInteger(value) &&
value >= 0
? value
: undefined
}
function safeProviderIdentifier(value: unknown): string | undefined {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > 256 ||
value.trim() !== value ||
new TextEncoder().encode(value).byteLength > 256
) {
return undefined
}
return value
}
function safeFinishReason(value: unknown): string | undefined {
const result =
modelCallResultMetadataSchema.shape.finishReason.safeParse(value)
return result.success ? result.data : undefined
}
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value)
)
}
function isRecordArray(value: unknown): value is Record<string, unknown>[] {
return Array.isArray(value) && value.every(isRecord)
}
function sha256(value: string | Uint8Array): string {
return `sha256:${createHash('sha256').update(value).digest('hex')}`
}
function remoteModelGatewayErrorMessage(code: string): string {
switch (code) {
case 'timeout':
return 'Remote model request timed out after dispatch'
case 'cancelled':
return 'Remote model request was cancelled after dispatch'
case 'response-too-large':
return 'Remote model response exceeded the allowed size'
case 'response-read-failed':
return 'Remote model response could not be read'
case 'invalid-bridge-response':
return 'Remote model response could not be safely delivered'
default:
return 'Remote model request outcome is unknown'
}
}
+236 -1
View File
@@ -1,7 +1,8 @@
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import {
defaultContextCompressionSettings,
@@ -35,6 +36,12 @@ import {
} from '../../shared/goodbuddy-config-tools'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { SubagentScheduler } from '../assistant/subagent-scheduler'
import { createAnthropicMessagesUrl } from './anthropic-endpoint'
import {
createOpenAIChatCompletionsUrl,
createOpenAIResponsesUrl
} from './openai-endpoint'
import { createModelRequestProbe } from '../../../tests/support/model-request-probe'
const enabled = process.env.GOODBUDDY_RUN_RUNTIME_E2E === '1'
const apiKey =
@@ -65,6 +72,55 @@ const portableRoot = process.env.GOODBUDDY_E2E_PACKAGED_ROOT
'win-unpacked'
)
const bundledOpenCodePath = existsSync(
join(
portableRoot,
'resources',
'runtimes',
'opencode',
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
)
)
? join(
portableRoot,
'resources',
'runtimes',
'opencode',
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
)
: join(
process.cwd(),
'.runtime-resources',
process.arch,
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
)
const bundledContinuePath = existsSync(
join(
portableRoot,
'resources',
'runtimes',
'continue',
'dist',
'cn.js'
)
)
? join(
portableRoot,
'resources',
'runtimes',
'continue',
'dist',
'cn.js'
)
: join(
process.cwd(),
'node_modules',
'@continuedev',
'cli',
'dist',
'cn.js'
)
async function collectText(
events: AsyncGenerator<RuntimeEvent, void, void>
): Promise<string> {
@@ -324,6 +380,65 @@ describe.runIf(enabled)('runtime end-to-end', () => {
120_000
)
it(
'adds configured headers and body fields to one real direct model request',
async () => {
const upstreamUrl =
protocol === 'anthropic-messages'
? createAnthropicMessagesUrl(baseUrl)
: protocol === 'openai-responses'
? createOpenAIResponsesUrl(baseUrl)
: createOpenAIChatCompletionsUrl(baseUrl)
const probe = await createModelRequestProbe({
upstreamUrl,
headerName: 'x-goodbuddy-e2e',
bodyFieldNames: ['goodbuddy_e2e_probe'],
removeBodyFieldNames: ['goodbuddy_e2e_probe']
})
const runtime = new ModelAgentRuntime({
apiKey,
baseUrl: probe.baseUrl,
model: modelName,
protocol,
authentication: 'api-key',
requestHeaders: {
'x-goodbuddy-e2e': 'header-present'
},
requestBody: {
goodbuddy_e2e_probe: 'body-present'
}
})
try {
const output = await collectText(
runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
workMode: 'ask',
prompt:
'Return exactly this text and nothing else: CUSTOM_REQUEST_E2E_OK'
},
new AbortController().signal
)
)
expect(output).toContain('CUSTOM_REQUEST_E2E_OK')
expect(probe.observations).toEqual([
{
headerValue: 'header-present',
bodyFields: {
goodbuddy_e2e_probe: 'body-present'
}
}
])
} finally {
await runtime.dispose()
await probe.close()
}
},
180_000
)
it(
'uses real direct-model process execution and a programming Subagent to repair and verify code',
async () => {
@@ -1256,6 +1371,11 @@ describe.runIf(enabled)('runtime end-to-end', () => {
)
.join('')
).toContain('Prism Relay')
const modelCalls = events.filter(
(event) => event.type === 'model-usage'
).length
expect(modelCalls).toBe(2)
console.info(`REAL_OPENCODE_MCP_MODEL_CALLS=${modelCalls}`)
} finally {
await runtime.dispose()
await gateway.dispose()
@@ -1346,4 +1466,119 @@ describe.runIf(enabled)('runtime end-to-end', () => {
},
180_000
)
it.each([
['OpenCode', 'opencode-browser-e2e', 'OPENCODE_BROWSER_E2E_OK'],
['Continue', 'continue-browser-e2e', 'CONTINUE_BROWSER_E2E_OK']
] as const)(
'calls the assigned built-in browser through bundled %s',
async (runtimeName, conversationId, completionMarker) => {
const navigate = vi.fn(async (_conversationId: string, url: string) => ({
url,
origin: new URL(url).origin
}))
const browserService = {
getOrigin: vi.fn(() => undefined),
navigate,
snapshot: vi.fn(),
click: vi.fn(),
type: vi.fn(),
select: vi.fn(),
back: vi.fn(),
screenshot: vi.fn(),
releaseConversation: vi.fn(async () => undefined)
}
const gateway = new KnowledgeMcpGateway({} as never, {
browserService: browserService as never
})
await gateway.start()
const controller = new AbortController()
const requestId = crypto.randomUUID()
const capabilityToken = gateway.grant(
requestId,
[],
controller.signal,
'none',
undefined,
conversationId
)!
const common = {
modelProfile: {
id: crypto.randomUUID(),
name: 'E2E model',
baseUrl,
modelName,
apiKey,
protocol,
authentication: 'api-key' as const
},
defaultWorkspace: workspace,
knowledgeGateway: gateway
}
const runtime = new AgentRuntimeController(
runtimeName === 'OpenCode'
? new OpenCodeRuntime({
embedded: true,
binaryPath: '',
bundledBinaryPath: bundledOpenCodePath,
configPath: '',
...common
})
: new ContinueAgentRuntime({
binaryPath: '',
bundledBinaryPath: bundledContinuePath,
configPath: '',
hostCacheRoot: join(
workspace,
'.continue-browser-host'
),
...common
})
)
const targetUrl = `https://example.com/${conversationId}`
try {
const events = await collectEvents(
runtime.run(
{
requestId,
conversationId,
workMode: 'execute',
knowledgeCapabilityToken: capabilityToken,
prompt:
`Call browser_navigate exactly once with ${targetUrl}. ` +
`After the tool succeeds, reply with exactly ${completionMarker}.`
},
controller.signal,
async () => 'once'
)
)
expect(navigate).toHaveBeenCalledOnce()
expect(navigate).toHaveBeenCalledWith(
conversationId,
targetUrl,
expect.any(AbortSignal)
)
expect(
events
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
)
.join('')
).toContain(completionMarker)
const modelCalls = events.filter(
(event) => event.type === 'model-usage'
).length
expect(modelCalls).toBeGreaterThanOrEqual(1)
console.info(
`REAL_${runtimeName.toUpperCase()}_BROWSER_MODEL_CALLS=${modelCalls}`
)
} finally {
controller.abort()
await runtime.dispose()
await gateway.dispose()
}
},
180_000
)
})
@@ -288,6 +288,36 @@ describe('SelectedRuntimeManager', () => {
expect(cached.dispose).toHaveBeenCalledOnce()
})
it('uses a lightweight factory only for passive status probes', async () => {
const probed = runtime()
const tested = runtime()
const create = vi.fn(async () => tested.value)
const createStatus = vi.fn(async () => probed.value)
const manager = new SelectedRuntimeManager(
create,
undefined,
undefined,
undefined,
createStatus
)
const selection = { provider: 'opencode' as const }
await expect(manager.getStatus(selection)).resolves.toMatchObject({
available: true
})
expect(createStatus).toHaveBeenCalledWith(selection)
expect(create).not.toHaveBeenCalled()
expect(probed.dispose).toHaveBeenCalledOnce()
await expect(manager.testStatus(selection)).resolves.toMatchObject({
available: true
})
expect(create).toHaveBeenCalledWith(selection)
expect(tested.testConnection).toHaveBeenCalledOnce()
expect(tested.dispose).toHaveBeenCalledOnce()
await manager.dispose()
})
it('disposes a native-inventory runtime without caching it', async () => {
const inspected = runtime()
const cached = runtime()
+3 -2
View File
@@ -75,7 +75,8 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
private readonly maximumRetainedRuntimes =
DEFAULT_MAXIMUM_RETAINED_RUNTIMES,
private readonly observeFailure?:
DesktopDiagnosticFailureObserver
DesktopDiagnosticFailureObserver,
private readonly createStatusRuntime = createRuntime
) {
if (
!Number.isSafeInteger(maximumCachedRuntimes) ||
@@ -339,7 +340,7 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver {
private async runStatusProbe(
selection: AgentRuntimeSelection
): Promise<AgentRuntimeStatus> {
const runtime = await this.createRuntime(selection)
const runtime = await this.createStatusRuntime(selection)
try {
if (this.disposed) {
throw new Error('Agent Runtime 正在关闭')

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