fix: prevent runtime permission stalls and Agent reconnect failures
Execute could stall on workspace-external access or native child-session permissions, while structured questions could be lost or answered incorrectly. Route owned OpenCode descendant interactions, correct Continue question and custom-answer handling, and resolve DSH Execute ACP permissions without a second approval. Ask remains read-only and unrelated sessions stay isolated. Restore the required DSH session projection service. Reuse offline OpenCode configuration by plugin version, remove unused npm shims from remote payloads, normalize dependency modes, and accept real compound descriptors up to 4 MiB. Fix intermittent Agent attach rejection and closed-handshake waits. Add focused and real-Runtime regressions, document interaction boundaries and validation evidence, and include the existing application/tool navigation design drafts. Those navigation changes are design-only, not implemented UI. Validation: full suite 3597 passed, 0 failed, 61 skipped across 338 files; typecheck, lint, release-note verification, whitespace and relative-link checks passed. Current-source Linux x64 installation, Ask/Execute, native subagent external writes, reconnect and stop/bootstrap passed with 10 completed real model calls, 13 native endpoint tests and 20 consecutive handshakes. System sleep/wake and release CI packaging remain unverified. Release note: 修复 Execute 访问工作区外目录或运行原生子代理时卡在权限等待的问题,并恢复 OpenCode 与 Continue 结构化问答的正确转交。修复远程 Agent 安装与重连失败;Ask 仍保持只读,任意终端密码或业务确认不会自动代答。
This commit is contained in:
@@ -56,6 +56,14 @@ OpenCode/Continue 用例默认读取 `dist/harness-package-probe/win-unpacked`
|
||||
调用。测试不会输出 API Key,文件操作在临时工作区中执行。文件包含经 Main 回环
|
||||
broker 调用已分配自定义 MCP 的真实 OpenCode 和 Continue 用例。
|
||||
|
||||
不使用外部模型的 Runtime 原生交互回归位于
|
||||
`src/main/agent/opencode-runtime-permissions.test.ts`、
|
||||
`src/main/agent/continue-runtime-permissions.test.ts` 和
|
||||
`src/main/agent/deepseek-harness-acp-e2e.test.ts`。它们用本地确定性模型驱动真实 Runtime
|
||||
或固定 Host,覆盖工作区外访问、原生问答及 Ask 边界。OpenCode 用例默认使用开发缓存
|
||||
二进制;可通过 `GOODBUDDY_TEST_OPENCODE_BINARY` 指定已经安装的候选二进制,避免把
|
||||
仍在运行的旧缓存版本当作候选版本验证。这不替代当前源码 Agent 的 Linux Host 验收。
|
||||
|
||||
## 生产构建
|
||||
|
||||
生成 Electron Main、Preload 和 Renderer 生产文件:
|
||||
@@ -211,6 +219,8 @@ package format v1 归档已经包含固定 Agent 和 Node,可直接用于 Host
|
||||
桌面包会携带经过版本与完整性校验的 OpenCode、Continue 和 DSH 插件安装 Runtime:
|
||||
|
||||
- OpenCode 平台二进制来自 `.runtime-resources/<arch>`。
|
||||
- OpenCode 离线配置位于 `.runtime-resources/opencode-config`,构建时按已安装 `@opencode-ai/plugin/package.json` 的版本复用,不依赖根 lockfile 或自定义 ready/integrity 标记。缺失、不可读或版本不匹配时,在 staging 中执行精确版本 npm 安装,成功后替换缓存;安装失败保留旧缓存并清理 staging。本机共享配置同样比较随包插件与缓存插件的版本,版本不同时通过 staging 替换。
|
||||
- 离线依赖安装禁用 npm bin links;远程 Runtime 组包排除不使用的 `.bin` 命令链接,并把复制后的普通依赖文件统一为 `0644`,OpenCode 入口仍为 `0755`。目录元数据、每个依赖文件及许可证继续纳入签名校验。
|
||||
- Continue Runtime 来自锁定版本的 `@continuedev/cli`。
|
||||
- DSH 插件安装使用精确锁定并从 `app.asar` 解包的 npm CLI,通过当前 Electron 的 Node 模式运行;最终用户不需要另装 Node.js 或 npm。
|
||||
- DSH 图片输入使用精确锁定的 `@napi-rs/canvas` 完整解码 JPEG/PNG。通用包与目标平台、目标架构的 Skia 原生包必须从 `app.asar` 解包;当打包 Runner 的架构与目标架构不同时,发布脚本会根据 lockfile 的精确版本、下载地址和 integrity 临时暂存目标原生包,完成后清理。发布校验会检查版本、目标架构和 MIT 许可证。
|
||||
|
||||
+9
-3
@@ -119,8 +119,8 @@ otherwise.
|
||||
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,
|
||||
Agent tool-dispatch boundary, while Execute explicitly allows native permissions
|
||||
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 v5. Work continues on
|
||||
the Host after Desktop exit, network loss, or local-process termination.
|
||||
@@ -217,7 +217,13 @@ otherwise.
|
||||
code is not subject to Ask initialization isolation.
|
||||
- [x] **Ask and Execute work modes**: Ask remains read-only. Execute is the
|
||||
user's authorization for all tools, processes, network access, and writable
|
||||
paths available to the current local or SSH account.
|
||||
paths available to the current local or SSH account, including paths outside
|
||||
the workspace and native subagent work.
|
||||
- [x] **Native Runtime interaction routing**: OpenCode and Continue questions
|
||||
support choices, yes/no, free-text answers, and skipping through the existing
|
||||
question card. OpenCode also routes questions from owned child sessions.
|
||||
Execute permission confirmations are handled automatically rather than
|
||||
waiting for another approval. See the [interaction boundaries](./docs/features/assistant-workbar/runtime-interactions.md).
|
||||
- [x] **Experts and Subagents**: Supports explicit experts, team analysis, and
|
||||
up to three read-only experts running in parallel. Chat shows each
|
||||
expandable full expert response first and the main Agent's synthesis below,
|
||||
|
||||
+2
-1
@@ -35,7 +35,8 @@
|
||||
- [ ] **SSH Host 环境准备真实 Host 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。
|
||||
- [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;优先使用管理员提供的连接,否则跟随兼容的默认模型或首个兼容连接,无需单独重复选择。Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。
|
||||
- [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。
|
||||
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权。
|
||||
- [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权,包括工作区外路径及原生子代理工作。
|
||||
- [x] **Runtime 原生交互转交**:OpenCode 与 Continue 的选择、yes/no、自由文本回答和跳过使用现有问答卡片,OpenCode 同时转交属于当前请求的子会话提问。Execute 权限确认自动处理,不等待第二次审批;具体支持范围见[交互边界](./docs/features/assistant-workbar/runtime-interactions.md)。
|
||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。
|
||||
- [x] **OpenCode 子代理过程与最终结果**:子代理卡片按顺序显示文字、推理和工具过程,最终结果独立展示。远程实时过程需要 Agent `0.11.20`,旧包仍显示最终结果但不补造缺失过程。
|
||||
- [x] **OpenCode 事件连接回收**:请求结束或取消后关闭自身订阅,不取消其他并行会话。
|
||||
|
||||
+3
-2
@@ -323,8 +323,9 @@
|
||||
能力自动增删稳定应用目录入口。
|
||||
- Tab 行只显示已打开实例,行末始终提供“+”普通按钮;点击后在内容区打开应用选择页。
|
||||
“+”不使用 `tab` 语义,也不能因为终端或其他应用已经打开而消失。
|
||||
- 应用注册表声明单实例或多实例。单实例重复选择时聚焦已有 Tab;终端从首期开始支持
|
||||
多实例,每次选择创建独立 Tab 和 Shell Session。
|
||||
- 应用注册表声明单实例或多实例。在工作栏“+”目录中,单实例重复选择时聚焦已有 Tab;终端
|
||||
从首期开始支持多实例,每次选择创建独立 Tab 和 Shell Session。主侧栏系统工具快捷入口
|
||||
可以聚焦当前执行空间中最近使用的终端,额外终端仍从“+”目录创建。
|
||||
- Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task
|
||||
只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,
|
||||
也不得把 Job/Run 提升为可导航 UI 对象。
|
||||
|
||||
@@ -50,6 +50,7 @@ const maximumPackageEntries = 50_002
|
||||
const maximumPackageEntryBytes = 384 * 1024 * 1024
|
||||
const maximumExpandedPackageBytes = 1024 * 1024 * 1024
|
||||
const maximumMetadataBytes = 1024 * 1024
|
||||
const maximumDescriptorBytes = 4 * 1024 * 1024
|
||||
const maximumCentralDirectoryBytes = 32 * 1024 * 1024
|
||||
const maximumCatalogEntries = 200
|
||||
const windowsReservedNamePattern =
|
||||
@@ -412,7 +413,10 @@ function streamPackageMetadata(archivePath) {
|
||||
entry.name === 'agent-package.json' ||
|
||||
entry.name === 'agent-package.sig'
|
||||
const size = entry.recordEnd - entry.dataOffset
|
||||
if (wanted && size > maximumMetadataBytes) {
|
||||
const maximum = entry.name === 'agent-package.json'
|
||||
? maximumDescriptorBytes
|
||||
: maximumMetadataBytes
|
||||
if (wanted && size > maximum) {
|
||||
throw new Error('Agent package metadata exceeds its limit')
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,6 @@ const {
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
@@ -24,41 +23,15 @@ function npmInvocation() {
|
||||
return { command: "npm", prefixArgs: [] };
|
||||
}
|
||||
|
||||
function pluginIntegrity(projectDir) {
|
||||
const lock = JSON.parse(
|
||||
readFileSync(join(projectDir, "package-lock.json"), "utf8"),
|
||||
);
|
||||
const entry = lock.packages?.[`node_modules/${opencodePluginPackage}`];
|
||||
if (
|
||||
entry?.version !== opencodeVersion ||
|
||||
typeof entry.integrity !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
`Missing locked ${opencodePluginPackage}@${opencodeVersion} integrity`,
|
||||
);
|
||||
}
|
||||
return entry.integrity;
|
||||
}
|
||||
|
||||
function prepareBundledOpenCodeConfig(projectDir) {
|
||||
const targetDirectory = join(
|
||||
projectDir,
|
||||
".runtime-resources",
|
||||
"opencode-config",
|
||||
);
|
||||
const identity = {
|
||||
packageName: opencodePluginPackage,
|
||||
version: opencodeVersion,
|
||||
integrity: pluginIntegrity(projectDir),
|
||||
};
|
||||
const markerPath = join(targetDirectory, ".goodbuddy-ready.json");
|
||||
try {
|
||||
const ready = JSON.parse(readFileSync(markerPath, "utf8"));
|
||||
if (
|
||||
ready.packageName === identity.packageName &&
|
||||
ready.version === identity.version &&
|
||||
ready.integrity === identity.integrity &&
|
||||
statSync(
|
||||
const installed = JSON.parse(
|
||||
readFileSync(
|
||||
join(
|
||||
targetDirectory,
|
||||
"node_modules",
|
||||
@@ -66,8 +39,10 @@ function prepareBundledOpenCodeConfig(projectDir) {
|
||||
"plugin",
|
||||
"package.json",
|
||||
),
|
||||
).isFile()
|
||||
) {
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
if (installed.version === opencodeVersion) {
|
||||
return targetDirectory;
|
||||
}
|
||||
} catch {
|
||||
@@ -102,6 +77,7 @@ function prepareBundledOpenCodeConfig(projectDir) {
|
||||
...npm.prefixArgs,
|
||||
"install",
|
||||
"--ignore-scripts",
|
||||
"--bin-links=false",
|
||||
"--omit=dev",
|
||||
"--omit=optional",
|
||||
"--no-audit",
|
||||
@@ -127,11 +103,6 @@ function prepareBundledOpenCodeConfig(projectDir) {
|
||||
}`,
|
||||
);
|
||||
}
|
||||
writeFileSync(
|
||||
join(stagingDirectory, ".goodbuddy-ready.json"),
|
||||
`${JSON.stringify(identity)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
rmSync(targetDirectory, { recursive: true, force: true });
|
||||
mkdirSync(join(targetDirectory, ".."), { recursive: true });
|
||||
renameSync(stagingDirectory, targetDirectory);
|
||||
|
||||
@@ -764,9 +764,15 @@ function buildRuntimeBundle(options) {
|
||||
platform,
|
||||
);
|
||||
const configTemplate = prepareBundledOpenCodeConfig(projectRoot);
|
||||
cpSync(configTemplate, join(staging, "config", "opencode"), {
|
||||
const configDestination = join(staging, "config", "opencode");
|
||||
cpSync(configTemplate, configDestination, {
|
||||
recursive: true,
|
||||
// npm CLI shims are not used by the imported plugin and may be symlinks.
|
||||
filter: (source) => basename(source) !== ".bin",
|
||||
});
|
||||
for (const file of listFiles(configDestination)) {
|
||||
chmodSync(file, 0o644);
|
||||
}
|
||||
mkdirSync(join(staging, "licenses"), { mode: 0o700 });
|
||||
copyFileSync(
|
||||
join(projectRoot, "node_modules", "opencode-ai", "LICENSE"),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# 应用导航与系统工具入口
|
||||
|
||||
本功能整理 GoodBuddy 主侧栏的入口层级:系统入口保持固定,应用占用主导航,系统工具从
|
||||
侧栏底部快捷打开助手工作栏。该功能同时为后续插件应用和插件工具预留统一的入口类型,但
|
||||
不包含插件市场的安装与运行机制。
|
||||
|
||||
## 文档导航
|
||||
|
||||
| 文档 | 权威职责 |
|
||||
| --- | --- |
|
||||
| [PRD](./prd.md) | 用户问题、产品边界、功能要求与产品验收 |
|
||||
| [User Stories](./user-stories.md) | 用户场景及 Given/When/Then 验收 |
|
||||
| [功能逻辑设计](./logic-design.md) | 分类规则、入口状态、优先级与失败结果 |
|
||||
| [UI 设计](./ui-design.md) | 侧栏信息架构、控件、文案、键盘和响应式行为 |
|
||||
| [技术设计](./technical-design.md) | 现有模块映射、状态契约、持久化和实施顺序 |
|
||||
|
||||
## 术语
|
||||
|
||||
| 术语 | 定义 |
|
||||
| --- | --- |
|
||||
| 系统入口 | GoodBuddy 工作流程或审计所必需的固定入口,用户不能卸载、隐藏或改变类型 |
|
||||
| 应用 | 在主内容区打开完整一级页面的功能,可显示在“当前对话”所在的主导航区 |
|
||||
| 系统工具 | GoodBuddy 自带且不可卸载的辅助能力,从侧栏底部打开助手工作栏中的对应面板 |
|
||||
| 插件应用 | 未来由插件提供的一级页面入口 |
|
||||
| 插件工具 | 未来由插件提供的辅助面板或命令入口 |
|
||||
| 快捷入口 | 只负责打开或聚焦能力,不复制该能力的界面和业务状态 |
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md)
|
||||
- [通用助手工作栏与执行空间 PRD](../assistant-workbar/prd.md)
|
||||
- [助手工作栏多终端页签 PRD](../assistant-workbar/terminal-tabs-prd.md)
|
||||
- [智能心跳 PRD](../smart-heartbeat/prd.md)
|
||||
- [知识库功能文档](../knowledge-base/README.md)
|
||||
@@ -0,0 +1,140 @@
|
||||
# 应用导航与系统工具入口功能逻辑设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 关联 User Stories | [User Stories](./user-stories.md) |
|
||||
|
||||
## 1. 不变量
|
||||
|
||||
1. “当前对话”“运行记录”和“设置”始终是系统入口。
|
||||
2. 运行记录不进入工具注册表、应用显隐设置或插件市场。
|
||||
3. 文件浏览器、终端和浏览器始终是系统工具,不能卸载或被插件替换。
|
||||
4. 左下角只提供快捷入口,工具内容仍由助手工作栏承载。
|
||||
5. 隐藏应用不等于停用功能,也不删除、迁移或清理业务数据。
|
||||
6. 快捷入口不能扩大执行空间、文件、PTY、网络或浏览器权限。
|
||||
|
||||
## 2. 入口模型
|
||||
|
||||
```ts
|
||||
type NavigationContribution =
|
||||
| {
|
||||
type: 'system-entry'
|
||||
id: 'chat' | 'activity' | 'settings'
|
||||
placement: 'primary-start' | 'primary-end' | 'footer'
|
||||
}
|
||||
| {
|
||||
type: 'application'
|
||||
id: 'magic-notes' | 'knowledge' | 'heartbeat'
|
||||
source: 'builtin'
|
||||
visible: boolean
|
||||
}
|
||||
| {
|
||||
type: 'system-tool'
|
||||
id: 'workspace' | 'terminal' | 'browser'
|
||||
workbarAppId: 'workspace' | 'terminal' | 'browser'
|
||||
}
|
||||
```
|
||||
|
||||
该模型只覆盖首期入口。插件贡献模型随插件 Host、权限和生命周期另行设计。
|
||||
|
||||
## 3. 主导航组合规则
|
||||
|
||||
主导航按以下顺序计算:
|
||||
|
||||
```text
|
||||
当前对话
|
||||
可见的内置应用
|
||||
运行记录
|
||||
```
|
||||
|
||||
当前对话和运行记录不参与排序。首期内置应用默认顺序为魔法笔记、知识库、智能心跳;显隐
|
||||
设置只从中移除对应项,不改变固定入口的位置。
|
||||
|
||||
### 3.1 应用可见性与业务状态
|
||||
|
||||
| 导航可见 | 功能启用 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 是 | 是 | 显示入口并允许进入 |
|
||||
| 否 | 是 | 隐藏入口,后台和数据保持原状 |
|
||||
| 是 | 否 | 不显示不可用入口;设置页保留启用路径 |
|
||||
| 否 | 否 | 不显示入口,保留符合该功能自身规则的数据 |
|
||||
|
||||
“功能启用”由各应用现有产品设置负责。例如魔法笔记启停仍使用原设置;导航偏好不能覆盖它。
|
||||
|
||||
## 4. 系统工具打开规则
|
||||
|
||||
### 4.1 通用流程
|
||||
|
||||
```text
|
||||
用户激活工具按钮
|
||||
-> 校验按钮仍可用
|
||||
-> 向助手工作栏发送打开意图
|
||||
-> 工作栏按应用实例策略查找或创建实例
|
||||
-> 展开工作栏
|
||||
-> 激活目标实例
|
||||
-> 把焦点移入面板标题或首个主要控件
|
||||
```
|
||||
|
||||
若创建失败,工作栏保持展开并在对应能力位置显示错误;主页面和其他工作栏实例不被清空。
|
||||
|
||||
### 4.2 决策表
|
||||
|
||||
| 工具 | 匹配实例存在 | 匹配实例不存在 | 上下文不可用 |
|
||||
| --- | --- | --- | --- |
|
||||
| 文件 | 激活单实例并保留其跟随或固定状态 | 创建跟随当前上下文的 `workspace` 单实例 | 打开面板并显示目录修复入口 |
|
||||
| 终端 | 激活当前执行空间最近的 `starting` 或 `running` 实例 | 创建一个绑定当前执行空间的实例 | 没有项目时使用本机 Home |
|
||||
| 浏览器 | 激活单实例并保留其目标状态 | 创建跟随当前会话的 `browser` 单实例 | 打开面板并提示选择或创建对话 |
|
||||
|
||||
文件和浏览器按以下优先级处理目标:已有有效固定目标、跟随模式的当前上下文、新实例的默认
|
||||
上下文。固定目标失效时显示该目标的修复状态,不回退到当前项目、默认工作区或当前会话。
|
||||
|
||||
`closing`、`exited`、`interrupted` 和 `failed` 终端不属于可复用实例。当前执行空间只有这些
|
||||
终端时,快捷按钮创建新实例并保留原 Tab 的历史状态。用户需要额外的并行终端时使用工作栏
|
||||
“+”或终端面板中的既有创建命令。
|
||||
|
||||
### 4.3 选中与状态
|
||||
|
||||
工具按钮满足以下条件时显示选中:助手工作栏已展开,且当前活动实例的 `appId` 与工具映射
|
||||
一致。只有实例存在但未激活时不显示选中。
|
||||
|
||||
徽标优先级为:等待用户处理 > 错误 > 运行中 > 无徽标。同一按钮只显示一个视觉徽标,
|
||||
可访问名称包含完整状态。
|
||||
|
||||
## 5. 设置保存规则
|
||||
|
||||
应用导航偏好与应用业务启停分开保存。Switch 每次切换立即提交完整目标集合;请求完成前禁用
|
||||
三个应用显隐 Switch,下一次操作基于最新确认结果提交,防止并发更新互相覆盖。
|
||||
|
||||
保存失败时:
|
||||
|
||||
- 主导航和 Switch 恢复最后一次成功保存的偏好。
|
||||
- 设置行显示可重试错误。
|
||||
- 不回滚或修改各应用的业务状态。
|
||||
|
||||
## 6. 与需求的对应关系
|
||||
|
||||
| 逻辑 | PRD | User Stories |
|
||||
| --- | --- | --- |
|
||||
| 固定入口不变量 | FR-1、FR-2 | US-A1、US-A2 |
|
||||
| 应用显隐和失败恢复 | FR-3、FR-4、FR-10、FR-11 | US-B1、US-B2、US-B4、US-B5 |
|
||||
| 工具打开决策表 | FR-5 至 FR-9 | US-C1 至 US-C6 |
|
||||
| 无障碍与窄窗口 | FR-12 | US-D1、US-D2 |
|
||||
|
||||
## 7. 逻辑完整性评估
|
||||
|
||||
首期规则已覆盖固定入口、应用显隐、保存失败、系统工具单实例与多实例差异、本机 Home、
|
||||
托管 SSH、无活动会话、目录失效、权限边界、键盘和窄窗口。工具面板内部的取消、超时、
|
||||
连接恢复和固定目标失效继续由助手工作栏负责,本功能不复制这些状态机。
|
||||
|
||||
## 8. 后续产品决策
|
||||
|
||||
- 插件工具上线后,快捷栏允许固定的最大数量及溢出规则。
|
||||
- 插件应用是否允许用户排序,以及固定系统入口之间允许的排序范围。
|
||||
|
||||
这些决策不影响首期三个系统工具的固定入口和内置应用显隐实现。
|
||||
@@ -0,0 +1,158 @@
|
||||
# 应用导航与系统工具入口 PRD
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 适用产品 | GoodBuddy 桌面端 |
|
||||
| 关联设计 | [统一界面设计系统](../../../UI-DESIGN.md)、[助手工作栏 PRD](../assistant-workbar/prd.md) |
|
||||
|
||||
## 1. 背景
|
||||
|
||||
主侧栏当前把“当前对话”“魔法笔记”“知识库”“智能心跳”和“运行记录”放在同一导航组,
|
||||
底部长期显示“GB 本地工作区”和平台架构。底部信息不能反映用户可以执行的操作,文件、终端
|
||||
和浏览器则需要先找到右侧助手工作栏后才能打开。
|
||||
|
||||
后续插件可能提供完整页面,也可能只提供辅助面板或命令。若不先区分系统入口、应用和工具,
|
||||
插件接入后会同时争用主导航、侧栏底部和助手工作栏,用户也无法判断入口会切换页面还是打开
|
||||
辅助面板。
|
||||
|
||||
## 2. 产品定义
|
||||
|
||||
### 2.1 三类入口
|
||||
|
||||
| 类型 | 首期成员 | 默认位置 | 用户控制范围 |
|
||||
| --- | --- | --- | --- |
|
||||
| 系统入口 | 当前对话、运行记录、设置 | 主导航固定位置或侧栏底部 | 不可隐藏、卸载或改变类型 |
|
||||
| 应用 | 魔法笔记、知识库、智能心跳 | 主导航应用区 | 可控制是否显示;隐藏不删除数据 |
|
||||
| 系统工具 | 文件浏览器、终端、浏览器 | 侧栏底部快捷工具栏 | 首期固定提供,不可卸载 |
|
||||
|
||||
运行记录是系统审计页面,不属于工具。文件浏览器、终端和浏览器是系统工具;它们的快捷入口
|
||||
位于左下角,实际界面继续由右侧助手工作栏承载。
|
||||
|
||||
### 2.2 后续插件类型
|
||||
|
||||
插件市场开放后,插件可以贡献“插件应用”或“插件工具”。插件不能注册、覆盖或替换系统入口,
|
||||
也不能冒充系统工具。内置应用继续标记为“内置”,不因采用统一注册协议而显示成待安装商品。
|
||||
|
||||
## 3. 目标
|
||||
|
||||
- 用户从侧栏底部一次点击即可打开文件、终端或浏览器。
|
||||
- 主导航只承载完整页面,辅助工具不占用一级页面位置。
|
||||
- 运行记录始终可发现,不受应用显示设置和插件状态影响。
|
||||
- 内置应用可以从一个设置位置控制主导航可见性,隐藏后仍可恢复。
|
||||
- 为插件应用和插件工具保留明确的入口契约,不提前建设插件安装能力。
|
||||
|
||||
## 4. 非目标
|
||||
|
||||
- 首期不提供第三方插件搜索、下载、安装、更新、签名验证或卸载。
|
||||
- 不把文件树、终端或浏览器重新实现到左侧栏。
|
||||
- 不改变助手工作栏的面板实例、停靠、执行空间和安全边界。
|
||||
- 不允许隐藏运行记录、当前对话或设置。
|
||||
- 不因隐藏应用而删除魔法笔记、知识库或智能心跳数据。
|
||||
- 不把“显示在侧边栏”解释为停止智能心跳计划或后台任务。
|
||||
- 不提供任意本机程序启动器。
|
||||
|
||||
## 5. 功能要求
|
||||
|
||||
### FR-1 入口分类
|
||||
|
||||
系统必须为每个可见入口声明稳定类型:`system-entry`、`application` 或 `system-tool`。界面位置、
|
||||
设置项和点击结果均以该类型为准,不根据当前页面临时改变类型。
|
||||
|
||||
### FR-2 固定系统入口
|
||||
|
||||
“当前对话”固定在主导航首位;“运行记录”固定在应用区之后;“设置”固定在侧栏底部。
|
||||
这些入口不出现在应用显隐、工具固定或插件管理设置中。
|
||||
|
||||
### FR-3 内置应用区
|
||||
|
||||
魔法笔记、知识库和智能心跳显示在当前对话与运行记录之间。应用入口打开对应一级页面,保留
|
||||
现有徽标、范围和页面状态。用户可以在设置中控制其主导航可见性。
|
||||
|
||||
### FR-4 隐藏应用
|
||||
|
||||
隐藏应用只移除主导航入口,不删除数据、不修改业务启停状态,也不终止正在进行的操作。显隐
|
||||
Switch 更新成功后主导航立即生效;更新失败时恢复原状态并允许重试。用户可从设置恢复入口。
|
||||
|
||||
### FR-5 系统工具快捷栏
|
||||
|
||||
侧栏底部提供文件浏览器、终端和浏览器三个图标按钮,并保留独立设置按钮。每个按钮具有可见
|
||||
工具提示和可访问名称。首期系统工具顺序固定为文件、终端、浏览器。
|
||||
|
||||
### FR-6 文件浏览器入口
|
||||
|
||||
点击文件按钮时展开助手工作栏,并打开或聚焦单实例的“工作区”面板。已有实例保持其“跟随
|
||||
当前上下文”或“固定到指定对象”状态;创建实例时默认跟随当前项目,没有项目时使用已配置
|
||||
的默认工作区。跟随上下文或新建实例所需的路径不可用时,面板显示修复入口;已有有效固定
|
||||
目标不受当前项目和默认工作区状态影响。
|
||||
|
||||
### FR-7 终端入口
|
||||
|
||||
点击终端按钮时展开助手工作栏。当前执行空间有正在启动或运行中的终端时聚焦最近使用的实例;
|
||||
没有时创建一个绑定当前执行空间的新终端,已退出、已中断或失败的历史 Tab 保持不变。没有
|
||||
项目时当前执行空间为本机 Home。创建额外并行终端继续使用工作栏现有的“+”目录入口。
|
||||
|
||||
### FR-8 浏览器入口
|
||||
|
||||
点击浏览器按钮时展开助手工作栏,并打开或聚焦单实例的受控浏览器面板。已有实例保持其目标
|
||||
状态;新实例默认跟随当前会话。新建或跟随模式下没有活动会话时,面板显示需要选择或创建
|
||||
会话的空状态;已有有效固定目标继续显示。入口不打开系统默认浏览器,也不创建旁路 WebView。
|
||||
|
||||
### FR-9 快捷入口状态
|
||||
|
||||
工具面板已激活时,对应按钮显示选中状态;面板存在但未激活时保持普通状态。运行中、错误或
|
||||
等待用户处理可以显示徽标,但必须有可访问文字,且不得自动抢占当前面板。
|
||||
|
||||
### FR-10 应用导航设置
|
||||
|
||||
设置页提供“应用与工具”管理入口。首期“应用”区域列出三个内置应用并提供“显示在侧边栏”
|
||||
Switch,切换后立即持久化。系统工具区域只说明其为内置固定能力,不提供虚假的卸载或停用
|
||||
按钮。
|
||||
|
||||
### FR-11 偏好持久化
|
||||
|
||||
应用可见性按设备持久化。升级时保留当前行为:已有可用内置应用继续显示;魔法笔记原有功能
|
||||
启停设置保持原意,不由新的导航可见性覆盖。
|
||||
|
||||
### FR-12 窄窗口行为
|
||||
|
||||
窄窗口下主侧栏仍按现有抽屉方式显示。系统工具按钮保持单行且不与设置按钮重叠;空间不足时
|
||||
收起文字和非必要分隔,不隐藏三个系统工具或设置。
|
||||
|
||||
## 6. 权限与安全
|
||||
|
||||
- 快捷入口不授予新的文件、命令、网络或浏览器权限。
|
||||
- 系统工具继续使用 Main、Preload、执行空间 Grant、Ask/Execute 和现有审批边界。
|
||||
- Renderer 不因新增入口获得任意文件系统、PTY、进程或 Electron API。
|
||||
- 插件贡献类型只是界面与导航声明,不能代替权限清单、用户授权和运行隔离。
|
||||
- 运行记录继续记录由系统工具触发的受审计操作。
|
||||
|
||||
## 7. 产品验收
|
||||
|
||||
- 主导航能明确区分固定系统入口和可配置内置应用。
|
||||
- 运行记录在所有应用显隐组合下保持可见并可打开。
|
||||
- 左下角不再用“本地工作区”与系统架构占据主要空间。
|
||||
- 文件、终端和浏览器均可从左下角打开正确的工作栏面板,且不产生第二套内容界面。
|
||||
- 连续点击系统工具不会无意创建重复的文件或浏览器实例;终端遵守多实例规则。
|
||||
- 隐藏内置应用不删除数据、不停止后台计划,并能从设置恢复;保存失败时入口保持原状。
|
||||
- 键盘、屏幕阅读器、浅色主题、深色主题和窄窗口均可完成相同操作。
|
||||
|
||||
## 8. 分期
|
||||
|
||||
### 首期
|
||||
|
||||
- 重构侧栏底部,增加三个系统工具按钮和独立设置按钮。
|
||||
- 建立从 App 到助手工作栏的“打开或聚焦工具”意图接口。
|
||||
- 增加内置应用的主导航显示设置。
|
||||
- 保持运行记录为固定系统入口。
|
||||
|
||||
### 后续
|
||||
|
||||
- 插件应用和插件工具注册。
|
||||
- 插件工具固定、取消固定和排序。
|
||||
- 插件市场、权限详情、安装和卸载。
|
||||
- 用户级快捷键与命令面板接入同一贡献命令。
|
||||
@@ -0,0 +1,242 @@
|
||||
# 应用导航与系统工具入口技术设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| UI 设计 | [UI 设计](./ui-design.md) |
|
||||
|
||||
## 1. 现状
|
||||
|
||||
主页面没有 URL Router,由 `App.tsx` 中的 `WorkspaceView` 和 keep-alive 状态切换:
|
||||
|
||||
```ts
|
||||
type WorkspaceView =
|
||||
| 'chat'
|
||||
| 'magic-notes'
|
||||
| 'knowledge'
|
||||
| 'heartbeat'
|
||||
| 'activity'
|
||||
| 'settings'
|
||||
```
|
||||
|
||||
文件、浏览器和终端已经由 `RightAssistantSidebar`、`WorkbarShell` 与 `workbar-contracts.ts`
|
||||
承载。工作区与浏览器是单实例应用,终端是多实例应用。现有侧栏底部 `user-card` 整行打开
|
||||
设置,并显示“本地工作区”和平台架构。
|
||||
|
||||
本次改动只增加入口编排和一个从主侧栏到工作栏的命令通道,不改变各工具后端。
|
||||
|
||||
## 2. 模块边界
|
||||
|
||||
| 模块 | 责任 |
|
||||
| --- | --- |
|
||||
| `App.tsx` | 组合主导航、系统工具栏、设置入口和页面离开检查 |
|
||||
| `SidebarSystemTools`(新增) | 渲染三个系统工具按钮并发送打开意图 |
|
||||
| `RightAssistantSidebar.tsx` | 接收打开意图,调用工作栏实例创建或激活逻辑 |
|
||||
| `WorkbarShell.tsx` | 继续负责实例策略、Tab、目录和焦点行为 |
|
||||
| `workbar-contracts.ts` | 继续作为工作栏应用 ID 和实例布局的权威契约 |
|
||||
| `application-settings-contracts.ts` | 增加应用导航偏好 schema 和默认值 |
|
||||
| `application-settings-store.ts` | 迁移并原子保存应用导航偏好 |
|
||||
| `PlatformFeaturesSettingsSection` 或独立设置 Section | 编辑“显示在侧边栏”偏好 |
|
||||
|
||||
不要在 `SidebarSystemTools` 中读取文件、创建 PTY 或操作浏览器;该组件只发送有界的工具 ID。
|
||||
|
||||
## 3. Renderer 契约
|
||||
|
||||
### 3.1 系统工具 ID
|
||||
|
||||
```ts
|
||||
export const sidebarSystemToolIds = [
|
||||
'workspace',
|
||||
'terminal',
|
||||
'browser'
|
||||
] as const
|
||||
|
||||
export type SidebarSystemToolId = (typeof sidebarSystemToolIds)[number]
|
||||
```
|
||||
|
||||
该类型可以放在 Renderer 内共享模块。首期不暴露 IPC,因为动作只编排已有工作栏 Renderer
|
||||
状态;实际能力调用继续走现有 Preload API。
|
||||
|
||||
### 3.2 打开意图
|
||||
|
||||
`App` 向 `RightAssistantSidebar` 传入单调递增的请求或命令对象:
|
||||
|
||||
```ts
|
||||
type WorkbarOpenIntent = {
|
||||
requestId: number
|
||||
appId: 'workspace' | 'terminal' | 'browser'
|
||||
reason: 'sidebar-system-tool'
|
||||
}
|
||||
```
|
||||
|
||||
`requestId` 使连续点击同一工具仍能被消费。不要仅设置 `assistantSidebarTab`,因为用户可能已
|
||||
关闭对应实例,且终端需要按执行空间寻找实例。
|
||||
|
||||
建议由 `RightAssistantSidebar` 在消费意图后通过现有 `WorkbarShell` 控制接口完成。文件和浏览器
|
||||
已有实例时只激活实例,保留其“跟随当前上下文”或“固定到指定对象”状态:
|
||||
|
||||
```ts
|
||||
type WorkbarController = {
|
||||
openOrActivate(appId: WorkbarAppId): Promise<WorkbarTabInstance>
|
||||
}
|
||||
```
|
||||
|
||||
若当前 `WorkbarShell` 没有命令式入口,先增加窄的回调或受控请求 Props,不引入全局事件总线。
|
||||
|
||||
### 3.3 终端匹配
|
||||
|
||||
工作栏“+”目录选择终端时每次创建新实例;侧栏系统工具按钮执行“进入当前终端”,采用:
|
||||
|
||||
1. 解析当前项目对应的 `ExecutionSpaceSelection`。
|
||||
2. 在现有终端实例中按目标 identity 查找 `starting` 或 `running` 状态。
|
||||
3. 存在多个匹配实例时激活最近使用的实例。
|
||||
4. 没有匹配实例时调用现有终端创建流程。
|
||||
|
||||
`closing`、`exited`、`interrupted` 和 `failed` 实例保留为历史 Tab,不阻止快捷按钮创建新终端。
|
||||
工作栏“+”继续按原规则创建额外终端,不受快捷按钮去重影响。
|
||||
|
||||
## 4. 应用导航偏好
|
||||
|
||||
### 4.1 设置结构
|
||||
|
||||
在 `ApplicationSettings` 增加独立字段:
|
||||
|
||||
```ts
|
||||
type BuiltInApplicationId = 'magic-notes' | 'knowledge' | 'heartbeat'
|
||||
|
||||
type ApplicationNavigationSettings = {
|
||||
hiddenBuiltInApplicationIds: BuiltInApplicationId[]
|
||||
}
|
||||
```
|
||||
|
||||
默认值为空数组,保持升级前所有可用入口可见。ID 数组可以避免为每个应用增加一个顶层布尔
|
||||
字段;Zod schema 必须去重、限制数量并拒绝未知内置
|
||||
ID。
|
||||
|
||||
首期不保存排序,因为内置应用顺序固定。插件偏好在插件系统确定稳定 identity 和卸载保留
|
||||
规则后另行扩展,不提前混入当前 schema。
|
||||
|
||||
### 4.2 与现有开关组合
|
||||
|
||||
入口可见条件为:
|
||||
|
||||
```ts
|
||||
const visible = featureEnabled && !hiddenBuiltInApplicationIds.includes(id)
|
||||
```
|
||||
|
||||
`magicNotesEnabled` 继续控制功能启停。知识库和智能心跳若未来增加业务启停开关,也按同一组合
|
||||
规则处理。功能停用时设置页保留显隐偏好但禁用显隐 Switch,并提供原功能设置入口。导航设置
|
||||
不能写回现有开关。
|
||||
|
||||
### 4.3 持久化与 IPC
|
||||
|
||||
沿用现有应用设置链路:
|
||||
|
||||
```text
|
||||
Renderer
|
||||
-> window.goodbuddy.updates.updateSettings
|
||||
-> settings:application:update
|
||||
-> application-settings-store
|
||||
-> application-settings.json
|
||||
```
|
||||
|
||||
更新继续由 Main 校验可信 sender、Zod 输入和原子写入。设置版本迁移只补充默认空数组,不
|
||||
修改已有 `magicNotesEnabled` 或其他平台设置。
|
||||
|
||||
## 5. 侧栏底部重构
|
||||
|
||||
现有 `user-card` 是一个覆盖整行的设置按钮。实现时替换为容器和相邻独立按钮:
|
||||
|
||||
```tsx
|
||||
<div className="sidebar-footer">
|
||||
<SidebarSystemTools onOpenTool={openSystemTool} />
|
||||
<button aria-label={t('navigation.settings')} ...>
|
||||
<Settings aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
不能在设置按钮内部嵌套工具按钮。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`,
|
||||
保留设置预加载、焦点恢复和未保存离开确认。
|
||||
|
||||
系统工具按钮调用统一 `openSystemTool`:
|
||||
|
||||
1. 设置工作栏打开意图。
|
||||
2. 展开工作栏。
|
||||
3. 由工作栏创建或激活实例。
|
||||
4. 按现有策略把焦点移入目标面板。
|
||||
|
||||
工作栏仍由窗口右上方的固定开关关闭,关闭后的焦点返回该开关。
|
||||
|
||||
## 6. 工作栏状态与快捷按钮
|
||||
|
||||
`RightAssistantSidebar` 应向 `App` 暴露当前活动实例的 `appId` 和必要的有界状态摘要,或由
|
||||
现有 `onTabChange` 扩展得到。主侧栏不读取终端输出、文件内容、URL 或活动详情。
|
||||
|
||||
```ts
|
||||
type SidebarSystemToolState = {
|
||||
activeToolId?: SidebarSystemToolId
|
||||
statusByToolId?: Partial<Record<SidebarSystemToolId, 'attention' | 'error' | 'running'>>
|
||||
}
|
||||
```
|
||||
|
||||
首期可以只实现 `activeToolId`,没有可靠聚合来源时不显示状态徽标。不能根据局部事件猜测错误
|
||||
或运行状态。
|
||||
|
||||
## 7. 应用显隐更新
|
||||
|
||||
应用显隐 Switch 复用平台功能设置的即时更新模式。请求开始后禁用三个应用显隐 Switch,只
|
||||
允许一个完整目标集合处于提交中。Main 返回成功结果后更新 App 中的平台设置快照,主导航
|
||||
随之变化。失败时恢复控件原值,在对应设置行显示可重试错误;主导航继续读取最后一次成功
|
||||
保存的设置。
|
||||
|
||||
## 8. 插件兼容边界
|
||||
|
||||
首期不增加插件注册表、插件 schema 或插件持久化字段,只保留系统 ID 不向插件开放的产品边界。
|
||||
插件市场设计需要单独确定 Host、权限、签名、生命周期和稳定 identity;不得直接复用 DeepSeek
|
||||
Harness 的 npm 插件市场。
|
||||
|
||||
## 9. 测试
|
||||
|
||||
### 9.1 Renderer 测试
|
||||
|
||||
- 固定入口顺序不受三个应用显隐组合影响。
|
||||
- 运行记录和设置没有显隐控件。
|
||||
- 三个系统工具按钮具有名称、工具提示、键盘行为和选中状态。
|
||||
- 文件和浏览器复用单实例;终端优先聚焦当前执行空间实例。
|
||||
- 工具按钮展开工作栏但不改变当前主页面。
|
||||
- 显隐更新成功后主导航立即变化,失败时 Switch 和主导航恢复原状态。
|
||||
- 窄窗口下工具按钮不重叠,焦点恢复正确。
|
||||
|
||||
### 9.2 Main 与契约测试
|
||||
|
||||
- 新设置字段默认值、合法 ID、重复 ID、数量上限和未知 ID 校验。
|
||||
- 旧版本设置迁移后默认显示所有既有应用。
|
||||
- 更新导航偏好不修改魔法笔记启停等无关字段。
|
||||
- 原子写入失败时保留最后一次有效设置。
|
||||
|
||||
### 9.3 回归范围
|
||||
|
||||
- 工作栏布局恢复和已关闭实例重建。
|
||||
- 本地与托管 SSH 终端目标。
|
||||
- 浏览器无会话、创建中和 Agent 操作中状态。
|
||||
- 设置页未保存离开确认。
|
||||
- 魔法笔记待办数和智能心跳建议数徽标。
|
||||
- 浅色、深色、`220px` 与 `420px` 侧栏宽度。
|
||||
|
||||
## 10. 实施顺序
|
||||
|
||||
1. 增加应用导航设置 schema、迁移和设置界面。
|
||||
2. 按即时保存结果组合主导航,并补充失败恢复。
|
||||
3. 拆分侧栏底部设置按钮,加入三个系统工具按钮。
|
||||
4. 为工作栏增加 `openOrActivate` 意图接口和终端匹配规则。
|
||||
5. 接入选中状态、焦点恢复、无障碍文案和响应式样式。
|
||||
6. 完成 Renderer、契约、迁移和回归测试。
|
||||
|
||||
插件注册与插件市场不与首期改动一并实施。
|
||||
@@ -0,0 +1,164 @@
|
||||
# 应用导航与系统工具入口 UI 设计
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
| 全局规范 | [GoodBuddy 统一界面设计系统](../../../UI-DESIGN.md) |
|
||||
|
||||
本文只定义入口和设置界面。文件、终端、浏览器的内容布局继续遵守助手工作栏文档。
|
||||
|
||||
## 1. 侧栏信息架构
|
||||
|
||||
```text
|
||||
品牌
|
||||
项目选择
|
||||
新对话
|
||||
搜索
|
||||
|
||||
当前对话
|
||||
魔法笔记 3
|
||||
知识库
|
||||
智能心跳 1
|
||||
运行记录
|
||||
|
||||
最近对话
|
||||
...
|
||||
|
||||
────────────────────────
|
||||
[文件] [终端] [浏览器] [设置]
|
||||
```
|
||||
|
||||
“当前对话”和“运行记录”属于固定系统入口。三个内置应用位于二者之间。侧栏底部不再显示
|
||||
“本地工作区”和 `platform · arch`;排障信息移至关于或诊断页面。
|
||||
|
||||
## 2. 底部系统工具栏
|
||||
|
||||
底部使用连续平整表面和顶部分隔线,不做成账户卡片或嵌套卡片。按钮顺序固定:
|
||||
|
||||
| 位置 | 图标 | 可见工具提示 | 点击结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `FolderOpen` | 文件 | 打开或聚焦工作区面板 |
|
||||
| 2 | `SquareTerminal` | 终端 | 打开或聚焦当前执行空间终端 |
|
||||
| 3 | `Globe` | 浏览器 | 打开或聚焦受控浏览器面板 |
|
||||
| 末位 | `Settings` | 设置 | 打开设置页面 |
|
||||
|
||||
图标使用项目现有 Lucide 库。每个按钮采用统一的 `34px × 34px` 点击区域,内部图标建议
|
||||
`16px`。工具栏使用 `8px` 内边距和 `4px` 间隔;设置按钮通过自动空白靠右,不添加第二条
|
||||
视觉分隔线。
|
||||
|
||||
### 2.1 状态
|
||||
|
||||
- 默认:透明背景,图标使用次级文字色。
|
||||
- 悬停:使用弱表面色,不能强于当前选中状态。
|
||||
- 选中:使用强调选中背景,并提高图标对比度。
|
||||
- 键盘焦点:显示统一焦点环,不能被侧栏边缘裁切。
|
||||
- 处理中:创建或激活请求未完成时暂时禁用对应按钮,保留尺寸并提供进行中名称。
|
||||
- 徽标:只用于等待处理、错误或活动状态,不显示无意义的常驻绿色圆点。
|
||||
|
||||
选中状态表达“对应工作栏面板正在显示”,不表达功能已安装或权限已授予。
|
||||
|
||||
## 3. 主导航应用区
|
||||
|
||||
应用入口沿用现有 `nav-item` 行,不使用应用卡片。图标、名称和徽标保持单行:
|
||||
|
||||
```text
|
||||
[图标] 魔法笔记 3
|
||||
[图标] 知识库
|
||||
[图标] 智能心跳 1
|
||||
```
|
||||
|
||||
隐藏某个应用后,其余应用向上补位。运行记录始终位于最后一个可见应用之后,不因应用显隐
|
||||
改变样式或归类为工具。
|
||||
|
||||
设置页打开期间,主侧栏显示最后一次成功保存的导航状态。Switch 更新成功后导航立即变化,
|
||||
失败时保持原导航。
|
||||
|
||||
## 4. 应用与工具设置
|
||||
|
||||
设置中心新增“应用与工具”分类,采用 `standard` PageShell:
|
||||
|
||||
```text
|
||||
应用与工具
|
||||
管理主导航中的应用。系统工具由 GoodBuddy 提供并固定在侧栏底部。
|
||||
|
||||
应用
|
||||
魔法笔记 笔记、待办和评论 [显示在侧边栏 开]
|
||||
知识库 管理和检索知识 [显示在侧边栏 开]
|
||||
智能心跳 查看计划、建议和运行历史 [显示在侧边栏 开]
|
||||
|
||||
系统工具
|
||||
文件 打开当前工作区 内置
|
||||
终端 进入当前执行空间 内置
|
||||
浏览器 打开 GoodBuddy 受控浏览器 内置
|
||||
|
||||
系统入口
|
||||
当前对话、运行记录和设置位置固定,不参与上述配置。
|
||||
```
|
||||
|
||||
应用显隐使用共享 Switch,标签必须是“显示在侧边栏”,不能缩写为“启用”。切换时立即保存,
|
||||
请求期间禁用三个应用显隐 Switch;失败后恢复原值,并在对应设置行显示可重试错误。系统工具
|
||||
使用紧凑只读列表,不渲染禁用的卸载、停用或固定按钮。
|
||||
|
||||
功能已停用时,对应应用行显示“功能已停用”和设置入口,保留显隐偏好但暂时禁用 Switch。
|
||||
|
||||
若某应用自身支持业务启停,应在该应用原有设置中保留“启用功能”开关,并用说明区分:
|
||||
|
||||
- “显示在侧边栏”只控制入口。
|
||||
- “启用功能”控制该功能是否运行。
|
||||
|
||||
## 5. 助手工作栏反馈
|
||||
|
||||
点击系统工具后,右侧工作栏按现有布局回流打开。主内容页面保持不变,侧栏不渲染工具内容。
|
||||
|
||||
- 文件:激活“工作区”Tab,焦点进入目录区域或错误修复入口。
|
||||
- 终端:激活匹配终端,焦点进入终端输入区;新建期间保留按钮宽度和进度状态。
|
||||
- 浏览器:激活“浏览器”Tab,焦点进入工具栏;没有会话时进入空状态主操作。
|
||||
|
||||
后台事件可以更新徽标,不能自动展开工作栏或切换当前 Tab。由用户点击快捷按钮触发的打开
|
||||
属于显式操作,可以展开工作栏并切换 Tab。
|
||||
|
||||
## 6. 文案
|
||||
|
||||
固定使用以下名称:
|
||||
|
||||
| 对象 | 文案 |
|
||||
| --- | --- |
|
||||
| 设置分类 | 应用与工具 |
|
||||
| 文件系统工具 | 文件 |
|
||||
| 终端系统工具 | 终端 |
|
||||
| 浏览器系统工具 | 浏览器 |
|
||||
| 浏览器说明 | GoodBuddy 受控浏览器 |
|
||||
| 应用显隐开关 | 显示在侧边栏 |
|
||||
| 内置来源 | 内置 |
|
||||
|
||||
不再使用“本地工作区”作为侧栏底部身份名称。项目和默认工作区的路径继续由项目选择器、页面
|
||||
范围或工作区面板显示。
|
||||
|
||||
## 7. 键盘和无障碍
|
||||
|
||||
- 底部按钮均为独立 `button`,不能嵌套在整行设置按钮中。
|
||||
- Tab 顺序为文件、终端、浏览器、设置。
|
||||
- 图标按钮通过 `aria-label` 提供名称,工具提示不能作为唯一可访问名称。
|
||||
- 工作栏打开后按现有焦点策略聚焦目标面板;用户通过固定工作栏开关关闭时,焦点返回该开关。
|
||||
- 选中按钮使用 `aria-pressed` 或等价的可访问状态,不能只依靠背景色。
|
||||
- 徽标的数量或状态合并进按钮可访问名称,例如“终端,1 个等待处理”。
|
||||
- 应用显隐 Switch 支持 Space 切换,保存失败后恢复原值并保留焦点。
|
||||
|
||||
## 8. 响应式
|
||||
|
||||
- 侧栏宽度 `220px` 至 `420px` 时,底部按钮尺寸不随宽度缩放。
|
||||
- 窄窗口继续使用现有侧栏抽屉;工具栏不转为多行。
|
||||
- 空间不足时缩小设置按钮前的弹性空白,不隐藏三个系统工具和设置。
|
||||
- 工具提示不得超出窗口;靠近底边时显示在按钮上方。
|
||||
- 粗指针设备不依赖悬停,按钮的可访问名称和选中反馈保持有效。
|
||||
|
||||
## 9. 未来插件入口
|
||||
|
||||
插件市场交付后另行定义插件区域、固定数量、溢出和权限界面。系统入口与三个系统工具的位置
|
||||
保持保留,不向插件开放。
|
||||
@@ -0,0 +1,207 @@
|
||||
# 应用导航与系统工具入口 User Stories
|
||||
|
||||
## 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 设计中 |
|
||||
| 版本 | 0.1 |
|
||||
| 日期 | 2026-09-08 |
|
||||
| 关联 PRD | [应用导航与系统工具入口 PRD](./prd.md) |
|
||||
| 关联逻辑 | [功能逻辑设计](./logic-design.md) |
|
||||
|
||||
## 1. 角色
|
||||
|
||||
### 1.1 普通用户
|
||||
|
||||
使用对话、笔记和知识功能,希望常用入口稳定,不需要理解工作栏实例模型。
|
||||
|
||||
### 1.2 本地工作用户
|
||||
|
||||
经常查看项目文件、打开终端或使用受控浏览器,希望从任意一级页面快速进入这些工具。
|
||||
|
||||
## 2. Epic A:固定系统入口
|
||||
|
||||
### US-A1 始终访问运行记录
|
||||
|
||||
作为用户,我希望运行记录保持在固定位置,以便应用入口变化后仍能检查 Agent 和工具活动。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户隐藏任意内置应用
|
||||
- When 用户查看主导航
|
||||
- Then “运行记录”仍显示在应用区之后
|
||||
- And 设置中没有隐藏、卸载或固定运行记录的选项
|
||||
|
||||
### US-A2 从固定入口进入设置
|
||||
|
||||
作为用户,我希望设置入口始终位于侧栏底部,以便快捷工具状态变化时仍能进入配置。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 任意主页面和任意侧栏宽度
|
||||
- When 用户查看侧栏底部
|
||||
- Then 设置按钮保持可见且点击区域不与工具按钮重叠
|
||||
- And 点击后继续遵守未保存内容的离开确认
|
||||
|
||||
## 3. Epic B:管理应用入口
|
||||
|
||||
### US-B1 识别完整应用
|
||||
|
||||
作为用户,我希望魔法笔记、知识库和智能心跳显示在主导航,以便知道它们会打开完整页面。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 对应功能已启用且应用设置为显示
|
||||
- When 用户打开主侧栏
|
||||
- Then 应用位于“当前对话”和“运行记录”之间
|
||||
- And 点击应用在主内容区打开对应一级页面
|
||||
- And 原有待办或建议徽标继续显示
|
||||
|
||||
### US-B2 隐藏低频应用
|
||||
|
||||
作为用户,我希望隐藏不常用的内置应用,以便缩短主导航,但保留已有数据。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 应用已有内容
|
||||
- When 用户关闭该应用的“显示在侧边栏”Switch
|
||||
- Then 主导航不再显示该应用
|
||||
- And 应用数据、计划和业务启停状态不变
|
||||
- And 用户可从同一设置位置恢复显示
|
||||
|
||||
### US-B4 保留功能启停语义
|
||||
|
||||
作为魔法笔记用户,我希望导航显隐与功能启停相互独立,以免整理导航时关闭功能。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 魔法笔记功能已启用
|
||||
- When 用户只隐藏其侧栏入口
|
||||
- Then 笔记及待办处理能力继续按原设置工作
|
||||
- And 再次显示时恢复原页面和数据
|
||||
|
||||
### US-B5 恢复显隐保存失败
|
||||
|
||||
作为用户,我希望入口设置保存失败时继续使用原导航,以免界面状态与实际设置不一致。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 应用显隐更新尚未完成
|
||||
- When 用户切换“显示在侧边栏”Switch
|
||||
- Then 三个应用显隐 Switch 在请求期间暂时不可操作
|
||||
- And 更新成功后主导航立即变化
|
||||
- And 更新失败时 Switch 和主导航恢复原状态并显示可重试错误
|
||||
|
||||
## 4. Epic C:使用系统工具
|
||||
|
||||
### US-C1 一键查看文件
|
||||
|
||||
作为本地工作用户,我希望点击左下角文件按钮后直接查看当前工作区文件。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 当前项目有有效根目录
|
||||
- When 用户点击文件按钮
|
||||
- Then 助手工作栏展开
|
||||
- And 已有工作区面板时聚焦该实例,没有时创建单实例
|
||||
- And 文件按钮通过选中状态表明该面板正在显示
|
||||
|
||||
### US-C2 处理无效工作区
|
||||
|
||||
作为用户,我希望目录不可用时看到修复方法,并收到明确反馈。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 工作区面板尚未创建或处于跟随模式
|
||||
- And 当前项目和默认工作区均不可用
|
||||
- When 用户点击文件按钮
|
||||
- Then 工作区面板仍打开
|
||||
- And 面板说明目录不可用并提供选择或修复目录的入口
|
||||
- And 应用不静默改用其他目录
|
||||
|
||||
### US-C3 快速进入终端
|
||||
|
||||
作为本地工作用户,我希望点击终端按钮后进入当前执行空间的终端。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 当前执行空间已有正在启动或运行中的终端
|
||||
- When 用户点击终端按钮
|
||||
- Then 工作栏聚焦最近使用的匹配终端,不创建重复实例
|
||||
- Given 当前执行空间没有终端
|
||||
- When 用户点击终端按钮
|
||||
- Then 创建一个绑定该执行空间的新终端
|
||||
- Given 当前没有项目
|
||||
- When 用户点击终端按钮
|
||||
- Then 创建或聚焦使用本机 Home 的终端
|
||||
- Given 当前执行空间只有已退出、已中断或失败的终端
|
||||
- When 用户点击终端按钮
|
||||
- Then 创建新终端并保留原终端的历史状态
|
||||
|
||||
### US-C4 使用远程项目终端
|
||||
|
||||
作为远程项目用户,我希望同一个终端按钮进入远程项目目录,以免误在本机执行命令。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 当前项目绑定托管 SSH 执行空间
|
||||
- When 用户点击终端按钮
|
||||
- Then 创建或聚焦绑定该远程项目及其权威目录的终端
|
||||
- And 面板持续显示远程主机和工作目录
|
||||
- And 连接失败时保留重连与诊断信息
|
||||
|
||||
### US-C5 打开受控浏览器
|
||||
|
||||
作为用户,我希望浏览器按钮打开 GoodBuddy 受控浏览器,以便查看与 Agent 相同的会话。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 浏览器面板跟随当前上下文且当前会话已有浏览器会话
|
||||
- When 用户点击浏览器按钮
|
||||
- Then 工作栏聚焦同一个浏览器会话
|
||||
- And 不打开系统默认浏览器或第二个 WebView
|
||||
- Given 浏览器面板已固定到其他会话
|
||||
- When 用户点击浏览器按钮
|
||||
- Then 工作栏聚焦该面板并保持固定目标
|
||||
- Given 浏览器面板尚未创建或处于跟随模式
|
||||
- And 当前没有活动对话
|
||||
- When 用户点击浏览器按钮
|
||||
- Then 面板说明需要选择或创建对话
|
||||
|
||||
### US-C6 从任意应用打开工具
|
||||
|
||||
作为用户,我希望在魔法笔记、知识库、智能心跳和运行记录页面也能打开系统工具。
|
||||
|
||||
验收:
|
||||
|
||||
- Given 用户位于任意一级页面
|
||||
- When 用户点击文件、终端或浏览器
|
||||
- Then 主页面保持不变
|
||||
- And 右侧工作栏打开对应面板
|
||||
- And 工具不会覆盖主内容或伪装成一级页面
|
||||
|
||||
## 5. Epic D:可访问性和布局
|
||||
|
||||
### US-D1 使用键盘打开系统工具
|
||||
|
||||
作为键盘用户,我希望按顺序聚焦并打开左下角工具,以便不依赖鼠标。
|
||||
|
||||
验收:
|
||||
|
||||
- 每个系统工具和设置都是独立按钮
|
||||
- Tab 顺序与视觉顺序一致
|
||||
- Enter 和 Space 均可激活按钮
|
||||
- 每个纯图标按钮都有可访问名称和工具提示
|
||||
- 焦点样式不被侧栏边界裁切
|
||||
|
||||
### US-D2 在窄窗口使用快捷栏
|
||||
|
||||
作为小屏用户,我希望侧栏抽屉中的工具按钮保持可操作。
|
||||
|
||||
验收:
|
||||
|
||||
- 工具按钮保持单行且不重叠
|
||||
- 不因宽度不足隐藏文件、终端、浏览器或设置
|
||||
- 触控点击区域符合统一界面规范
|
||||
- 关闭侧栏抽屉后焦点返回原触发按钮
|
||||
@@ -9,6 +9,7 @@
|
||||
| [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 |
|
||||
| [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 |
|
||||
| [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 |
|
||||
| [Runtime 交互边界](./runtime-interactions.md) | 现有原生权限、结构化问答、后台拒绝和非结构化终端交互的适配范围 |
|
||||
|
||||
当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在
|
||||
两份现有 PRD 中,后续实际维护该功能时按职责拆分。
|
||||
|
||||
@@ -168,7 +168,7 @@ Runtime
|
||||
- 建立统一范围、执行空间、生命周期、成果和控制契约。
|
||||
- 复用现有 Project、Conversation、Task、Artifact、Activity 和 Approval 数据。
|
||||
- 保持 Renderer 无任意文件、进程、PTY、SSH 或 Electron API 能力。
|
||||
- 保持 Ask 只读、Execute 审批、取消、超时、输出边界和活动审计。
|
||||
- 保持 Ask 只读、Execute 账号完整授权、取消、超时、输出边界和活动审计。
|
||||
- 为本机与远程能力提供一致 UI,同时准确表达能力差异。
|
||||
|
||||
## 5. 非目标
|
||||
@@ -209,8 +209,9 @@ Runtime
|
||||
|
||||
- 每项始终显示稳定图标,并提供可见标签或可持续查看的工具提示。
|
||||
- 已打开实例使用 `tablist`、`tab`、`tabpanel`;“+”是位于 `tablist` 外的普通命令按钮。
|
||||
- 应用注册表声明 `single` 或 `multiple`。重复选择单实例应用时聚焦已有实例;选择多实例
|
||||
应用时每次创建新实例。
|
||||
- 应用注册表声明 `single` 或 `multiple`。在工作栏“+”目录中重复选择单实例应用时聚焦已有
|
||||
实例;选择多实例应用时每次创建新实例。主侧栏系统工具快捷入口可以聚焦当前执行空间中
|
||||
最近使用的终端,额外终端仍从“+”目录创建。
|
||||
- 支持方向键、Home、End、Enter、Space、关闭面板和正确焦点恢复。
|
||||
- 徽标显示未解决数量、等待审批或失败状态,并同时提供文字或可访问名称。
|
||||
- 用户调整目录顺序、打开实例、停靠位置和尺寸后持久化;关闭实例后能力仍可从目录重新打开。
|
||||
@@ -795,7 +796,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
|
||||
1. 工作栏能力目录项和面板实例不授予任何能力;权限只由 Main 中的范围和控制契约产生。
|
||||
2. Ask 在本机和远程 Runtime 边界保持只读。
|
||||
3. Execute 继续经过现有 Runtime 和审批控制,工作栏不能直接放宽。
|
||||
3. Execute 使用当前执行账号的完整权限,不另加目录或逐工具审批;工作栏不能绕过 Main、Preload 与用户数据边界。
|
||||
4. 用户终端和 Agent 工具执行使用不同身份和事件来源。
|
||||
5. 进程面板只控制 GoodBuddy 受管对象,不接受任意 PID。
|
||||
6. 本机和远程路径分别在对应文件系统上 canonicalize 并验证符号链接边界。
|
||||
@@ -897,8 +898,9 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。
|
||||
- 签名、side-by-side GoodBuddy Agent 安装和按需 detached Daemon。
|
||||
- SSH connection pool、固定 attach relay、Agent 控制协议、Agent 本地 ACP 日志和断线重连同步。
|
||||
- 远程工作区、Git、终端和受管进程。
|
||||
- 先完成签名 OpenCode bundle、Ask bubblewrap、Execute 直接进程、官方 ACP channel、
|
||||
Main 模型网关、取消、超时和活动记录。
|
||||
- 当前签名 OpenCode bundle 的 Ask/Execute 均直接启动,Ask 在工具边界保持只读,
|
||||
Execute 使用账号完整权限;官方 ACP channel、Agent 本地模型网关、取消、超时和活动记录
|
||||
以[远程主机技术设计](../remote-host/technical-design.md)为准。
|
||||
- OpenCode 闭环稳定后,Continue 与 DeepSeek Harness 复用相同面板和通道契约逐个接入。
|
||||
- 新增或重新验证 Host 时只保存并探测 Agent/Runtime,不自动安装。用户在 Host 卡片用
|
||||
单一主按钮启动,并用次级控件选择自动、Host 下载或 GoodBuddy 传输;Host 保存与环境
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# 工作栏实现与验证进度
|
||||
|
||||
## 2026-09-08 Execute 目录权限等待修复
|
||||
|
||||
- 本机 OpenCode 的内部配置显式允许默认工具权限;每次 Ask 仍设置 deny-all 会话规则并
|
||||
禁用工具,当前请求精确分配的只读能力例外。Execute 不把 Workspace 当作目录边界。
|
||||
- `permission.asked` 不再忽略所有非父会话事件。Main 用 OpenCode Session 的 `parentID`
|
||||
确认属于当前请求的子孙会话后按当前模式回复;其他并行会话不受影响,同一请求只回复
|
||||
一次。子会话权限不伪装为父会话中的待完成工具。
|
||||
- 真实 OpenCode `1.18.9` 和候选 `1.18.29` 均已通过本地确定性模型驱动的原生 Task
|
||||
工作区外读取回归;同一 Runtime 的 Ask 仍不暴露文件与命令工具。
|
||||
- Continue `1.5.47` 的真实 CLI 已通过 Execute Shell 写入工作区外测试文件;DSH 固定
|
||||
Host 已通过 Execute 工作区外写入、随后 Ask 拒绝同一写工具,未增加权限策略或人工审批。
|
||||
- 真实 OpenCode `1.18.29` 已完成原生 Task 后的 yes/no 与自由文本问答回传;父、子会话
|
||||
提问及后台任务不能交互时的失败路径均有回归测试。Continue 真实 `AskQuestion` 暴露了
|
||||
QuizService 扁平数据结构与原适配器不一致的问题,现已按锁定版本修正;真实自由文本
|
||||
回传、yes/no、选项及跳过均验证通过,自定义答案不再误标为选项。
|
||||
- DSH 活跃 Execute 的 ACP 权限回调不再等待第二次 authorizer,Ask 继续拒绝;未添加
|
||||
通用业务问答工具或从终端日志猜测确认意图。完整范围见
|
||||
[Runtime 交互边界](./runtime-interactions.md)。
|
||||
- 当前源码 Linux x64 Agent 通过签名组包、生产安装、真实 Ask/Execute、原生子代理
|
||||
工作区外写入、重连同一 daemon 和 stop/bootstrap。实机回归同时定位并修复了握手
|
||||
Base64URL 响应误用 ID 校验及断流未结算等待;13 项 Unix 原生端点测试和连续 20 次
|
||||
握手通过。两轮真实模型调用合计 10 次,均 completed;本轮测试 Agent 已停止。
|
||||
- 最终完整回归使用 `npm test -- --reporter=json --outputFile=<临时报告路径>`,并通过
|
||||
`GOODBUDDY_TEST_OPENCODE_BINARY` 指向已验证版本的候选 OpenCode。结果为 338 个文件、
|
||||
3597 项通过、0 项失败、61 项按平台/手动条件跳过,报告 `success=true`、退出码 0;
|
||||
Windows 跳过的 Unix 端点场景另在 Linux Host 运行了上述 13 项原生测试。
|
||||
- 最终 `npm run typecheck`、`npm run lint`、`npm run release:notes:verify` 和差异空白检查
|
||||
通过,相关 Markdown 相对链接有效。此前失败、暂停及控制台截断的回归记录由本次完整
|
||||
JSON 报告覆盖,不代表仍有测试失败。未执行系统休眠/唤醒或本地桌面生产构建、打包;
|
||||
后续发布仍需对应候选的 CI 构建和跨平台验证。
|
||||
|
||||
## 2026-09-08 OpenCode 复用与原生控件恢复
|
||||
|
||||
- `OpenCodeRuntime` 复用缓存的 embedded server 前执行有超时的健康检查;健康则保留
|
||||
@@ -16,7 +47,8 @@
|
||||
验证记录:首轮定向测试 273 项通过、5 项跳过;补充修复后的 Runtime 与生命周期测试
|
||||
77 项通过,界面定向测试 6 项通过,类型检查通过。修复前的 lint 检查通过。
|
||||
全量测试超过 120 秒被终止,期间出现一项 DS 本地 stdio MCP 测试失败,尚待定位。
|
||||
真实系统休眠恢复和当前源码的 GoodBuddy Agent 实机路径尚未完成验证。
|
||||
这是当次历史结果。后续发布前修复与实机验证见本文上方及
|
||||
[远程主机技术设计](../remote-host/technical-design.md);真实系统休眠恢复仍未完成验证。
|
||||
|
||||
## 2026-09-06 项目工作区
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Runtime 交互边界
|
||||
|
||||
本文定义当前原生交互的适配范围,不增加工作模式或第二层 Execute 审批。
|
||||
|
||||
| Runtime | 权限确认 | 业务问答 |
|
||||
| --- | --- | --- |
|
||||
| 本机 GoodBuddy 管理的 OpenCode | 内部默认配置允许权限;每个 Ask 请求仍使用 deny-all 与显式只读能力。Execute 自动回复属于当前请求的父、子孙会话权限事件,不处理其他并行请求 | 通过 `question.asked` 转交父、子孙会话问题,支持原生单选、多选、yes/no、自由文本及跳过;使用公开 ID 回传 `question.reply/reject` |
|
||||
| Continue | Execute 启动传入 `--auto`,剩余原生权限请求按当前模式自动答复 | 对接 `1.5.47` QuizService 的扁平 `pendingQuestion`,其中 `question` 是字符串。选项回答与自定义文本分别回传正确的 `isCustomAnswer`;跳过发送拒绝回答的文本 |
|
||||
| DeepSeek Harness | 固定 Host 的 Execute 文件与命令工具不询问目录权限;若收到 ACP 权限请求,活跃 Execute 直接选一次性允许,缺少该选项时使用 Runtime 提供的允许选项,Ask 拒绝,不等待额外 authorizer | 固定 Host 当前未提供通用原生业务问答服务,不把普通模型正文误识别成待答协议。模型以普通回复提问时,用户通过下一条消息回答 |
|
||||
|
||||
托管 SSH OpenCode 的 ACP 权限处理与直接启动配置由
|
||||
[远程主机技术设计](../remote-host/technical-design.md)定义;本机 SDK 问答适配不代表远程
|
||||
ACP 已提供相同业务问答接口。
|
||||
|
||||
## 运行与失败
|
||||
|
||||
- 结构化 yes/no 是普通选项,不从日志、自然语言或 Shell 输出猜测用户意图。
|
||||
- 前台问题停留在现有问答卡片,保留回答、提交失败提示与跳过入口。
|
||||
- 后台任务收到结构化提问时,通过现有 Main 路径明确失败并提示改为前台对话,不永久等待。
|
||||
- 请求结束或取消后清除对应待答映射;过期回答明确报错,不转交给后来的请求。
|
||||
- 任意终端程序的密码、OAuth 登录、许可证确认或 stdin 提示不自动回复 `yes`。这类程序应
|
||||
使用其非交互参数,或由用户在终端完成;不能把权限确认和业务决策混为一谈。Shell
|
||||
工具保留其现有取消与超时,不新增日志解析或通用交互代理。
|
||||
|
||||
实现与真实 Runtime 验证证据见[进度记录](./progress.md)。
|
||||
@@ -139,6 +139,8 @@ Harness 子进程内控制面不能取代 Main 控制面,Main 控制面也不
|
||||
- GoodBuddy 在 Runtime 重启后可以用现有的有界历史创建新 Harness Session。
|
||||
|
||||
Harness Session 只在当前 Runtime 进程生命周期内存在。释放 GoodBuddy 会话时必须同步释放对应 Harness Agent。
|
||||
固定 Host 显式加载 `dsh-session-projection` 的内存 SessionProjectionRegistry,这是
|
||||
`0.1.2-rc.1` AgentLoop 的必需依赖;不加载 Session persistence,也不新增磁盘会话副本。
|
||||
|
||||
## 6. 总体架构
|
||||
|
||||
@@ -290,6 +292,10 @@ GoodBuddy 的工作模式属于每个请求,不属于 Runtime 进程全局状
|
||||
5. 缺少准备状态、重复使用、请求标识不匹配时,Control Plane 直接拒绝请求。
|
||||
6. 同一 Session 只允许一个 Prompt 在途。
|
||||
|
||||
若 Runtime 回传 ACP 权限请求,Main 只按活跃 Session 的当前模式答复:Execute 优先选择
|
||||
`allow_once`,缺少时使用提供的 `allow_always`,不调用第二次人工 authorizer;
|
||||
Ask、已关闭或未知 Session 拒绝。这里不写入永久授权规则,也不把业务问答当作权限确认。
|
||||
|
||||
### 8.5 事件模型
|
||||
|
||||
Harness Control Plane 只发送 GoodBuddy 能稳定解释的字段:
|
||||
|
||||
@@ -69,6 +69,10 @@ Detached GoodBuddy Agent
|
||||
## Agent 安装与生命周期
|
||||
|
||||
- Agent bundle 通过 manifest、Ed25519 签名、payload digest、平台和架构校验。
|
||||
- 外层 `agent-package.json` 描述符上限为 4 MiB,覆盖包含 OpenCode 离线依赖的实际包
|
||||
(2026-09-08 Linux x64 验证包为 1,101,863 字节)。Desktop 解包、Host prepare/commit
|
||||
与目录生成器使用相同上限;其余元数据仍为 1 MiB,签名、文件数量、归档大小和完整性校验不变。
|
||||
- Runtime 组包使用的 OpenCode 离线依赖缓存按插件版本复用,安装与替换规则见 [Runtime 资源](../../../BUILD.md#runtime-资源)。复制进 Runtime 包的依赖仍属于签名 manifest 的 payload。
|
||||
- 安装使用当前 SSH 用户目录中的 GoodBuddy-owned 路径和 side-by-side digest 目录。
|
||||
- Agent 代码与固定 Node Runtime 一起位于签名 compound payload。显式安装或重装从本次
|
||||
已完整校验的准备目录原子发布整个 Agent,不扫描旧安装、不创建跨版本 Node 硬链接;
|
||||
@@ -94,6 +98,9 @@ Detached GoodBuddy Agent
|
||||
- Agent 是按需启动的 detached process,不注册开机服务,不依赖 systemd、D-Bus 或 Linger。
|
||||
- 每个模型桥 helper 都为 loopback HTTP 入口生成一次性随机路径 capability;只有写入当前 OpenCode 子进程配置的 URL 可以访问该入口,其他本机用户即使发现临时端口也不能提交模型请求。
|
||||
- Agent 监听当前用户拥有的私有 Unix socket。SSH 中断只关闭 relay,不拥有 Agent 和活动 Runtime 的生命周期。
|
||||
- 私有握手的 HMAC 响应按固定 32 字节的规范 Base64URL 校验,不套用要求字母数字开头
|
||||
的应用 ID 规则;health nonce 使用固定前缀。读取握手时,流结束或关闭都立即失败,
|
||||
不留下未结算 Promise;Host Key、同 UID 校验和 HMAC 验证保持不变。
|
||||
- SSH Host 设置通过固定探针和有界 SFTP 显示 Host 已登记的 Agent/OpenCode 版本,并与当前
|
||||
GoodBuddy 所需版本比较。进入或切回该设置页只读取本地 Host 列表,不连接任何 Host;
|
||||
用户点击“刷新版本”后才探测对应 Host,探测不安装或切换远端组件。项目切换与此按钮
|
||||
@@ -195,6 +202,10 @@ Ask 与 Execute 一样直接启动已签名 Runtime,不要求 Host 安装额
|
||||
- Ask 启动时同时注入 OpenCode 顶层和 build Agent 的 `permission: "ask"`;Agent
|
||||
工具权限分发边界只批准上述原生读取请求。该双重边界不另加文件系统 confinement,
|
||||
也不改变 Runtime 的直接启动方式。
|
||||
- Execute 的直接启动与模型桥启动均显式设置顶层和 build Agent 的 `permission: "allow"`,
|
||||
避免继承 OpenCode 默认的 `external_directory` 询问;项目目录只是默认工作目录。
|
||||
原生子代理同样使用当前 SSH 账号可访问的路径。仍出现的 ACP 权限请求由 Agent 按
|
||||
当前 Prompt 模式自动答复,不增加 Desktop 人工审批,也不依赖日志解析。
|
||||
|
||||
### Execute
|
||||
|
||||
@@ -372,6 +383,28 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
|
||||
不可达,应立即把真实 Host 验证报告为开发阻塞,保持该验证未完成,不能把 Agent 改动表述
|
||||
为已完成,也不能把首次真实验证推迟到发布阶段。
|
||||
|
||||
## 离线配置缓存简化验证(2026-09-08)
|
||||
|
||||
`npm test -- tests/opencode-config.test.ts src/main/agent/opencode-runtime.test.ts src/main/agent/bundled-runtimes.test.ts tests/remote-runtime-bundle.test.ts tests/agent-bundle.test.ts --testTimeout=30000` 通过 108 项;`npm run typecheck`、`npm run lint` 和 `npm run build` 通过。
|
||||
全量 `npm test -- --testTimeout=30000 --hookTimeout=120000` 当次结果为 3556 项通过、28 项失败、58 项跳过;其中离线配置路径断言随后修正并通过上述聚焦测试,未重跑全量。其余失败涉及 Agent 包元数据大小限制、Darwin 组包超时、Runtime 探测、DSH 和打包夹具。
|
||||
测试 Host 的 SSH 端口可达,但候选复合包验证受元数据大小限制阻塞,未部署或执行实机安装/更新验收;本次真实模型调用 0 次。
|
||||
以上为缓存简化阶段的历史记录,不代表后续发布前修复的最终结果。
|
||||
|
||||
## 发布前修复实机验证(2026-09-08)
|
||||
|
||||
共享 Linux x64 Host 上使用当前源码、锁定 Node 与 OpenCode `1.18.29`、进程内临时签名
|
||||
身份组包,签名目录读取、生产 `preparePackage/commitPackage`、adopt、Runtime activate
|
||||
与 health 通过。所有写入位于独立 GoodBuddy 测试 HOME,未修改共享安装。
|
||||
|
||||
真实 Desktop 协议客户端已通过 Ask 文本回复、Execute 原生 Task 子代理在 Workspace 外
|
||||
的专用测试目录写文件、读回校验、关闭连接后重连同一 daemon,以及 stop/bootstrap 后
|
||||
health。首轮发现握手 Base64URL 误拒绝后完成上述修复;最终源码复测通过,独立 Unix
|
||||
endpoint 连续 20 次握手也通过。两轮模型账本各 5 次 completed,本轮合计 10 次真实文本
|
||||
模型调用,没有发布工件、签名目录或 release tag。
|
||||
|
||||
最终自动化回归与尚未覆盖的系统休眠场景见
|
||||
[工作栏进度](../assistant-workbar/progress.md)。
|
||||
|
||||
## 已完成的 E2E 验收记录
|
||||
|
||||
### macOS arm64 Host 支持
|
||||
@@ -605,7 +638,8 @@ goodbuddy-agent diagnostics --installation-id <installationId>
|
||||
1. `npm test`
|
||||
2. `npm run typecheck`
|
||||
3. `npm run lint`
|
||||
4. `npm run build`
|
||||
4. 发布候选的生产构建由 main 分支 CI 验证;发布前不在本地执行生产构建或打包,详见
|
||||
[发布手册](../../development/release-runbook.md)。
|
||||
5. 用测试签名复合包验证在线下载源选择和离线导入/导出;正式 Agent 候选还需公开校验
|
||||
双架构 production 包与签名目录。
|
||||
6. 在已固定 Host Key 的 Linux x64 测试 Host 上验证 attach-or-bootstrap。
|
||||
|
||||
Generated
+1
-1
@@ -22,6 +22,7 @@
|
||||
"@deepseek-ai/dsh-sandbox": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-session-projection": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-shell-env": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-skill": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-subprocess-local": "0.1.2-rc.1",
|
||||
@@ -1902,7 +1903,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-projection/-/dsh-session-projection-0.1.2-rc.1.tgz",
|
||||
"integrity": "sha512-qVWlIzPm4u9vzQ5G/QtE6jeSJ3b3b42hWVJj664BurEZlHbAfdqkhYc1QsoBU+5k5kUEJFHh07z8HxuXA9kTnQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"@deepseek-ai/dsh-sandbox": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-session-projection": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-shell-env": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-skill": "0.1.2-rc.1",
|
||||
"@deepseek-ai/dsh-subprocess-local": "0.1.2-rc.1",
|
||||
|
||||
@@ -48,13 +48,13 @@ export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
|
||||
|
||||
export type OpenCodeModelBridgeProviderConfig = {
|
||||
model: string
|
||||
permission?: 'ask'
|
||||
permission: 'ask' | 'allow'
|
||||
agent: {
|
||||
title: {
|
||||
disable: true
|
||||
}
|
||||
build?: {
|
||||
permission: 'ask'
|
||||
build: {
|
||||
permission: 'ask' | 'allow'
|
||||
}
|
||||
}
|
||||
provider: Record<
|
||||
@@ -521,11 +521,7 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
const baseURL = `${origin}/v1`
|
||||
return {
|
||||
model: openCodeModelBridgeModelId(input.protocol, model),
|
||||
...(input.workMode === 'ask'
|
||||
? {
|
||||
permission: 'ask' as const
|
||||
}
|
||||
: {}),
|
||||
permission: input.workMode === 'ask' ? 'ask' : 'allow',
|
||||
// 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.
|
||||
@@ -533,13 +529,9 @@ export function createOpenCodeModelBridgeProviderConfig(input: {
|
||||
title: {
|
||||
disable: true
|
||||
},
|
||||
...(input.workMode === 'ask'
|
||||
? {
|
||||
build: {
|
||||
permission: 'ask' as const
|
||||
}
|
||||
}
|
||||
: {})
|
||||
build: {
|
||||
permission: input.workMode === 'ask' ? 'ask' : 'allow'
|
||||
}
|
||||
},
|
||||
provider: {
|
||||
[providerId]: {
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('model bridge loopback helper', () => {
|
||||
expect(options.env.NODE_OPTIONS).toBeUndefined()
|
||||
const config = JSON.parse(options.env.OPENCODE_CONFIG_CONTENT!)
|
||||
expect(config.model).toBe('goodbuddy-anthropic/private-model')
|
||||
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : undefined)
|
||||
expect(config.permission).toBe(workMode === 'ask' ? 'ask' : 'allow')
|
||||
expect(config.plugin).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
@@ -594,7 +594,7 @@ describe('model bridge loopback helper', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves Execute provider permissions unchanged', () => {
|
||||
it('explicitly allows Execute permissions instead of inheriting directory prompts', () => {
|
||||
const config = createOpenCodeModelBridgeProviderConfig({
|
||||
protocol: 'openai-responses',
|
||||
model: 'private-model',
|
||||
@@ -603,8 +603,8 @@ describe('model bridge loopback helper', () => {
|
||||
workMode: 'execute'
|
||||
})
|
||||
|
||||
expect(config).not.toHaveProperty('permission')
|
||||
expect(config.agent).not.toHaveProperty('build')
|
||||
expect(config.permission).toBe('allow')
|
||||
expect(config.agent.build.permission).toBe('allow')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -102,6 +102,10 @@ describe('OpenCode direct launch profile', () => {
|
||||
)
|
||||
})
|
||||
expect(execute.args).not.toContain('--unshare-all')
|
||||
expect(JSON.parse(execute.env.OPENCODE_CONFIG_CONTENT!)).toMatchObject({
|
||||
permission: 'allow',
|
||||
agent: { build: { permission: 'allow' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Runtime arguments credential-free and rejects a changed manifest', () => {
|
||||
|
||||
@@ -10,15 +10,6 @@ import {
|
||||
} from "../shared/model-bridge-contracts";
|
||||
import { assertAbsoluteManagedPath } from "./managed-paths";
|
||||
|
||||
const OPEN_CODE_ASK_CONFIG = JSON.stringify({
|
||||
permission: "ask",
|
||||
agent: {
|
||||
build: {
|
||||
permission: "ask",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const OPEN_CODE_RUNTIME_ENVIRONMENT = Object.freeze({
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1",
|
||||
@@ -151,13 +142,14 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
...OPEN_CODE_RUNTIME_ENVIRONMENT,
|
||||
OPENCODE_CONFIG_DIR: join(bundleDirectory, "config", "opencode"),
|
||||
};
|
||||
const environment =
|
||||
input.workMode === "ask"
|
||||
? {
|
||||
...environmentBase,
|
||||
OPENCODE_CONFIG_CONTENT: OPEN_CODE_ASK_CONFIG,
|
||||
}
|
||||
: environmentBase;
|
||||
const permission = input.workMode === "ask" ? "ask" : "allow";
|
||||
const environment = {
|
||||
...environmentBase,
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
||||
permission,
|
||||
agent: { build: { permission } },
|
||||
}),
|
||||
};
|
||||
return {
|
||||
...runtimeCommand,
|
||||
processExecutable: runtimeCommand.executable,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { InstallationChallengeVerifier } from './installation-challenge'
|
||||
import {
|
||||
attachRelay,
|
||||
PrivateEndpoint,
|
||||
probeAuthenticatedEndpoint,
|
||||
type PeerIdentity
|
||||
} from './private-endpoint'
|
||||
import { AgentProtocolServer } from './protocol-server'
|
||||
@@ -51,6 +52,57 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('private endpoint lifecycle', () => {
|
||||
runOnUnix.each(['-', '_'])('accepts a valid HMAC beginning with %s', async (prefix) => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const verifier = new InstallationChallengeVerifier(Buffer.alloc(32, 7))
|
||||
const attached = vi.fn()
|
||||
const endpoint = new PrivateEndpoint({
|
||||
socketPath: join(root, 'agent.sock'),
|
||||
peerIdentity: currentPeerIdentity(), challenge: verifier,
|
||||
controllers: new ControllerRegistry(), ...endpointIdentity, onAttach: attached
|
||||
})
|
||||
await endpoint.listen()
|
||||
const client = createConnection(join(root, 'agent.sock'))
|
||||
try {
|
||||
await onceConnected(client)
|
||||
const challenge = await readPacket(client)
|
||||
let clientNonce = ''
|
||||
let response = ''
|
||||
for (let index = 0; index < 4096 && !response.startsWith(prefix); index++) {
|
||||
clientNonce = `client-${index}`
|
||||
response = verifier.createResponse({
|
||||
serverNonce: String(challenge.serverNonce), clientNonce, controllerId: 'controller-a'
|
||||
})
|
||||
}
|
||||
expect(response.startsWith(prefix)).toBe(true)
|
||||
await writePacket(client, {
|
||||
type: 'response', version: 1, controllerId: 'controller-a', clientNonce,
|
||||
protocol: AGENT_PROTOCOL_VERSION, response
|
||||
})
|
||||
expect(attachWelcomeSchema.parse(await readPacket(client)).installationId).toBe('installation-a')
|
||||
await vi.waitFor(() => expect(attached).toHaveBeenCalledOnce())
|
||||
} finally {
|
||||
client.destroy()
|
||||
await endpoint.close()
|
||||
}
|
||||
})
|
||||
|
||||
runOnUnix('rejects a failed health handshake instead of leaving an unsettled read', async () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const socketPath = join(root, 'agent.sock')
|
||||
const endpoint = new PrivateEndpoint({
|
||||
socketPath, peerIdentity: currentPeerIdentity(),
|
||||
challenge: new InstallationChallengeVerifier(Buffer.alloc(32, 7)),
|
||||
controllers: new ControllerRegistry(), ...endpointIdentity, onAttach: vi.fn()
|
||||
})
|
||||
await endpoint.listen()
|
||||
try {
|
||||
await expect(probeAuthenticatedEndpoint({
|
||||
socketPath, secret: Buffer.alloc(32, 8), ...endpointIdentity
|
||||
})).rejects.toThrow('closed during handshake')
|
||||
} finally { await endpoint.close() }
|
||||
})
|
||||
|
||||
runOnUnix(
|
||||
'never pre-unlinks an endpoint that may still be live',
|
||||
async () => {
|
||||
@@ -346,6 +398,20 @@ describe('private endpoint lifecycle', () => {
|
||||
})
|
||||
|
||||
describe('attach relay preface validation', () => {
|
||||
it.each(['before', 'during'])('rejects a stream destroyed %s its preface arrives', async (when) => {
|
||||
const input = new PassThrough()
|
||||
if (when === 'before') input.destroy()
|
||||
const relay = attachRelay({
|
||||
socketPath: resolve('missing-agent.sock'), secret: Buffer.alloc(32, 7),
|
||||
input, output: new PassThrough()
|
||||
})
|
||||
const rejection = expect(relay).rejects.toThrow('closed during preface')
|
||||
if (when === 'during') input.destroy()
|
||||
await rejection
|
||||
expect(input.listenerCount('readable')).toBe(0)
|
||||
expect(input.listenerCount('close')).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects unknown Main preface fields before opening an endpoint', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new PassThrough()
|
||||
|
||||
@@ -51,7 +51,8 @@ const challengeResponsePacketSchema = z
|
||||
controllerId: agentIdentifierSchema,
|
||||
clientNonce: agentIdentifierSchema,
|
||||
protocol: protocolVersionSchema,
|
||||
response: agentIdentifierSchema
|
||||
// A SHA-256 HMAC is canonical Base64URL, not an application identifier.
|
||||
response: z.string().regex(/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/u)
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -400,7 +401,7 @@ export async function probeAuthenticatedEndpoint(options: {
|
||||
const challenge = challengePacketSchema.parse(await readPacket(socket))
|
||||
const verifier = new InstallationChallengeVerifier(options.secret)
|
||||
const controllerId = 'lifecycle-health'
|
||||
const clientNonce = randomBytes(24).toString('base64url')
|
||||
const clientNonce = `health-${randomBytes(24).toString('base64url')}`
|
||||
await writePacket(socket, {
|
||||
type: 'response',
|
||||
version: 1,
|
||||
@@ -559,6 +560,9 @@ async function readExactly(socket: Socket, byteLength: number): Promise<Buffer>
|
||||
remaining -= chunk.byteLength
|
||||
continue
|
||||
}
|
||||
if (socket.destroyed || socket.readableEnded) {
|
||||
throw new Error('Attach socket closed during handshake')
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onReadable = (): void => {
|
||||
cleanup()
|
||||
@@ -575,10 +579,12 @@ async function readExactly(socket: Socket, byteLength: number): Promise<Buffer>
|
||||
const cleanup = (): void => {
|
||||
socket.off('readable', onReadable)
|
||||
socket.off('end', onEnd)
|
||||
socket.off('close', onEnd)
|
||||
socket.off('error', onError)
|
||||
}
|
||||
socket.once('readable', onReadable)
|
||||
socket.once('end', onEnd)
|
||||
socket.once('close', onEnd)
|
||||
socket.once('error', onError)
|
||||
})
|
||||
}
|
||||
@@ -588,6 +594,8 @@ async function readExactly(socket: Socket, byteLength: number): Promise<Buffer>
|
||||
async function readExactlyFromStream(
|
||||
stream: NodeJS.ReadableStream & {
|
||||
read(size?: number): Buffer | null
|
||||
destroyed?: boolean
|
||||
readableEnded?: boolean
|
||||
},
|
||||
byteLength: number
|
||||
): Promise<Buffer> {
|
||||
@@ -601,6 +609,9 @@ async function readExactlyFromStream(
|
||||
remaining -= bytes.byteLength
|
||||
continue
|
||||
}
|
||||
if (stream.destroyed || stream.readableEnded) {
|
||||
throw new Error('Attach relay input closed during preface')
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onReadable = (): void => {
|
||||
cleanup()
|
||||
@@ -617,10 +628,12 @@ async function readExactlyFromStream(
|
||||
const cleanup = (): void => {
|
||||
stream.removeListener('readable', onReadable)
|
||||
stream.removeListener('end', onEnd)
|
||||
stream.removeListener('close', onEnd)
|
||||
stream.removeListener('error', onError)
|
||||
}
|
||||
stream.once('readable', onReadable)
|
||||
stream.once('end', onEnd)
|
||||
stream.once('close', onEnd)
|
||||
stream.once('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,6 +55,12 @@ describe('bundled runtime paths', () => {
|
||||
'opencode',
|
||||
'opencode.exe'
|
||||
),
|
||||
opencodeConfig: join(
|
||||
'installed',
|
||||
'resources',
|
||||
'runtimes',
|
||||
'opencode-config'
|
||||
),
|
||||
continue: join(
|
||||
'installed',
|
||||
'resources',
|
||||
|
||||
@@ -1971,7 +1971,12 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(inventory.detail).toContain('不提供 Resources')
|
||||
})
|
||||
|
||||
it('bridges authenticated QuizService questions and cleans answered mappings', async () => {
|
||||
it.each([
|
||||
{ options: ['Safe', 'Fast'], answer: [['Safe']], submitted: 'Safe', custom: false },
|
||||
{ options: ['yes', 'no'], answer: [['no']], submitted: 'no', custom: false },
|
||||
{ options: [], answer: [['Free text']], submitted: 'Free text', custom: true },
|
||||
{ options: ['yes', 'no'], answer: undefined, submitted: 'User declined to answer this question.', custom: true }
|
||||
])('bridges QuizService answers $submitted and cleans mappings', async (scenario) => {
|
||||
const distribution = await createDistribution()
|
||||
const configPath = join(
|
||||
distribution.cacheRoot,
|
||||
@@ -2017,11 +2022,9 @@ describe('ContinueHostAdapter', () => {
|
||||
goodbuddyQuestion: {
|
||||
requestId: 'quiz-123',
|
||||
timestamp: Date.now(),
|
||||
question: {
|
||||
question: 'Choose safely',
|
||||
options: ['Safe', 'Fast'],
|
||||
defaultAnswer: 'Safe'
|
||||
}
|
||||
question: 'Choose safely',
|
||||
options: scenario.options,
|
||||
defaultAnswer: 'Safe'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2072,7 +2075,7 @@ describe('ContinueHostAdapter', () => {
|
||||
if (event.type === 'question') {
|
||||
await adapter.respondToQuestion(
|
||||
event.questionId,
|
||||
[['Safe']]
|
||||
scenario.answer
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2087,10 +2090,7 @@ describe('ContinueHostAdapter', () => {
|
||||
questions: [
|
||||
expect.objectContaining({
|
||||
question: 'Choose safely',
|
||||
options: [
|
||||
{ label: 'Safe', description: '' },
|
||||
{ label: 'Fast', description: '' }
|
||||
]
|
||||
options: scenario.options.map(label => ({ label, description: '' }))
|
||||
})
|
||||
]
|
||||
})
|
||||
@@ -2098,8 +2098,8 @@ describe('ContinueHostAdapter', () => {
|
||||
expect(answerBodies).toEqual([
|
||||
{
|
||||
requestId: 'quiz-123',
|
||||
answer: 'Safe',
|
||||
isCustomAnswer: false
|
||||
answer: scenario.submitted,
|
||||
isCustomAnswer: scenario.custom
|
||||
}
|
||||
])
|
||||
await expect(
|
||||
|
||||
@@ -122,16 +122,12 @@ const continueHostQuestionSchema = z
|
||||
.object({
|
||||
requestId: z.string().min(1).max(128),
|
||||
timestamp: z.number().finite().optional(),
|
||||
question: z
|
||||
.object({
|
||||
question: z.string().trim().min(1).max(2_000),
|
||||
options: z
|
||||
.array(z.string().trim().min(1).max(200))
|
||||
.max(20)
|
||||
.optional(),
|
||||
defaultAnswer: z.string().trim().max(2_000).optional()
|
||||
})
|
||||
.passthrough()
|
||||
question: z.string().trim().min(1).max(2_000),
|
||||
options: z
|
||||
.array(z.string().trim().min(1).max(200))
|
||||
.max(20)
|
||||
.optional(),
|
||||
defaultAnswer: z.string().trim().max(2_000).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -812,6 +808,7 @@ export class ContinueHostAdapter {
|
||||
origin: string
|
||||
token: string
|
||||
signal: AbortSignal
|
||||
options: readonly string[]
|
||||
}
|
||||
>()
|
||||
private preparation?: Promise<PreparedHost>
|
||||
@@ -1111,7 +1108,7 @@ export class ContinueHostAdapter {
|
||||
throw new Error('Continue 提问回答数量不匹配')
|
||||
}
|
||||
answer = answers[0][0].trim()
|
||||
isCustomAnswer = false
|
||||
isCustomAnswer = !pending.options.includes(answer)
|
||||
}
|
||||
await this.request(
|
||||
pending.origin,
|
||||
@@ -1711,7 +1708,8 @@ export class ContinueHostAdapter {
|
||||
this.pendingQuestions.set(pendingQuestion.requestId, {
|
||||
origin,
|
||||
token,
|
||||
signal: executionSignal
|
||||
signal: executionSignal,
|
||||
options: pendingQuestion.options ?? []
|
||||
})
|
||||
await runOptions.onEvent?.({
|
||||
type: 'question',
|
||||
@@ -1719,9 +1717,9 @@ export class ContinueHostAdapter {
|
||||
questions: [
|
||||
{
|
||||
header: 'Continue',
|
||||
question: pendingQuestion.question.question,
|
||||
question: pendingQuestion.question,
|
||||
options: (
|
||||
pendingQuestion.question.options ?? []
|
||||
pendingQuestion.options ?? []
|
||||
).map((option) => ({
|
||||
label: option,
|
||||
description: ''
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment node
|
||||
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { ContinueAgentRuntime } from './continue-runtime'
|
||||
|
||||
it('completes Execute external writes and native free-text questions without a second approval', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-continue-permissions-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const externalFile = join(root, 'external.txt')
|
||||
await mkdir(workspace)
|
||||
const command = process.platform === 'win32'
|
||||
? `Set-Content -LiteralPath '${externalFile.replaceAll("'", "''")}' -Value 'CONTINUE_EXTERNAL_OK' -NoNewline`
|
||||
: `printf CONTINUE_EXTERNAL_OK > '${externalFile.replaceAll("'", "'\\''")}'`
|
||||
let requests = 0
|
||||
let questions = 0
|
||||
const server = createServer((request, response) => {
|
||||
void (async () => {
|
||||
for await (const chunk of request) { void chunk }
|
||||
if (++requests > 3) {
|
||||
response.writeHead(500).end('Unexpected extra model request')
|
||||
return
|
||||
}
|
||||
const tool = requests <= 2
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
for (const [delta, finishReason] of [
|
||||
[tool ? {
|
||||
role: 'assistant', tool_calls: [{
|
||||
index: 0, id: requests === 1 ? 'external-write' : 'native-question', type: 'function',
|
||||
function: requests === 1
|
||||
? { name: 'Bash', arguments: JSON.stringify({ command }) }
|
||||
: { name: 'AskQuestion', arguments: JSON.stringify({ question: 'Add details', options: [] }) }
|
||||
}]
|
||||
} : { role: 'assistant', content: 'CONTINUE_DONE' }, null],
|
||||
[{}, tool ? 'tool_calls' : 'stop']
|
||||
]) {
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: `continue-${requests}`, object: 'chat.completion.chunk', created: 1,
|
||||
model: 'permissions', choices: [{ index: 0, delta, finish_reason: finishReason }]
|
||||
})}\n\n`)
|
||||
}
|
||||
response.end('data: [DONE]\n\n')
|
||||
})().catch(() => response.destroy())
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('No model port')
|
||||
const runtime = new ContinueAgentRuntime({
|
||||
binaryPath: '', bundledBinaryPath: join(process.cwd(), 'node_modules', '@continuedev', 'cli', 'dist', 'cn.js'),
|
||||
configPath: '', defaultWorkspace: workspace, hostCacheRoot: join(root, 'cache'),
|
||||
modelProfile: {
|
||||
id: crypto.randomUUID(), name: 'Permissions fixture', modelName: 'permissions',
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
protocol: 'openai-chat-completions', authentication: 'none'
|
||||
}
|
||||
})
|
||||
try {
|
||||
let text = ''
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(),
|
||||
workMode: 'execute', prompt: 'Write the external test sentinel, then reply CONTINUE_DONE.'
|
||||
}, AbortSignal.timeout(30_000))) {
|
||||
if (event.type === 'text') text += event.delta
|
||||
if (event.type === 'question') {
|
||||
questions++
|
||||
expect(event.questions[0]).toMatchObject({ custom: true, options: [] })
|
||||
await runtime.respondToQuestion(event.questionId, [['Custom answer']])
|
||||
}
|
||||
}
|
||||
expect(text).toContain('CONTINUE_DONE')
|
||||
expect(await readFile(externalFile, 'utf8')).toBe('CONTINUE_EXTERNAL_OK')
|
||||
expect(requests).toBe(3)
|
||||
expect(questions).toBe(1)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
server.closeAllConnections()
|
||||
await new Promise<void>(resolve => server.close(() => resolve()))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 60_000)
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
writeFile
|
||||
@@ -383,6 +384,49 @@ function createInProcessLaunch(
|
||||
}
|
||||
|
||||
describe('DeepSeek Harness real ACP control-plane E2E', () => {
|
||||
it('writes outside the Workspace in Execute without approval and rejects the same tool in Ask', async () => {
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-')))
|
||||
const workspace = join(root, 'workspace')
|
||||
const dshHome = join(root, 'home')
|
||||
await Promise.all([mkdir(workspace), mkdir(dshHome)])
|
||||
const externalFile = join(root, 'external.txt')
|
||||
const results: string[] = []
|
||||
const inProcess = createInProcessLaunch(dshHome, {
|
||||
stream(options) {
|
||||
const result = toolResultText(options, 'external-write')
|
||||
if (result !== undefined) {
|
||||
results.push(result)
|
||||
return textResponse('DIRECTORY_TEST_DONE')
|
||||
}
|
||||
return toolCall('external-write', 'write', {
|
||||
file_path: externalFile, content: latestUserText(options)
|
||||
})
|
||||
}
|
||||
})
|
||||
const runtime = new DeepSeekHarnessRuntime({
|
||||
defaultWorkspace: workspace, baseUrl: 'https://api.deepseek.com',
|
||||
model: 'directory-test', launch: options => inProcess.launch(options),
|
||||
credentialRefs: { [CREDENTIAL_REF]: 'unused-in-memory-model-credential' },
|
||||
initializationTimeoutMs: 20_000, promptTimeoutMs: 20_000, shutdownTimeoutMs: 5_000
|
||||
})
|
||||
const authorize = vi.fn(async () => 'deny' as const)
|
||||
try {
|
||||
for (const mode of ['execute', 'ask'] as const) {
|
||||
const events = await collect(runtime.run({
|
||||
requestId: `directory-${mode}`, conversationId: `directory-${mode}`,
|
||||
workMode: mode, prompt: mode === 'execute' ? 'EXECUTE_WRITTEN' : 'ASK_MUST_NOT_WRITE'
|
||||
}, AbortSignal.timeout(20_000), authorize))
|
||||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||||
expect(await readFile(externalFile, 'utf8')).toBe('EXECUTE_WRITTEN')
|
||||
}
|
||||
expect(results).toHaveLength(2)
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('delivers bounded inline images to an image-capable Harness model', async () => {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), 'goodbuddy-harness-acp-image-'))
|
||||
|
||||
@@ -858,9 +858,9 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
await harness.runtime.dispose()
|
||||
})
|
||||
|
||||
it('authorizes Execute but can select only allow-once', async () => {
|
||||
it('answers Execute permissions without waiting for a second authorizer', async () => {
|
||||
const harness = setup()
|
||||
const authorize = vi.fn().mockResolvedValue('always')
|
||||
const authorize = vi.fn(() => new Promise<never>(() => {}))
|
||||
const running = collect(
|
||||
harness.runtime.run(
|
||||
request('execute'),
|
||||
@@ -880,13 +880,11 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
optionId: 'allow-once'
|
||||
}
|
||||
})
|
||||
expect(authorize).toHaveBeenCalledWith({
|
||||
scopeKey: 'deepseek-harness:shell',
|
||||
title: 'Run tests',
|
||||
description: 'DeepSeek Harness 请求一次性执行此工具',
|
||||
toolName: 'shell',
|
||||
argumentSummary: '{\n "command": "npm test"\n}',
|
||||
allowPermanent: false
|
||||
expect(authorize).not.toHaveBeenCalled()
|
||||
const persistentOnly = permission('session-1')
|
||||
persistentOnly.options = persistentOnly.options.filter(option => option.kind !== 'allow_once')
|
||||
await expect(harness.permission(persistentOnly)).resolves.toEqual({
|
||||
outcome: { outcome: 'selected', optionId: 'allow-always' }
|
||||
})
|
||||
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
|
||||
await running
|
||||
@@ -1194,7 +1192,8 @@ describe('DeepSeekHarnessRuntime', () => {
|
||||
assignments: ['deepseek-harness'],
|
||||
secretConfigured: false,
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
// Electron's execPath needs an env flag that the MCP SDK does not inherit.
|
||||
command: process.versions.electron ? 'node' : process.execPath,
|
||||
args: [
|
||||
resolve('tests', 'fixtures', 'web-3d-game-mcp.mjs')
|
||||
]
|
||||
|
||||
@@ -754,12 +754,11 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
)
|
||||
const allowOnce = permission.options.find(
|
||||
(option) => option.kind === 'allow_once'
|
||||
)
|
||||
) ?? permission.options.find((option) => option.kind === 'allow_always')
|
||||
if (
|
||||
!run ||
|
||||
run.closed ||
|
||||
run.request.workMode !== 'execute' ||
|
||||
!run.authorize
|
||||
run.request.workMode !== 'execute'
|
||||
) {
|
||||
return reject
|
||||
? {
|
||||
@@ -771,26 +770,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime {
|
||||
: { outcome: { outcome: 'cancelled' } }
|
||||
}
|
||||
|
||||
const argumentSummary = safeStringify(
|
||||
permission.toolCall.rawInput
|
||||
)
|
||||
const decision: Awaited<ReturnType<RuntimeAuthorizer>> =
|
||||
await run
|
||||
.authorize({
|
||||
scopeKey: `deepseek-harness:${permission.toolCall.name ?? permission.toolCall.kind ?? 'tool'}`,
|
||||
title:
|
||||
(permission.toolCall.title ??
|
||||
permission.toolCall.name ??
|
||||
'DeepSeek Harness 工具请求').slice(0, 200),
|
||||
description: 'DeepSeek Harness 请求一次性执行此工具',
|
||||
...(permission.toolCall.name
|
||||
? { toolName: permission.toolCall.name.slice(0, 200) }
|
||||
: {}),
|
||||
...(argumentSummary ? { argumentSummary } : {}),
|
||||
allowPermanent: false
|
||||
})
|
||||
.catch(() => 'deny')
|
||||
if (decision !== 'deny' && allowOnce) {
|
||||
if (allowOnce) {
|
||||
return {
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment node
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { OpenCodeRuntime } from './opencode-runtime'
|
||||
|
||||
const cachedBinary = join(
|
||||
process.cwd(), '.runtime-resources', process.arch,
|
||||
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
|
||||
)
|
||||
const binaryPath = process.env.GOODBUDDY_TEST_OPENCODE_BINARY || cachedBinary
|
||||
|
||||
it.skipIf(!existsSync(binaryPath))(
|
||||
'completes native Task external reads and structured yes/no and text questions while Ask disables tools',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-permissions-'))
|
||||
const workspace = join(root, 'workspace')
|
||||
const externalFile = join(root, 'external.txt')
|
||||
await mkdir(workspace)
|
||||
await writeFile(externalFile, 'EXTERNAL_DIRECTORY_READ_OK\n')
|
||||
let requests = 0
|
||||
let readVerified = false
|
||||
let answerVerified = false
|
||||
let askTools: string[] | undefined
|
||||
const model = createServer((request, response) => {
|
||||
void (async () => {
|
||||
let body = ''
|
||||
for await (const chunk of request) body += chunk.toString()
|
||||
const input = JSON.parse(body)
|
||||
if (++requests > 7) {
|
||||
response.writeHead(500).end('Unexpected extra model request')
|
||||
return
|
||||
}
|
||||
const messages = input.messages as Array<{ role: string; content: unknown; tool_call_id?: string }>
|
||||
const user = JSON.stringify(messages.filter(message => message.role === 'user').at(-1)?.content)
|
||||
const results = messages.filter(message => message.role === 'tool')
|
||||
let content: string | undefined
|
||||
let tool: { name: string; arguments: object; id: string } | undefined
|
||||
if (user.includes('ASK_WITHOUT_TOOLS')) {
|
||||
askTools = (input.tools ?? []).map((entry: { function: { name: string } }) => entry.function.name)
|
||||
content = 'ASK_OK'
|
||||
} else if (user.includes('READ_EXTERNAL_SENTINEL')) {
|
||||
if (results.some(result => result.tool_call_id === 'external-read')) {
|
||||
readVerified = JSON.stringify(results).includes('EXTERNAL_DIRECTORY_READ_OK')
|
||||
content = 'CHILD_OK'
|
||||
} else {
|
||||
tool = { id: 'external-read', name: 'read', arguments: { filePath: externalFile } }
|
||||
}
|
||||
} else if (results.some(result => result.tool_call_id === 'native-question')) {
|
||||
answerVerified = JSON.stringify(results).includes('Yes') && JSON.stringify(results).includes('Custom answer')
|
||||
content = 'PARENT_OK'
|
||||
} else if (results.some(result => result.tool_call_id === 'native-task')) {
|
||||
tool = { id: 'native-question', name: 'question', arguments: { questions: [
|
||||
{ header: 'Confirm', question: 'Continue?', options: [
|
||||
{ label: 'Yes', description: 'Continue' }, { label: 'No', description: 'Stop' }
|
||||
], custom: false },
|
||||
{ header: 'Details', question: 'Add details', options: [], custom: true }
|
||||
] } }
|
||||
} else {
|
||||
tool = {
|
||||
id: 'native-task', name: 'task',
|
||||
arguments: {
|
||||
subagent_type: 'general', description: 'Read external test file',
|
||||
prompt: `READ_EXTERNAL_SENTINEL: read ${externalFile}, then reply CHILD_OK.`
|
||||
}
|
||||
}
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
for (const [delta, finishReason] of [
|
||||
[tool ? {
|
||||
role: 'assistant',
|
||||
tool_calls: [{ index: 0, id: tool.id, type: 'function', function: {
|
||||
name: tool.name, arguments: JSON.stringify(tool.arguments)
|
||||
} }]
|
||||
} : { role: 'assistant', content }, null],
|
||||
[{}, tool ? 'tool_calls' : 'stop']
|
||||
]) {
|
||||
response.write(`data: ${JSON.stringify({
|
||||
id: `permissions-${requests}`, object: 'chat.completion.chunk', created: 1,
|
||||
model: 'permissions', choices: [{ index: 0, delta, finish_reason: finishReason }]
|
||||
})}\n\n`)
|
||||
}
|
||||
response.end('data: [DONE]\n\n')
|
||||
})().catch(() => response.destroy())
|
||||
})
|
||||
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
|
||||
const address = model.address()
|
||||
if (!address || typeof address === 'string') throw new Error('No model port')
|
||||
const runtime = new OpenCodeRuntime({
|
||||
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath, configPath: '',
|
||||
bundledConfigPath: join(process.cwd(), '.runtime-resources', 'opencode-config'),
|
||||
defaultWorkspace: workspace,
|
||||
modelProfile: {
|
||||
id: crypto.randomUUID(), name: 'Permissions fixture', modelName: 'permissions',
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
protocol: 'openai-chat-completions', authentication: 'none'
|
||||
}
|
||||
})
|
||||
try {
|
||||
for (const mode of ['execute', 'ask'] as const) {
|
||||
let text = ''
|
||||
for await (const event of runtime.run({
|
||||
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(),
|
||||
workMode: mode, prompt: mode === 'execute' ? 'USE_NATIVE_TASK' : 'ASK_WITHOUT_TOOLS'
|
||||
}, AbortSignal.timeout(30_000))) {
|
||||
if (event.type === 'text') text += event.delta
|
||||
if (event.type === 'question') {
|
||||
expect(event.questions).toHaveLength(2)
|
||||
await runtime.respondToQuestion(event.questionId, [['Yes'], ['Custom answer']])
|
||||
}
|
||||
}
|
||||
expect(text).toBe(mode === 'execute' ? 'PARENT_OK' : 'ASK_OK')
|
||||
}
|
||||
expect(readVerified).toBe(true)
|
||||
expect(answerVerified).toBe(true)
|
||||
expect(askTools).toBeDefined()
|
||||
expect(askTools).not.toEqual(expect.arrayContaining(['task']))
|
||||
expect(askTools?.filter(name => ['bash', 'write', 'edit', 'read'].includes(name))).toEqual([])
|
||||
expect(requests).toBe(6)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
model.closeAllConnections()
|
||||
await new Promise<void>(resolve => model.close(() => resolve()))
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
90_000
|
||||
)
|
||||
@@ -185,6 +185,7 @@ function runClient(events: Record<string, unknown>[]) {
|
||||
});
|
||||
const client = {
|
||||
session: {
|
||||
get: vi.fn().mockResolvedValue({ data: { id: "unrelated-session" } }),
|
||||
list: vi.fn().mockResolvedValue({ data: [], error: undefined }),
|
||||
create: vi.fn().mockResolvedValue({
|
||||
data: { id: "session-1" },
|
||||
@@ -598,6 +599,7 @@ describe("OpenCodeRuntime embedded launcher", () => {
|
||||
urls: [],
|
||||
},
|
||||
permission: {
|
||||
"*": "allow",
|
||||
skill: {
|
||||
"*": "deny",
|
||||
"longdoc-docx": "allow",
|
||||
@@ -620,6 +622,33 @@ describe("OpenCodeRuntime embedded launcher", () => {
|
||||
await expect(stat(registrationRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each(["1.18.29", "0.0.1"])("handles cached plugin %s without ready markers", async (cachedVersion) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "goodbuddy-opencode-version-"));
|
||||
const bundledConfigPath = join(root, "bundle");
|
||||
const sharedCacheRoot = join(root, "cache");
|
||||
const target = join(sharedCacheRoot, "config", "opencode");
|
||||
const packagePath = join("node_modules", "@opencode-ai", "plugin", "package.json");
|
||||
for (const [directory, version] of [[bundledConfigPath, "1.18.29"], [target, cachedVersion]]) {
|
||||
await mkdir(join(directory!, "node_modules", "@opencode-ai", "plugin"), { recursive: true });
|
||||
await writeFile(join(directory!, packagePath), JSON.stringify({ version }));
|
||||
}
|
||||
await writeFile(join(target, "cached-only"), "old cache");
|
||||
const { deps } = dependencies(fakeChild());
|
||||
const runtime = new OpenCodeRuntime(options({ bundledConfigPath, sharedCacheRoot }), deps);
|
||||
try {
|
||||
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
|
||||
expect(JSON.parse(await readFile(join(target, packagePath), "utf8")).version).toBe("1.18.29");
|
||||
if (cachedVersion === "1.18.29") {
|
||||
await expect(readFile(join(target, "cached-only"), "utf8")).resolves.toBe("old cache");
|
||||
} else {
|
||||
await expect(stat(join(target, "cached-only"))).rejects.toThrow();
|
||||
}
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses shared OpenCode config and content-addressed Skills across projects", async () => {
|
||||
const sourceRoot = await mkdtemp(
|
||||
join(tmpdir(), "goodbuddy-opencode-shared-skill-source-"),
|
||||
@@ -634,11 +663,6 @@ describe("OpenCodeRuntime embedded launcher", () => {
|
||||
join(bundledConfigPath, "node_modules", "@opencode-ai", "plugin"),
|
||||
{ recursive: true },
|
||||
);
|
||||
await writeFile(
|
||||
join(bundledConfigPath, ".goodbuddy-ready.json"),
|
||||
'{"version":"1.18.29"}\n',
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(
|
||||
bundledConfigPath,
|
||||
@@ -1877,14 +1901,14 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("parses OpenCode questions and sends the selected answers back", async () => {
|
||||
it.each(["parent", "child"] as const)("parses %s OpenCode questions and sends selected answers back", async (owner) => {
|
||||
const setup = runClient([
|
||||
{
|
||||
id: "question-event",
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: "question-1",
|
||||
sessionID: "session-1",
|
||||
sessionID: owner === "parent" ? "session-1" : "child-session",
|
||||
questions: [
|
||||
{
|
||||
header: "实现方式",
|
||||
@@ -1915,6 +1939,9 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
properties: { sessionID: "session-1" },
|
||||
},
|
||||
]);
|
||||
vi.mocked(setup.client.session.get).mockResolvedValue({
|
||||
data: { id: "child-session", parentID: "session-1" },
|
||||
} as never);
|
||||
const runtime = embeddedRuntime(setup.client);
|
||||
const stream = runtime.run(
|
||||
{
|
||||
@@ -3263,6 +3290,45 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
|
||||
await runtime.dispose();
|
||||
});
|
||||
|
||||
it.each(["ask", "execute"] as const)("settles owned descendant directory permissions in %s without touching peer sessions", async (mode) => {
|
||||
const { client, permissionReply } = runClient([
|
||||
permissionEvent({ id: "peer-permission", sessionID: "peer-session" }),
|
||||
permissionEvent({
|
||||
id: "child-permission", sessionID: "grandchild-session",
|
||||
permission: "external_directory", patterns: ["/tmp/logs/*"],
|
||||
}),
|
||||
permissionEvent({
|
||||
id: "child-permission", sessionID: "grandchild-session",
|
||||
permission: "external_directory", patterns: ["/tmp/logs/*"],
|
||||
}),
|
||||
{
|
||||
id: "event-idle", type: "session.idle",
|
||||
properties: { sessionID: "session-1" },
|
||||
},
|
||||
]);
|
||||
const sessionGet = vi.fn(async ({ sessionID }: { sessionID: string }) => ({
|
||||
data: {
|
||||
id: sessionID,
|
||||
parentID: sessionID === "grandchild-session" ? "child-session"
|
||||
: sessionID === "child-session" ? "session-1" : undefined,
|
||||
},
|
||||
}));
|
||||
client.session.get = sessionGet as unknown as typeof client.session.get;
|
||||
const runtime = embeddedRuntime(client);
|
||||
try {
|
||||
const events = await collectRun(runtime, mode);
|
||||
expect(events.at(-1)).toMatchObject({ type: "done" });
|
||||
expect(events.filter((event) => event.type === "tool")).toEqual([]);
|
||||
expect(permissionReply).toHaveBeenCalledExactlyOnceWith({
|
||||
requestID: "child-permission", directory: process.cwd(),
|
||||
reply: mode === "execute" ? "once" : "reject",
|
||||
}, { signal: expect.any(AbortSignal) });
|
||||
expect(sessionGet).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails the run when a tool reports an error before session idle", async () => {
|
||||
const { client, session } = runClient([
|
||||
{
|
||||
|
||||
@@ -625,6 +625,7 @@ function createOpenCodeSkillConfig(
|
||||
): {
|
||||
skills: { paths: string[]; urls: never[] };
|
||||
permission: {
|
||||
"*": "allow";
|
||||
skill: Record<string, "allow" | "deny">;
|
||||
};
|
||||
} {
|
||||
@@ -634,6 +635,7 @@ function createOpenCodeSkillConfig(
|
||||
urls: [],
|
||||
},
|
||||
permission: {
|
||||
"*": "allow",
|
||||
skill: createSkillPermissionConfig(skillIds),
|
||||
},
|
||||
};
|
||||
@@ -961,12 +963,20 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
}
|
||||
const preparation = (async () => {
|
||||
const source = resolve(bundledConfigPath);
|
||||
const markerName = ".goodbuddy-ready.json";
|
||||
const sourceMarker = await readFile(join(source, markerName), "utf8");
|
||||
const packagePath = join(
|
||||
"node_modules",
|
||||
"@opencode-ai",
|
||||
"plugin",
|
||||
"package.json",
|
||||
);
|
||||
const sourcePackage = JSON.parse(
|
||||
await readFile(join(source, packagePath), "utf8"),
|
||||
) as { version: string };
|
||||
try {
|
||||
if (
|
||||
(await readFile(join(target, markerName), "utf8")) === sourceMarker
|
||||
) {
|
||||
const installed = JSON.parse(
|
||||
await readFile(join(target, packagePath), "utf8"),
|
||||
) as { version?: string };
|
||||
if (installed.version === sourcePackage.version) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -2399,6 +2409,29 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
prompt.catch(() => undefined);
|
||||
|
||||
const repliedPermissionIds = new Set<string>();
|
||||
const ownedSessions = new Set([sessionId]);
|
||||
const ownsSession = async (candidate: string): Promise<boolean> => {
|
||||
const visited = new Set<string>();
|
||||
let current: string | undefined = candidate;
|
||||
while (current && !visited.has(current)) {
|
||||
if (ownedSessions.has(current)) {
|
||||
for (const id of visited) ownedSessions.add(id);
|
||||
return true;
|
||||
}
|
||||
visited.add(current);
|
||||
const result = await this.controlRequest(
|
||||
"确认子会话权限归属",
|
||||
(controlSignal) => client.session.get(
|
||||
{ sessionID: current!, directory },
|
||||
{ signal: controlSignal },
|
||||
),
|
||||
signal,
|
||||
);
|
||||
if (result.error || !result.data) return false;
|
||||
current = result.data.parentID;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const reportedMessageIds = new Set<string>();
|
||||
for await (const event of subscription.stream) {
|
||||
const childProgress = subagentProgress.update(event);
|
||||
@@ -2563,11 +2596,11 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
|
||||
if (
|
||||
event.type === "question.asked" &&
|
||||
event.properties.sessionID === sessionId
|
||||
await ownsSession(event.properties.sessionID)
|
||||
) {
|
||||
const questionRequest = parseQuestionRequest(
|
||||
event.properties,
|
||||
sessionId,
|
||||
event.properties.sessionID,
|
||||
);
|
||||
if (
|
||||
questionRequest &&
|
||||
@@ -2575,7 +2608,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
) {
|
||||
const publicQuestionId = createPublicQuestionId(
|
||||
request.requestId,
|
||||
sessionId,
|
||||
event.properties.sessionID,
|
||||
questionRequest.id,
|
||||
);
|
||||
if (this.pendingQuestions.has(publicQuestionId)) {
|
||||
@@ -2609,7 +2642,7 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
if (
|
||||
(event.type === "question.replied" ||
|
||||
event.type === "question.rejected") &&
|
||||
event.properties.sessionID === sessionId
|
||||
reportedQuestionIds.has(event.properties.requestID)
|
||||
) {
|
||||
const publicQuestionId = reportedQuestionIds.get(
|
||||
event.properties.requestID,
|
||||
@@ -2624,17 +2657,19 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
event.type === "permission.asked"
|
||||
) {
|
||||
const properties = event.properties as unknown;
|
||||
const permissionSessionId = isRecord(properties) &&
|
||||
typeof properties.sessionID === "string"
|
||||
? properties.sessionID
|
||||
: sessionId;
|
||||
if (
|
||||
isRecord(properties) &&
|
||||
typeof properties.sessionID === "string" &&
|
||||
properties.sessionID !== sessionId
|
||||
!await ownsSession(permissionSessionId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let permissionRequest: PermissionRequest;
|
||||
try {
|
||||
const parsed = parsePermissionRequest(properties, sessionId);
|
||||
const parsed = parsePermissionRequest(properties, permissionSessionId);
|
||||
if (!parsed) {
|
||||
throw new Error("OpenCode 权限请求格式无效");
|
||||
}
|
||||
@@ -2680,15 +2715,17 @@ export class OpenCodeRuntime implements AgentRuntime {
|
||||
const callId =
|
||||
permissionRequest.tool?.callID ?? permissionRequest.id;
|
||||
const toolName = permissionRequest.permission.slice(0, 200);
|
||||
toolStates.set(callId, { name: toolName, state: "pending" });
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: "tool",
|
||||
callId,
|
||||
name: toolName,
|
||||
state: "pending",
|
||||
summary: `OpenCode 工具:${toolName}`,
|
||||
};
|
||||
if (permissionSessionId === sessionId) {
|
||||
toolStates.set(callId, { name: toolName, state: "pending" });
|
||||
yield {
|
||||
requestId: request.requestId,
|
||||
type: "tool",
|
||||
callId,
|
||||
name: toolName,
|
||||
state: "pending",
|
||||
summary: `OpenCode 工具:${toolName}`,
|
||||
};
|
||||
}
|
||||
const allowKnowledge =
|
||||
request.workMode === "ask" &&
|
||||
knowledgeToolIds.includes(permissionRequest.permission);
|
||||
|
||||
@@ -114,14 +114,16 @@ describe('bundled skills', () => {
|
||||
service.getRuntimeSkillContext('deepseek-harness')
|
||||
).resolves.toMatchObject({
|
||||
packages: expect.arrayContaining([
|
||||
{
|
||||
expect.objectContaining({
|
||||
id: 'deai-writing',
|
||||
directory: join(builtinSkillsRoot, 'deai-writing')
|
||||
},
|
||||
{
|
||||
directory: join(builtinSkillsRoot, 'deai-writing'),
|
||||
digest: expect.stringMatching(/^[a-f0-9]{64}$/u)
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'longdoc-docx',
|
||||
directory: join(builtinSkillsRoot, 'longdoc-docx')
|
||||
}
|
||||
directory: join(builtinSkillsRoot, 'longdoc-docx'),
|
||||
digest: expect.stringMatching(/^[a-f0-9]{64}$/u)
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,6 +92,8 @@ describe('controlled DeepSeek Harness host', () => {
|
||||
})
|
||||
|
||||
expect(host.context.fs.sandboxMode).toBeUndefined()
|
||||
expect(host.context.get('sessionProjections')).toBeDefined()
|
||||
expect(host.context.get('agentLoop')).toBeDefined()
|
||||
expect(host.context.shell.sandboxMode).toBeUndefined()
|
||||
expect(host.context.get('attachments')).toBeInstanceOf(
|
||||
GoodBuddyHarnessAttachmentStore
|
||||
|
||||
@@ -10,6 +10,7 @@ import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import * as PiAiLlm from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SkillRegistry from '@deepseek-ai/dsh-skill'
|
||||
import LocalSubprocess from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -225,6 +226,7 @@ export async function startControlledDeepSeekHarnessHost(
|
||||
const specs: PluginSpec[] = [
|
||||
{ plugin: LlmRuntime },
|
||||
{ plugin: SessionStore },
|
||||
{ plugin: SessionProjectionRegistry },
|
||||
...(config.supportsImageInput
|
||||
? [{ plugin: GoodBuddyHarnessAttachmentStore }]
|
||||
: []),
|
||||
|
||||
@@ -60,6 +60,7 @@ const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024
|
||||
const MAXIMUM_EXPANDED_BYTES = 1024 * 1024 * 1024
|
||||
const MAXIMUM_FILE_BYTES = 384 * 1024 * 1024
|
||||
const MAXIMUM_METADATA_BYTES = 1024 * 1024
|
||||
const MAXIMUM_DESCRIPTOR_BYTES = 4 * 1024 * 1024
|
||||
const MAXIMUM_ENTRIES = 50_002
|
||||
const PRIVATE_DIRECTORY_MODE = 0o700
|
||||
const SIGNATURE_DOMAIN = Buffer.from(
|
||||
@@ -115,7 +116,7 @@ export async function verifyExtractedAgentPackage(options: {
|
||||
await Promise.all([
|
||||
readBoundedFile(
|
||||
join(rootDirectory, DESCRIPTOR_NAME),
|
||||
MAXIMUM_METADATA_BYTES
|
||||
MAXIMUM_DESCRIPTOR_BYTES
|
||||
),
|
||||
readBoundedFile(
|
||||
join(rootDirectory, SIGNATURE_NAME),
|
||||
@@ -321,9 +322,11 @@ async function extractArchive(
|
||||
}
|
||||
seen.add(name)
|
||||
const maximum =
|
||||
name === DESCRIPTOR_NAME || name === SIGNATURE_NAME
|
||||
? MAXIMUM_METADATA_BYTES
|
||||
: MAXIMUM_FILE_BYTES
|
||||
name === DESCRIPTOR_NAME
|
||||
? MAXIMUM_DESCRIPTOR_BYTES
|
||||
: name === SIGNATURE_NAME
|
||||
? MAXIMUM_METADATA_BYTES
|
||||
: MAXIMUM_FILE_BYTES
|
||||
if (
|
||||
file.originalSize !== undefined &&
|
||||
(
|
||||
|
||||
@@ -37,6 +37,7 @@ const MAXIMUM_ARCHIVE_BYTES = 512 * 1024 * 1024
|
||||
const MAXIMUM_EXPANDED_BYTES = 1024 * 1024 * 1024
|
||||
const MAXIMUM_ENTRY_BYTES = 384 * 1024 * 1024
|
||||
const MAXIMUM_METADATA_BYTES = 1024 * 1024
|
||||
const MAXIMUM_DESCRIPTOR_BYTES = 4 * 1024 * 1024
|
||||
const MAXIMUM_ENTRIES = 50_002
|
||||
const MAXIMUM_CENTRAL_BYTES = 32 * 1024 * 1024
|
||||
const ZIP_LOCAL = 0x04034b50
|
||||
@@ -480,7 +481,9 @@ function streamEntry(
|
||||
destination?: string
|
||||
): { sha256: string; crc32: number; bytes?: Buffer } {
|
||||
const wantedBytes =
|
||||
destination === undefined && entry.size <= MAXIMUM_METADATA_BYTES
|
||||
destination === undefined && entry.size <= (
|
||||
entry.name === PACKAGE_DESCRIPTOR ? MAXIMUM_DESCRIPTOR_BYTES : MAXIMUM_METADATA_BYTES
|
||||
)
|
||||
? Buffer.allocUnsafe(entry.size)
|
||||
: undefined
|
||||
let output: number | undefined
|
||||
@@ -549,7 +552,10 @@ function writeFileChunk(handle: number, bytes: Buffer, offset: number): number {
|
||||
|
||||
function readEntry(handle: number, entries: Map<string, ZipEntry>, name: string): Buffer {
|
||||
const entry = entries.get(name)
|
||||
if (entry === undefined || entry.size > MAXIMUM_METADATA_BYTES) {
|
||||
const maximumBytes = name === PACKAGE_DESCRIPTOR
|
||||
? MAXIMUM_DESCRIPTOR_BYTES
|
||||
: MAXIMUM_METADATA_BYTES
|
||||
if (entry === undefined || entry.size > maximumBytes) {
|
||||
throw new Error(`Agent package metadata is missing or too large: ${name}`)
|
||||
}
|
||||
const actual = streamEntry(handle, entry)
|
||||
@@ -1656,7 +1662,9 @@ function writePreparedFile(
|
||||
): void {
|
||||
if (
|
||||
bytes.byteLength <= 0 ||
|
||||
bytes.byteLength > MAXIMUM_METADATA_BYTES
|
||||
bytes.byteLength > (
|
||||
name === PACKAGE_DESCRIPTOR ? MAXIMUM_DESCRIPTOR_BYTES : MAXIMUM_METADATA_BYTES
|
||||
)
|
||||
) {
|
||||
throw new Error(`Prepared package metadata is invalid: ${name}`)
|
||||
}
|
||||
@@ -2392,7 +2400,7 @@ export function commitPackage(options: InstallerOptions): PackageInstallerResult
|
||||
)
|
||||
const descriptorBytes = readBoundedRegularFile(
|
||||
join(preparedRoot, PACKAGE_DESCRIPTOR),
|
||||
MAXIMUM_METADATA_BYTES,
|
||||
MAXIMUM_DESCRIPTOR_BYTES,
|
||||
'Prepared Agent package descriptor',
|
||||
'0600'
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'
|
||||
import { c as createTar } from 'tar'
|
||||
import { unzipSync } from 'fflate'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createOpenCodeConfigFixture } from './support/opencode-config-fixture'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const ci = require('../build/agent-ci-bundle.cjs') as {
|
||||
@@ -83,6 +84,7 @@ describe('Darwin arm64 Agent packages', () => {
|
||||
|
||||
it('assembles deterministic Darwin packages and rejects cross-platform bundle selection', () => {
|
||||
const root = scratch()
|
||||
createOpenCodeConfigFixture(root)
|
||||
const bundle = join(root, 'agent')
|
||||
const { lock, runtimeLock } = ci.readCiLocks('darwin', 'arm64')
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
@@ -128,6 +130,7 @@ describe('Darwin arm64 Agent packages', () => {
|
||||
runtimeLock.runtimes.opencode.targets['darwin-arm64']!.integrity =
|
||||
`sha512-${createHash('sha512').update(readFileSync(archive)).digest('base64')}`
|
||||
const built = runtime.buildRuntimeBundle({
|
||||
projectRoot: root,
|
||||
platform: 'darwin', architecture: 'arm64', runtimeArchive: archive,
|
||||
outputRoot: join(root, 'runtime'), lock: runtimeLock, registry,
|
||||
testSigningIdentity: identity
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
AgentPackageManager,
|
||||
selectLatestCompatibleEntry
|
||||
} from '../src/main/remote-agent/agent-package-manager'
|
||||
import { createOpenCodeConfigFixture } from './support/opencode-config-fixture'
|
||||
|
||||
type AgentBundleModule = {
|
||||
canonicalManifestBytes(manifest: unknown): Buffer
|
||||
@@ -305,7 +306,7 @@ beforeAll(() => {
|
||||
architecture: 'x64' | 'arm64',
|
||||
archive: string
|
||||
) => runtimeBundle.buildRuntimeBundle({
|
||||
projectRoot: process.cwd(),
|
||||
projectRoot: temporaryRoot,
|
||||
architecture,
|
||||
runtimeArchive: archive,
|
||||
outputRoot: join(temporaryRoot, 'runtime'),
|
||||
@@ -317,6 +318,7 @@ beforeAll(() => {
|
||||
},
|
||||
enforceFilesystemMode: false
|
||||
})
|
||||
createOpenCodeConfigFixture(temporaryRoot)
|
||||
const builtRuntime = buildRuntime('x64', runtimeInput)
|
||||
const builtArm64Runtime = buildRuntime(
|
||||
'arm64',
|
||||
@@ -367,6 +369,58 @@ afterAll(() => {
|
||||
})
|
||||
|
||||
describe('compound Agent packages', () => {
|
||||
it('verifies and installs offline dependency inventories larger than one MiB', async () => {
|
||||
const projectRoot = join(temporaryRoot, 'large-offline-config')
|
||||
const config = createOpenCodeConfigFixture(projectRoot)
|
||||
const inventory = join(config, 'inventory')
|
||||
mkdirSync(inventory)
|
||||
for (let index = 0; index < 3000; index++) {
|
||||
const path = join(inventory, `${String(index).padStart(4, '0')}-${'x'.repeat(100)}.js`)
|
||||
writeFileSync(path, '// fixture\n')
|
||||
}
|
||||
const runtime = runtimeBundle.buildRuntimeBundle({
|
||||
projectRoot, architecture: 'x64',
|
||||
runtimeArchive: createRuntimeInput('x64'),
|
||||
outputRoot: join(projectRoot, 'runtime'),
|
||||
lock: remoteRuntimeLock, registry,
|
||||
testSigningIdentity: { keyId: 'agent-package-fixture', privateKey },
|
||||
enforceFilesystemMode: false
|
||||
})
|
||||
const archive = join(projectRoot, agentPackageArchiveName(agentLock.agentVersion, 'x64'))
|
||||
const result = agentPackage.assembleAgentPackage({
|
||||
projectRoot, architecture: 'x64', minimumDesktopVersion: '0.11.0',
|
||||
output: archive, agentBundle: join(temporaryRoot, 'agent'),
|
||||
runtimeBundle: runtime.bundleDirectory, agentLock, runtimeLock: remoteRuntimeLock,
|
||||
registry, testSigningIdentity: { keyId: 'agent-package-fixture', privateKey }
|
||||
})
|
||||
const descriptorBytes = agentPackage.descriptorBytes(result.descriptor)
|
||||
expect(descriptorBytes.length).toBeGreaterThan(1024 * 1024)
|
||||
expect(descriptorBytes.length).toBeLessThan(4 * 1024 * 1024)
|
||||
expect(agentCatalog.readPackageMetadata(archive, registry).sha256).toBe(result.sha256)
|
||||
await expect(extractAndVerifyAgentPackage({
|
||||
archivePath: archive, destinationDirectory: join(projectRoot, 'verified'),
|
||||
architecture: 'x64', desktopVersion: '0.11.0', trustedRegistry: registry
|
||||
})).resolves.toMatchObject({ descriptor: { contentDigest: result.descriptor.contentDigest } })
|
||||
const options = {
|
||||
operationRoot: join(projectRoot, 'operation'), archive,
|
||||
expectedSha256: result.sha256, homeDirectory: join(projectRoot, 'home')
|
||||
}
|
||||
mkdirSync(options.homeDirectory, { mode: 0o700 })
|
||||
expect(packageInstaller.preparePackage(options).status).toBe('prepared')
|
||||
expect(packageInstaller.commitPackage(options).status).toBe('committed')
|
||||
}, 60_000)
|
||||
|
||||
it('still rejects descriptors larger than four MiB before parsing', async () => {
|
||||
const archive = join(temporaryRoot, 'oversized-descriptor.zip')
|
||||
writeFileSync(archive, zipSync({
|
||||
'agent-package.json': new Uint8Array(4 * 1024 * 1024 + 1)
|
||||
}, { level: 0 }))
|
||||
await expect(extractAndVerifyAgentPackage({
|
||||
archivePath: archive, destinationDirectory: join(temporaryRoot, 'oversized-descriptor'),
|
||||
desktopVersion: '0.11.0', trustedRegistry: registry
|
||||
})).rejects.toThrow(/limit|large/iu)
|
||||
})
|
||||
|
||||
it('assembles deterministic signed archives with private extraction', async () => {
|
||||
const secondRoot = join(temporaryRoot, 'second')
|
||||
mkdirSync(secondRoot)
|
||||
@@ -456,9 +510,9 @@ describe('compound Agent packages', () => {
|
||||
expect(second.archive).toBe(
|
||||
agentPackageArchiveName(agentLock.agentVersion, 'x64')
|
||||
)
|
||||
expect(readFileSync(secondPath)).toEqual(
|
||||
expect(readFileSync(secondPath).equals(
|
||||
readFileSync(packagePath)
|
||||
)
|
||||
)).toBe(true)
|
||||
})
|
||||
|
||||
it('prepares and commits a format-v1 package without an embedded installer', () => {
|
||||
|
||||
@@ -888,6 +888,12 @@ describe('release build arguments', () => {
|
||||
const integrity = 'sha512-test-integrity'
|
||||
const executable = Buffer.from('desktop runtime')
|
||||
try {
|
||||
const configPlugin = join(
|
||||
projectRoot, '.runtime-resources', 'opencode-config',
|
||||
'node_modules', '@opencode-ai', 'plugin'
|
||||
)
|
||||
mkdirSync(configPlugin, { recursive: true })
|
||||
writeFileSync(join(configPlugin, 'package.json'), JSON.stringify({ version: '1.18.29' }))
|
||||
mkdirSync(join(projectRoot, 'out', 'main'), {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const modulePath = require.resolve('../build/opencode-config.cjs')
|
||||
const spawnSync = vi.fn()
|
||||
let directory: string
|
||||
let target: string
|
||||
let config: {
|
||||
opencodeVersion: string
|
||||
prepareBundledOpenCodeConfig(projectDir: string): string
|
||||
}
|
||||
|
||||
function installPlugin(root: string, version: string) {
|
||||
const path = join(root, 'node_modules', '@opencode-ai', 'plugin', 'package.json')
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, JSON.stringify({ name: '@opencode-ai/plugin', version }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
directory = mkdtempSync(join(tmpdir(), 'goodbuddy-opencode-config-'))
|
||||
target = join(directory, '.runtime-resources', 'opencode-config')
|
||||
spawnSync.mockReset()
|
||||
vi.spyOn(require('node:child_process'), 'spawnSync').mockImplementation(spawnSync)
|
||||
vi.stubEnv('npm_execpath', 'npm-cli.js')
|
||||
delete require.cache[modulePath]
|
||||
config = require(modulePath)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
delete require.cache[modulePath]
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('bundled OpenCode config', () => {
|
||||
it('reuses the installed version without a root lock or ready marker', () => {
|
||||
installPlugin(target, config.opencodeVersion)
|
||||
expect(config.prepareBundledOpenCodeConfig(directory)).toBe(target)
|
||||
expect(spawnSync).not.toHaveBeenCalled()
|
||||
expect(existsSync(join(directory, 'package-lock.json'))).toBe(false)
|
||||
expect(existsSync(join(target, '.goodbuddy-ready.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['missing', 'old', 'invalid'])('installs a %s cache through staging', (state) => {
|
||||
if (state !== 'missing') {
|
||||
installPlugin(target, '0.0.1')
|
||||
if (state === 'invalid') {
|
||||
writeFileSync(join(target, 'node_modules', '@opencode-ai', 'plugin', 'package.json'), '{')
|
||||
}
|
||||
}
|
||||
spawnSync.mockImplementation((_command, args, options) => {
|
||||
expect(args).toContain('--ignore-scripts')
|
||||
expect(args).toContain('--bin-links=false')
|
||||
expect(options.cwd).toBe(`${target}.staging-${process.pid}`)
|
||||
expect(JSON.parse(readFileSync(join(options.cwd, 'package.json'), 'utf8')).dependencies)
|
||||
.toEqual({ '@opencode-ai/plugin': config.opencodeVersion })
|
||||
installPlugin(options.cwd, config.opencodeVersion)
|
||||
return { status: 0 }
|
||||
})
|
||||
expect(config.prepareBundledOpenCodeConfig(directory)).toBe(target)
|
||||
expect(readFileSync(join(target, 'node_modules', '@opencode-ai', 'plugin', 'package.json'), 'utf8'))
|
||||
.toContain(config.opencodeVersion)
|
||||
expect(existsSync(`${target}.staging-${process.pid}`)).toBe(false)
|
||||
expect(existsSync(join(target, '.goodbuddy-ready.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the old cache and removes staging after a failed install, then retries', () => {
|
||||
installPlugin(target, '0.0.1')
|
||||
spawnSync.mockImplementation((_command, _args, options) => {
|
||||
installPlugin(options.cwd, config.opencodeVersion)
|
||||
return { status: 1, stderr: 'install failed' }
|
||||
})
|
||||
expect(() => config.prepareBundledOpenCodeConfig(directory)).toThrow('install failed')
|
||||
expect(readFileSync(join(target, 'node_modules', '@opencode-ai', 'plugin', 'package.json'), 'utf8'))
|
||||
.toContain('0.0.1')
|
||||
expect(existsSync(`${target}.staging-${process.pid}`)).toBe(false)
|
||||
spawnSync.mockImplementation((_command, _args, options) => {
|
||||
installPlugin(options.cwd, config.opencodeVersion)
|
||||
return { status: 0 }
|
||||
})
|
||||
expect(config.prepareBundledOpenCodeConfig(directory)).toBe(target)
|
||||
expect(spawnSync).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
type RemoteRuntimeBundleManifest,
|
||||
type RemoteRuntimeLock
|
||||
} from '../src/shared/remote-runtime-launch-contracts'
|
||||
import { createOpenCodeConfigFixture } from './support/opencode-config-fixture'
|
||||
|
||||
type VerifiedBundle = {
|
||||
bundleDirectory: string
|
||||
@@ -110,6 +112,7 @@ beforeEach(() => {
|
||||
temporaryRoot = resolve(
|
||||
mkdtempSync(join(tmpdir(), 'goodbuddy-runtime-build-'))
|
||||
)
|
||||
createOpenCodeConfigFixture(temporaryRoot)
|
||||
privateKey = createPrivateKey({
|
||||
key: Buffer.from(
|
||||
'302e020100300506032b657004220420202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f',
|
||||
@@ -243,6 +246,14 @@ describe('Remote Runtime bundle tooling', () => {
|
||||
}
|
||||
})
|
||||
expect(first.manifest).not.toHaveProperty('quotas')
|
||||
expect(first.manifest.files.some((file) => file.path.includes('/.bin/'))).toBe(false)
|
||||
const pluginPath = 'config/opencode/node_modules/@opencode-ai/plugin/index.js'
|
||||
expect(first.manifest.files).toContainEqual(expect.objectContaining({
|
||||
path: pluginPath, mode: '0644'
|
||||
}))
|
||||
if (process.platform !== 'win32') {
|
||||
expect(lstatSync(join(first.bundleDirectory, pluginPath)).mode & 0o777).toBe(0o644)
|
||||
}
|
||||
})
|
||||
|
||||
it('creates deterministic archives and imports only verified content', () => {
|
||||
@@ -257,9 +268,9 @@ describe('Remote Runtime bundle tooling', () => {
|
||||
built.bundleDirectory,
|
||||
secondArchive
|
||||
)
|
||||
expect(readFileSync(secondArchive)).toEqual(
|
||||
expect(readFileSync(secondArchive).equals(
|
||||
readFileSync(firstArchive)
|
||||
)
|
||||
)).toBe(true)
|
||||
|
||||
const imported = runtimeBundle.importRuntimeArchive(
|
||||
firstArchive,
|
||||
@@ -335,7 +346,7 @@ describe('Remote Runtime bundle tooling', () => {
|
||||
}))
|
||||
}
|
||||
const built = runtimeBundle.buildRuntimeBundle({
|
||||
projectRoot: process.cwd(),
|
||||
projectRoot: temporaryRoot,
|
||||
architecture: 'x64',
|
||||
runtimeArchive,
|
||||
outputRoot: join(temporaryRoot, 'production'),
|
||||
@@ -354,7 +365,7 @@ describe('Remote Runtime bundle tooling', () => {
|
||||
|
||||
function build(outputRoot: string): VerifiedBundle {
|
||||
return runtimeBundle.buildRuntimeBundle({
|
||||
projectRoot: process.cwd(),
|
||||
projectRoot: temporaryRoot,
|
||||
architecture: 'x64',
|
||||
runtimeArchive,
|
||||
outputRoot,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
export function createOpenCodeConfigFixture(projectRoot: string): string {
|
||||
const config = join(projectRoot, '.runtime-resources', 'opencode-config')
|
||||
const files = {
|
||||
'node_modules/@opencode-ai/plugin/package.json': JSON.stringify({
|
||||
name: '@opencode-ai/plugin', version: '1.18.29'
|
||||
}),
|
||||
'node_modules/@opencode-ai/plugin/index.js': 'export default {}\n',
|
||||
'node_modules/.bin/unused': 'unused CLI shim\n'
|
||||
}
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const destination = join(config, name)
|
||||
mkdirSync(dirname(destination), { recursive: true })
|
||||
writeFileSync(destination, content)
|
||||
chmodSync(destination, 0o755)
|
||||
}
|
||||
const license = join(projectRoot, 'node_modules', 'opencode-ai', 'LICENSE')
|
||||
mkdirSync(dirname(license), { recursive: true })
|
||||
writeFileSync(license, 'fixture license\n')
|
||||
return config
|
||||
}
|
||||
Reference in New Issue
Block a user