diff --git a/BUILD.md b/BUILD.md index 60b8291b..26a6454a 100644 --- a/BUILD.md +++ b/BUILD.md @@ -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/`。 +- 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 许可证。 diff --git a/FEATURES.md b/FEATURES.md index 6bc1b2a3..5d9b021f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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, diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index 0a54cd85..5af4a6bb 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -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 事件连接回收**:请求结束或取消后关闭自身订阅,不取消其他并行会话。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 561b4c9c..5fe55d22 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -323,8 +323,9 @@ 能力自动增删稳定应用目录入口。 - Tab 行只显示已打开实例,行末始终提供“+”普通按钮;点击后在内容区打开应用选择页。 “+”不使用 `tab` 语义,也不能因为终端或其他应用已经打开而消失。 -- 应用注册表声明单实例或多实例。单实例重复选择时聚焦已有 Tab;终端从首期开始支持 - 多实例,每次选择创建独立 Tab 和 Shell Session。 +- 应用注册表声明单实例或多实例。在工作栏“+”目录中,单实例重复选择时聚焦已有 Tab;终端 + 从首期开始支持多实例,每次选择创建独立 Tab 和 Shell Session。主侧栏系统工具快捷入口 + 可以聚焦当前执行空间中最近使用的终端,额外终端仍从“+”目录创建。 - Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task 只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容, 也不得把 Job/Run 提升为可导航 UI 对象。 diff --git a/build/agent-catalog.cjs b/build/agent-catalog.cjs index ca0399c3..1ef4f6b2 100644 --- a/build/agent-catalog.cjs +++ b/build/agent-catalog.cjs @@ -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 { diff --git a/build/opencode-config.cjs b/build/opencode-config.cjs index 60445c91..9fb07614 100644 --- a/build/opencode-config.cjs +++ b/build/opencode-config.cjs @@ -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); diff --git a/build/remote-runtime-bundle.cjs b/build/remote-runtime-bundle.cjs index dc8fb5a1..4da7199c 100644 --- a/build/remote-runtime-bundle.cjs +++ b/build/remote-runtime-bundle.cjs @@ -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"), diff --git a/docs/features/application-tool-navigation/README.md b/docs/features/application-tool-navigation/README.md new file mode 100644 index 00000000..db959732 --- /dev/null +++ b/docs/features/application-tool-navigation/README.md @@ -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) diff --git a/docs/features/application-tool-navigation/logic-design.md b/docs/features/application-tool-navigation/logic-design.md new file mode 100644 index 00000000..c16a596c --- /dev/null +++ b/docs/features/application-tool-navigation/logic-design.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. 后续产品决策 + +- 插件工具上线后,快捷栏允许固定的最大数量及溢出规则。 +- 插件应用是否允许用户排序,以及固定系统入口之间允许的排序范围。 + +这些决策不影响首期三个系统工具的固定入口和内置应用显隐实现。 diff --git a/docs/features/application-tool-navigation/prd.md b/docs/features/application-tool-navigation/prd.md new file mode 100644 index 00000000..e63efb39 --- /dev/null +++ b/docs/features/application-tool-navigation/prd.md @@ -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 到助手工作栏的“打开或聚焦工具”意图接口。 +- 增加内置应用的主导航显示设置。 +- 保持运行记录为固定系统入口。 + +### 后续 + +- 插件应用和插件工具注册。 +- 插件工具固定、取消固定和排序。 +- 插件市场、权限详情、安装和卸载。 +- 用户级快捷键与命令面板接入同一贡献命令。 diff --git a/docs/features/application-tool-navigation/technical-design.md b/docs/features/application-tool-navigation/technical-design.md new file mode 100644 index 00000000..6263683e --- /dev/null +++ b/docs/features/application-tool-navigation/technical-design.md @@ -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 +} +``` + +若当前 `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 +
+ + +
+``` + +不能在设置按钮内部嵌套工具按钮。设置按钮继续调用 `navigateFromSidebar('settings', trigger)`, +保留设置预加载、焦点恢复和未保存离开确认。 + +系统工具按钮调用统一 `openSystemTool`: + +1. 设置工作栏打开意图。 +2. 展开工作栏。 +3. 由工作栏创建或激活实例。 +4. 按现有策略把焦点移入目标面板。 + +工作栏仍由窗口右上方的固定开关关闭,关闭后的焦点返回该开关。 + +## 6. 工作栏状态与快捷按钮 + +`RightAssistantSidebar` 应向 `App` 暴露当前活动实例的 `appId` 和必要的有界状态摘要,或由 +现有 `onTabChange` 扩展得到。主侧栏不读取终端输出、文件内容、URL 或活动详情。 + +```ts +type SidebarSystemToolState = { + activeToolId?: SidebarSystemToolId + statusByToolId?: Partial> +} +``` + +首期可以只实现 `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、契约、迁移和回归测试。 + +插件注册与插件市场不与首期改动一并实施。 diff --git a/docs/features/application-tool-navigation/ui-design.md b/docs/features/application-tool-navigation/ui-design.md new file mode 100644 index 00000000..016864bc --- /dev/null +++ b/docs/features/application-tool-navigation/ui-design.md @@ -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. 未来插件入口 + +插件市场交付后另行定义插件区域、固定数量、溢出和权限界面。系统入口与三个系统工具的位置 +保持保留,不向插件开放。 diff --git a/docs/features/application-tool-navigation/user-stories.md b/docs/features/application-tool-navigation/user-stories.md new file mode 100644 index 00000000..0e354c1f --- /dev/null +++ b/docs/features/application-tool-navigation/user-stories.md @@ -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 在窄窗口使用快捷栏 + +作为小屏用户,我希望侧栏抽屉中的工具按钮保持可操作。 + +验收: + +- 工具按钮保持单行且不重叠 +- 不因宽度不足隐藏文件、终端、浏览器或设置 +- 触控点击区域符合统一界面规范 +- 关闭侧栏抽屉后焦点返回原触发按钮 diff --git a/docs/features/assistant-workbar/README.md b/docs/features/assistant-workbar/README.md index 3f253f44..63434cac 100644 --- a/docs/features/assistant-workbar/README.md +++ b/docs/features/assistant-workbar/README.md @@ -9,6 +9,7 @@ | [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 | | [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 | | [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 | +| [Runtime 交互边界](./runtime-interactions.md) | 现有原生权限、结构化问答、后台拒绝和非结构化终端交互的适配范围 | 当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在 两份现有 PRD 中,后续实际维护该功能时按职责拆分。 diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index 10b4ae9a..da3994ca 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -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 保存与环境 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 1eb13e29..4293d693 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -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 项目工作区 diff --git a/docs/features/assistant-workbar/runtime-interactions.md b/docs/features/assistant-workbar/runtime-interactions.md new file mode 100644 index 00000000..1f486c9e --- /dev/null +++ b/docs/features/assistant-workbar/runtime-interactions.md @@ -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)。 diff --git a/docs/features/deepseek-harness/technical-design.md b/docs/features/deepseek-harness/technical-design.md index 775bb9ae..deb7a7da 100644 --- a/docs/features/deepseek-harness/technical-design.md +++ b/docs/features/deepseek-harness/technical-design.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 能稳定解释的字段: diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 1320cbdb..fb184167 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -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 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。 diff --git a/package-lock.json b/package-lock.json index 55966b14..7e948ce7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index 55e02080..ac0d4d60 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/agent-daemon/model-bridge-helper.ts b/src/agent-daemon/model-bridge-helper.ts index bde1ee5e..beffca79 100644 --- a/src/agent-daemon/model-bridge-helper.ts +++ b/src/agent-daemon/model-bridge-helper.ts @@ -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]: { diff --git a/src/agent-daemon/model-bridge.test.ts b/src/agent-daemon/model-bridge.test.ts index 0f380e8a..c090c0ef 100644 --- a/src/agent-daemon/model-bridge.test.ts +++ b/src/agent-daemon/model-bridge.test.ts @@ -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') }) }) diff --git a/src/agent-daemon/opencode-runtime-profile.test.ts b/src/agent-daemon/opencode-runtime-profile.test.ts index faae95d1..789e4ccb 100644 --- a/src/agent-daemon/opencode-runtime-profile.test.ts +++ b/src/agent-daemon/opencode-runtime-profile.test.ts @@ -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', () => { diff --git a/src/agent-daemon/opencode-runtime-profile.ts b/src/agent-daemon/opencode-runtime-profile.ts index 66f85c8a..2e298a6d 100644 --- a/src/agent-daemon/opencode-runtime-profile.ts +++ b/src/agent-daemon/opencode-runtime-profile.ts @@ -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, diff --git a/src/agent-daemon/private-endpoint.test.ts b/src/agent-daemon/private-endpoint.test.ts index 3773a469..1b66473d 100644 --- a/src/agent-daemon/private-endpoint.test.ts +++ b/src/agent-daemon/private-endpoint.test.ts @@ -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() diff --git a/src/agent-daemon/private-endpoint.ts b/src/agent-daemon/private-endpoint.ts index f5734fb7..aa3c5509 100644 --- a/src/agent-daemon/private-endpoint.ts +++ b/src/agent-daemon/private-endpoint.ts @@ -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 remaining -= chunk.byteLength continue } + if (socket.destroyed || socket.readableEnded) { + throw new Error('Attach socket closed during handshake') + } await new Promise((resolve, reject) => { const onReadable = (): void => { cleanup() @@ -575,10 +579,12 @@ async function readExactly(socket: Socket, byteLength: number): Promise 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 async function readExactlyFromStream( stream: NodeJS.ReadableStream & { read(size?: number): Buffer | null + destroyed?: boolean + readableEnded?: boolean }, byteLength: number ): Promise { @@ -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((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) }) } diff --git a/src/main/agent/bundled-runtimes.test.ts b/src/main/agent/bundled-runtimes.test.ts index 9659b1a8..f4b41a64 100644 --- a/src/main/agent/bundled-runtimes.test.ts +++ b/src/main/agent/bundled-runtimes.test.ts @@ -55,6 +55,12 @@ describe('bundled runtime paths', () => { 'opencode', 'opencode.exe' ), + opencodeConfig: join( + 'installed', + 'resources', + 'runtimes', + 'opencode-config' + ), continue: join( 'installed', 'resources', diff --git a/src/main/agent/continue-host-adapter.test.ts b/src/main/agent/continue-host-adapter.test.ts index 4c63d7b0..26c15884 100644 --- a/src/main/agent/continue-host-adapter.test.ts +++ b/src/main/agent/continue-host-adapter.test.ts @@ -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( diff --git a/src/main/agent/continue-host-adapter.ts b/src/main/agent/continue-host-adapter.ts index f32b5f22..8e7b61c5 100644 --- a/src/main/agent/continue-host-adapter.ts +++ b/src/main/agent/continue-host-adapter.ts @@ -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 @@ -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: '' diff --git a/src/main/agent/continue-runtime-permissions.test.ts b/src/main/agent/continue-runtime-permissions.test.ts new file mode 100644 index 00000000..77d97c5b --- /dev/null +++ b/src/main/agent/continue-runtime-permissions.test.ts @@ -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(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(resolve => server.close(() => resolve())) + await rm(root, { recursive: true, force: true }) + } +}, 60_000) diff --git a/src/main/agent/deepseek-harness-acp-e2e.test.ts b/src/main/agent/deepseek-harness-acp-e2e.test.ts index 7437b70c..d3f6210e 100644 --- a/src/main/agent/deepseek-harness-acp-e2e.test.ts +++ b/src/main/agent/deepseek-harness-acp-e2e.test.ts @@ -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-')) diff --git a/src/main/agent/deepseek-harness-runtime.test.ts b/src/main/agent/deepseek-harness-runtime.test.ts index 802990df..6343e49f 100644 --- a/src/main/agent/deepseek-harness-runtime.test.ts +++ b/src/main/agent/deepseek-harness-runtime.test.ts @@ -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(() => {})) 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') ] diff --git a/src/main/agent/deepseek-harness-runtime.ts b/src/main/agent/deepseek-harness-runtime.ts index baaa8ee2..53b060d6 100644 --- a/src/main/agent/deepseek-harness-runtime.ts +++ b/src/main/agent/deepseek-harness-runtime.ts @@ -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> = - 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', diff --git a/src/main/agent/opencode-runtime-permissions.test.ts b/src/main/agent/opencode-runtime-permissions.test.ts new file mode 100644 index 00000000..6027ef50 --- /dev/null +++ b/src/main/agent/opencode-runtime-permissions.test.ts @@ -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(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(resolve => model.close(() => resolve())) + await rm(root, { recursive: true, force: true }) + } + }, + 90_000 +) diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 6c98d887..aed8b011 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -185,6 +185,7 @@ function runClient(events: Record[]) { }); 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([ { diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 554d037a..3b9c3a4d 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -625,6 +625,7 @@ function createOpenCodeSkillConfig( ): { skills: { paths: string[]; urls: never[] }; permission: { + "*": "allow"; skill: Record; }; } { @@ -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(); + const ownedSessions = new Set([sessionId]); + const ownsSession = async (candidate: string): Promise => { + const visited = new Set(); + 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(); 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); diff --git a/src/main/capabilities/builtin-skills.test.ts b/src/main/capabilities/builtin-skills.test.ts index b750b3dc..5dac3d1c 100644 --- a/src/main/capabilities/builtin-skills.test.ts +++ b/src/main/capabilities/builtin-skills.test.ts @@ -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) + }) ]) }) }) diff --git a/src/main/deepseek-harness-host.test.ts b/src/main/deepseek-harness-host.test.ts index 8c8aacb3..b062434b 100644 --- a/src/main/deepseek-harness-host.test.ts +++ b/src/main/deepseek-harness-host.test.ts @@ -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 diff --git a/src/main/deepseek-harness-host.ts b/src/main/deepseek-harness-host.ts index 1ea70e5d..83eea763 100644 --- a/src/main/deepseek-harness-host.ts +++ b/src/main/deepseek-harness-host.ts @@ -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 }] : []), diff --git a/src/main/remote-agent/agent-package-verifier.ts b/src/main/remote-agent/agent-package-verifier.ts index d848ad52..64e6adab 100644 --- a/src/main/remote-agent/agent-package-verifier.ts +++ b/src/main/remote-agent/agent-package-verifier.ts @@ -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 && ( diff --git a/src/main/remote-agent/control-plane-package-installer.ts b/src/main/remote-agent/control-plane-package-installer.ts index 4cea5918..604d9eac 100644 --- a/src/main/remote-agent/control-plane-package-installer.ts +++ b/src/main/remote-agent/control-plane-package-installer.ts @@ -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, 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' ) diff --git a/tests/agent-darwin-build.test.ts b/tests/agent-darwin-build.test.ts index e0060f08..ef3e7ff6 100644 --- a/tests/agent-darwin-build.test.ts +++ b/tests/agent-darwin-build.test.ts @@ -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 diff --git a/tests/agent-package.test.ts b/tests/agent-package.test.ts index 6804cf3d..36f31b11 100644 --- a/tests/agent-package.test.ts +++ b/tests/agent-package.test.ts @@ -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', () => { diff --git a/tests/build-release.test.ts b/tests/build-release.test.ts index 96fa1598..ca7e1967 100644 --- a/tests/build-release.test.ts +++ b/tests/build-release.test.ts @@ -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 }) diff --git a/tests/opencode-config.test.ts b/tests/opencode-config.test.ts new file mode 100644 index 00000000..61199904 --- /dev/null +++ b/tests/opencode-config.test.ts @@ -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) + }) +}) diff --git a/tests/remote-runtime-bundle.test.ts b/tests/remote-runtime-bundle.test.ts index 362732a0..e05a8d39 100644 --- a/tests/remote-runtime-bundle.test.ts +++ b/tests/remote-runtime-bundle.test.ts @@ -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, diff --git a/tests/support/opencode-config-fixture.ts b/tests/support/opencode-config-fixture.ts new file mode 100644 index 00000000..ce1d795b --- /dev/null +++ b/tests/support/opencode-config-fixture.ts @@ -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 +}