diff --git a/BUILD.md b/BUILD.md index 4f8edda8..e308183d 100644 --- a/BUILD.md +++ b/BUILD.md @@ -139,13 +139,20 @@ npm run dist:linux:arm64 组件,缺少远端包也不能阻塞普通桌面打包或发布。 “设置 > 平台功能 > 远程项目(技术预览)”列出用户数据目录中已经下载或导入的 -Linux x64/arm64 复合 Agent 包,并分别显示 `已下载并验证 / 未下载 / 校验失败`。 -在线“下载/检查并更新”必须由用户显式触发,来源跟随“关于与更新”的 `github` 或 -`mirror` 选择;打开远程项目不会自动下载。用户也可导入或导出 `.gbagent` 离线包。 +Linux x64/arm64 复合 Agent 包。首次打开和手动刷新会从“关于与更新”所选来源读取 +小型签名目录,显示本地版本、在线最新兼容版本以及 `有更新 / 已是最新 / 未下载 / +校验失败`;此检查不会下载 `.gbagent`。只有用户点击“下载”或“更新到”后才传输包, +用户也可导入或导出 `.gbagent` 离线包。 Main 在写入缓存前校验签名目录、目录签名、大小、SHA-256、桌面最低版本、Agent 协议、 内部 Agent/Runtime 签名和完整 payload。缓存位于 Electron `userData` 下的 `remote-components/agent-packages`,不暴露给 Renderer。 +公钥 registry 的信任来自严格 schema、唯一 key ID、规范 Base64、有效 Ed25519 key、 +production/test 环境和撤销列表,不来自 JSON 空白或换行。读取桌面资源、复合包和 Host +已有 registry 时接受等价 UTF-8 JSON 格式;向 Host 写入前统一重新序列化为确定性 LF +JSON。签名目录、包描述符、manifest、签名和 payload 的原始字节校验保持不变,包下载 +继续流式计算 SHA-256,不使用 MD5。 + “SSH 主机 > 远程运行环境”的“更新版本”只使用已验证的本地复合包。对应 Host 架构 尚未下载时会提示先到设置下载或导入,不会联网,也不会影响本地项目、普通桌面功能或 其他架构的 Agent 包。 diff --git a/FEATURES.md b/FEATURES.md index 0089dc15..e1d93431 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -23,14 +23,14 @@ - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 - [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。 -- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Main 通过签名 Agent、私有 Unix socket、ACP v3、持久恢复 cursor 和 Main-only 模型桥维持长任务、重连与会话恢复,模型凭据不进入远端。远端组件不嵌入桌面包,也不会在项目激活时自动下载;用户必须先在该设置页按 Linux x64/arm64 手动下载最新兼容的签名 `.gbagent`,或导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。缺少某架构包只使该架构托管 SSH 不可用。持久化验证继续精确绑定 Agent installation 与签名 Runtime digest;托管 SSH 会话只显示支持的 OpenCode 选择。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 已转为正式 Agent 0.11.4。 +- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Main 通过签名 Agent、私有 Unix socket、ACP v3、持久恢复 cursor 和 Main-only 模型桥维持长任务、重连与会话恢复,模型凭据不进入远端。远端组件不嵌入桌面包,也不会在项目激活时自动下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。持久化验证继续精确绑定 Agent installation 与签名 Runtime digest;托管 SSH 会话只显示支持的 OpenCode 选择。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 已转为正式 Agent 0.11.5。 - [x] **SSH Host 运行环境手动更新**:远程运行环境卡片只读取 Host 已安装 Agent/Runtime 与当前本地复合包所需版本,不会联网。用户显式触发后,Main 按 Agent、Runtime 顺序使用对应 Host 架构的已验证本地 `.gbagent` 强制重新校验并安装,显示阶段进度并允许在最终收尾前取消;尚未下载时提示先到平台功能设置下载或导入。更新期间保留旧版本卡片,失败或取消后刷新实际状态并允许重试;成功后定向清理该 Host 缓存并使引用项目重新验证,不删除 Host 配置、凭据、项目设置或 Workspace 文件。 - [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。 - [x] **DSH npm 插件市场**:市场默认关闭,由用户显式开启后搜索公共 npm 的 `dsh-plugin` 包,使用捆绑 npm 执行精确版本安装和普通 lifecycle scripts,并支持启停、JSON 配置、移除、失败启动自动停用和离线管理已安装插件;关闭市场只隐藏目录与管理界面,不改变已有插件的启停状态,第三方代码不受 Ask 初始化隔离。 - [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 是用户对当前本机或 SSH 账号可用工具、进程、网络和可写路径的完整授权。 - [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析;聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。 - [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 -- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。 +- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。 - [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩线始终根据当前设置与所选模型窗口即时计算,不在每个对话中保存旧配置;压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。 - [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。密钥随对应模型连接保存,修改服务地址或临时切换为无需认证不会要求重新输入;只有用户显式清除凭据或删除连接时才移除。 - [x] **OpenCode Runtime 定制**:GoodBuddy 管理的内置 OpenCode 可发现原生 Agents、Tools、Commands、LSP、Formatters、MCP、Skills、Prompts 与 Resources;Tools 单独显示读取、文件修改、命令、网络、Agent 编排等类型、来源及 Ask/Execute 可用性,并隐藏 OpenCode 内部 `invalid` 与 GoodBuddy 临时 MCP 工具。支持保存默认 Agent、每次请求覆盖 Agent、通过原生 SDK 执行 Command、显示上下文用量并调用有总时限的原生 Compact;并发外部 Server 对话的提问使用请求级公开 ID 映射,回答不会串到其他会话。外部 OpenCode Server 只报告连接状态,不宣称原生清单可读。任意插件安装、Session Share、自动 Worktree 和 OpenCode 原生会话持久化仍不开放。 diff --git a/UI-DESIGN.md b/UI-DESIGN.md index d92f2d29..e657976e 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -619,6 +619,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。 - 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。 - 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。 +- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。 - 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。 - 当前记录草稿非空时,切换笔记、待办或内容面板必须先在编辑器旁就地确认;继续编辑时保留草稿并恢复编辑焦点,只有明确选择放弃后才切换。 - 创建、保存、更新、删除和 AI 评论完成等短期结果进入应用级通知,不在编辑区或列表上方堆放页内通知。 @@ -639,7 +640,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - “模型连接”的认证字段按“认证方式 → API Key → 凭据状态”排列;只有选择 API Key 时显示输入框。已保存的 API Key 属于该连接,修改模型服务地址或临时选择“无需认证”不得自动清除,也不得要求重新输入;仅由明确的“清除凭据”或“删除连接”操作移除。 - “保存并测试模型”必须在保存后发送一次有界的真实模型请求。文本连接校验本次随机测试文本,图像连接校验实际返回的内联图片;仅收到成功 HTTP 状态、模型列表或健康检查响应不算测试成功。界面持续说明该操作可能产生少量服务商用量费用,成功反馈明确写为“真实生成测试通过”。 - 所有显式保存的设置草稿都参与离开保护:关闭、切换分类、主侧栏或工作区导航及托盘导航不得静默丢弃,统一通过设置中心的就地确认提供继续编辑与明确放弃入口,保存失败后保留输入。“平台功能 / 通用设置”承载全局快捷唤起的共享 Switch、可访问 accelerator 录制输入、恢复默认、保存及注册、停用或冲突状态,不新增分类或页签;注册或持久化失败时保留上一组可用快捷键和当前草稿,保存或停用成功后同步更新输入区的快捷键提示。 -- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64/arm64 Agent 包清单分成独立卡片;清单延迟到首次打开该页签时由 Main 完整校验,明确区分“已下载并验证”“未下载”和“校验失败”,提供手动下载/检查并更新、离线导入/导出与就地刷新,并且不混入 Host 已安装状态或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口,可恢复错误留在当前卡片。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。 +- “平台功能”使用共享 `PageTabs` 区分“通用设置”“远程项目(技术预览)”和“魔法笔记”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64/arm64 Agent 包清单分成独立卡片;清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。“魔法笔记”页提供默认开启的“显示未完成待办数量”持久 Switch,关闭后只隐藏左侧数量徽标,不改变待办数据或魔法笔记入口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。 - “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、原生单选下拉框和用途说明放在同一行,并复用设置表单的统一控件样式;关闭启动检查后,下拉框置灰且不可操作。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。 - “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图,失败时保留草稿并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。 - Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。 diff --git a/agent-runtime-lock.json b/agent-runtime-lock.json index 8de08222..bdaca5a7 100644 --- a/agent-runtime-lock.json +++ b/agent-runtime-lock.json @@ -1,6 +1,6 @@ { "formatVersion": 1, - "agentVersion": "0.11.4", + "agentVersion": "0.11.5", "protocol": { "major": 2, "minor": 0 diff --git a/docs/architecture/remote-host-and-goodbuddy-agent-design.md b/docs/architecture/remote-host-and-goodbuddy-agent-design.md index 740e43c7..3f333e34 100644 --- a/docs/architecture/remote-host-and-goodbuddy-agent-design.md +++ b/docs/architecture/remote-host-and-goodbuddy-agent-design.md @@ -2,7 +2,7 @@ ## 状态 -本文记录当前代码实现,不定义额外的信任框架。Windows 到 Linux x64 的安装、Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实 Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与 OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback 路径 capability 后,当前源码 lock 已转为正式 Agent `0.11.4`。失败的 `agent-v0.11.3` 保持不可变且未发布。远程 OpenCode 功能仍处于发布前验证阶段;公开签名 key registry 已供应,当前发布门槛是通过独立 Agent workflow 正式发布并公开验证 Linux x64/arm64 复合包和签名累计目录。 +本文记录当前代码实现,不定义额外的信任框架。Windows 到 Linux x64 的安装、Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实 Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与 OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback 路径 capability 后,当前源码 lock 已转为正式 Agent `0.11.5`。失败的 `agent-v0.11.3` 保持不可变且未发布。远程 OpenCode 功能仍处于发布前验证阶段;公开签名 key registry 已供应,当前发布门槛是通过独立 Agent workflow 正式发布并公开验证 Linux x64/arm64 复合包和签名累计目录。 ## 产品语义 @@ -65,10 +65,10 @@ Detached GoodBuddy Agent - SSH Host 设置通过固定探针和有界 SFTP 显示 Host 已登记的 Agent/OpenCode 版本,并与当前 GoodBuddy 所需版本比较;打开设置或“刷新版本”只读取状态,不安装或切换远端组件。 - “平台功能 > 远程项目”中的本地 Agent 包清单与 Host 状态分离。Main 对用户数据目录中 Linux x64/arm64 `.gbagent` 分别执行外层可信签名、桌面/协议兼容性、内部 Agent/Runtime - 签名、架构与完整 payload 校验;Renderer 只得到版本、架构、远端 Runtime 版本、协议和 - `verified | not-downloaded | invalid`,不得到缓存路径、key ID 或 digest。在线下载只由 - 用户点击触发,并使用“关于与更新”选择的 GitHub 或北京 OSS 来源;离线导入/导出通过 - Main 管理的文件对话框完成。 + 签名、架构与完整 payload 校验;首次打开和手动刷新还会读取“关于与更新”所选来源的 + 小型签名目录。Renderer 只得到本地/在线版本、是否有更新、架构、远端 Runtime 版本、 + 协议和本地状态,不得到缓存路径、key ID 或 digest。目录检查不下载 `.gbagent`,在线 + 包下载只由用户点击带目标版本的操作触发;离线导入/导出通过 Main 管理的文件对话框完成。 - 当任一组件缺失或不是当前版本时,用户可显式选择“更新版本”。Main 使用同一个取消信号依次强制执行 Agent、Runtime 安装器,并把 Agent、Runtime、Finalizing 阶段只发送给发起更新的 Renderer。更新启动仍受技术预览开关和可信 Sender 校验约束;取消不受开关约束,确保操作开始后即使关闭功能也能停止。 - 更新成功后才定向清理该 Host 的 Agent 连接和 Runtime 选择缓存,并让当前引用项目重新激活;失败或取消保留 Host 配置、凭据、项目、Workspace 和可继续验证的旧组件,Renderer 刷新实际版本并允许重试。同一 Renderer 同时只更新一个 Host,窗口销毁或 Main 退出会取消活动更新并执行有界收尾。 - 显式 stop、升级、身份冲突或进程退出时清理 GoodBuddy 自己的 socket、状态和子进程;不得删除或覆盖无关 Host 文件。 @@ -170,7 +170,7 @@ Execute 不经过 Ask 的 bubblewrap profile: - 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0` 和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布 - 的双架构验收。当前源码 lock 是正式版本 `0.11.4`,需要由新的复合包发布流程另行验证。 + 的双架构验收。当前源码 lock 是正式版本 `0.11.5`,需要由新的复合包发布流程另行验证。 - 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认 OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。 - 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生 @@ -227,6 +227,10 @@ Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转 - Main 的本地缓存只发布完整验证成功的内容。在线目录和包都绑定固定 GitHub/OSS URL、 大小和 SHA-256;选取规则是桌面最低版本满足、Agent protocol major 相同且 minor 不高于 当前支持值的最高 SemVer。缺少某架构仅使该架构 Host 的托管 SSH 无法激活。 +- `agent-release-keys.json` 读取时按 UTF-8 JSON 解析并严格验证 schema、唯一 key ID、 + 规范 Base64、有效 Ed25519 key、环境和撤销列表,不把 CRLF/LF 或普通 JSON 空白作为 + 信任条件。上传到 Host 前重新序列化为确定性 LF JSON。签名目录、描述符、manifest 和 + payload 仍按原始签名字节及 SHA-256 验证。 ## 发布前验证 diff --git a/package-lock.json b/package-lock.json index 6804eb05..80dc98f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodbuddy", - "version": "0.11.4", + "version": "0.11.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodbuddy", - "version": "0.11.4", + "version": "0.11.5", "license": "0BSD", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", diff --git a/package.json b/package.json index df56d0d7..cead8102 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodbuddy", - "version": "0.11.4", + "version": "0.11.5", "private": true, "description": "Secure desktop AI workspace with controlled Agent Runtimes", "desktopName": "GoodBuddy", diff --git a/resources/release-notes.json b/resources/release-notes.json index dba005f7..dce51c21 100644 --- a/resources/release-notes.json +++ b/resources/release-notes.json @@ -1,6 +1,46 @@ { "formatVersion": 1, "releases": [ + { + "version": "0.11.5", + "releasedAt": "2026-08-26", + "notes": { + "zh-CN": { + "highlights": [ + "GoodBuddy 0.11.5 修复 Agent 下载兼容性,显示可用 Agent 更新,并调整新安装的默认模型连接。" + ], + "features": [ + "**Agent 更新提示。** “设置 → 平台功能 → 远程项目”现在会显示本地 Agent 版本、在线最新兼容版本以及“有更新 / 已是最新”。打开页面只检查小型签名目录,不会自动下载 Agent 包。" + ], + "fixes": [ + "**Agent 公钥目录。** 修复 Windows 发布包使用 CRLF 换行时,合法 Agent 下载被拒绝的问题。JSON 排版不再影响信任,Ed25519 公钥、签名、撤销状态和 SHA-256 校验保持不变。", + "**默认模型连接。** 新安装改用本机 Ollama 兼容连接;升级只替换未配置凭据的旧默认项,用户显式保存的连接保持不变。", + "**侧栏菜单稳定性。** 修复魔法笔记未完成待办数字出现或隐藏时左侧菜单行高变化的问题;相邻入口现在保持固定位置。" + ], + "notices": [ + "本次正式 Agent 版本为 `0.11.5`,提供 Linux x64 与 arm64;大型包继续使用流式 SHA-256 校验,不会降级为 MD5。", + "`0.11.5` 将另外提供独立的实验性 LoongArch 桌面预览包。它不进入标准更新索引,且不包含 Agent、Remote Runtime、OpenCode 和原生 Canvas。" + ] + }, + "en-US": { + "highlights": [ + "GoodBuddy 0.11.5 fixes Agent download compatibility, shows available Agent updates, and updates the default model connection for new installations." + ], + "features": [ + "**Agent update status.** Settings → Platform Features → Remote Projects now shows the local Agent version, latest compatible online version, and Update available / Up to date status. Opening the page checks only the small signed catalog and never downloads an Agent package automatically." + ], + "fixes": [ + "**Agent public-key registry.** Fixed valid Agent downloads being rejected when Windows release packages used CRLF line endings. JSON formatting no longer affects trust, while Ed25519 keys, signatures, revocations, and SHA-256 verification remain unchanged.", + "**Default model connection.** New installations now use a local Ollama-compatible connection. Upgrades replace only the old default when it has no configured credential, while explicitly saved user connections remain unchanged.", + "**Stable sidebar navigation.** Fixed the Magic Notes incomplete-todo count changing the sidebar row height when the badge appeared or disappeared. Neighboring navigation items now remain in place." + ], + "notices": [ + "The formal Agent version is `0.11.5`, with Linux x64 and arm64 packages. Large packages continue to use streaming SHA-256 and are not downgraded to MD5.", + "A separate experimental LoongArch desktop preview will be provided for `0.11.5`. It remains outside the standard update index and excludes Agent, Remote Runtime, OpenCode, and native Canvas." + ] + } + } + }, { "version": "0.11.4", "releasedAt": "2026-08-26", diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 5f9df8f2..0c128cb9 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -1091,12 +1091,19 @@ describe('registerIpcHandlers SSH hosts', () => { } const agentPackageInventory = { checkedAt: '2030-01-01T00:00:00.000Z', + catalog: { + state: 'available', + checkedAt: '2030-01-01T00:00:00.000Z', + error: null + }, entries: [ { platform: 'linux', architecture: 'x64', state: 'verified', version: '0.11.2', + latestVersion: '0.11.4', + updateAvailable: true, remoteRuntimeVersion: '1.18.9', agentProtocol: { major: 2, minor: 0 } }, @@ -1105,6 +1112,8 @@ describe('registerIpcHandlers SSH hosts', () => { architecture: 'arm64', state: 'not-downloaded', version: null, + latestVersion: '0.11.4', + updateAvailable: false, remoteRuntimeVersion: null, agentProtocol: null } diff --git a/src/main/remote-agent/agent-bundle-verifier.test.ts b/src/main/remote-agent/agent-bundle-verifier.test.ts index b9a428be..520fee39 100644 --- a/src/main/remote-agent/agent-bundle-verifier.test.ts +++ b/src/main/remote-agent/agent-bundle-verifier.test.ts @@ -32,6 +32,8 @@ import type { import { agentManifestSignaturePayload, canonicalAgentManifestBytes, + canonicalAgentReleaseKeyRegistryBytes, + parseAgentReleaseKeyRegistryBytes, verifyAgentBundleDirectory, verifyAgentManifestSignature, type VerifiedAgentBundle @@ -280,6 +282,35 @@ afterEach(() => { }) describe('runtime and build-time Agent verifier parity', () => { + it('accepts equivalent registry whitespace and canonicalizes validated keys', () => { + const canonical = canonicalAgentReleaseKeyRegistryBytes(registry) + const crlf = Buffer.from( + canonical.toString('utf8').replace(/\n/gu, '\r\n'), + 'utf8' + ) + + const parsed = parseAgentReleaseKeyRegistryBytes(crlf) + + expect(parsed).toEqual(registry) + expect(canonicalAgentReleaseKeyRegistryBytes(parsed)).toEqual( + canonical + ) + }) + + it('rejects malformed or non-Ed25519 registry keys', () => { + expect(() => + parseAgentReleaseKeyRegistryBytes( + Buffer.from(JSON.stringify({ + ...registry, + keys: [{ + ...registry.keys[0], + publicKeySpkiBase64: 'AAAA' + }] + })) + ) + ).toThrow('contract is invalid') + }) + it('uses the exact build-time canonical bytes and signature domain', async () => { const manifest = writeBundle() const manifestBytes = readFileSync( diff --git a/src/main/remote-agent/agent-bundle-verifier.ts b/src/main/remote-agent/agent-bundle-verifier.ts index 3be11b85..c2f9958b 100644 --- a/src/main/remote-agent/agent-bundle-verifier.ts +++ b/src/main/remote-agent/agent-bundle-verifier.ts @@ -60,11 +60,15 @@ export type VerifyBundledAgentOptions = { export async function readAgentReleaseKeyRegistry( filePath: string ): Promise { - const value = await readJsonMetadata( + const bytes = await readBoundedFile( filePath, + MAXIMUM_METADATA_BYTES, + 'Agent trusted key registry' + ) + return parseAgentReleaseKeyRegistryBytes( + bytes, 'Agent trusted key registry' ) - return parseAgentReleaseKeyRegistry(value) } export async function readAgentRuntimeLock( @@ -233,13 +237,36 @@ export function canonicalAgentManifestBytes( export function canonicalAgentReleaseKeyRegistryBytes( registry: AgentReleaseKeyRegistry ): Buffer { - const canonical = agentReleaseKeyRegistrySchema.parse(registry) + const canonical = parseAgentReleaseKeyRegistry(registry) return Buffer.from( `${JSON.stringify(canonical, null, 2)}\n`, 'utf8' ) } +export function parseAgentReleaseKeyRegistryBytes( + bytes: Uint8Array, + description = 'Agent trusted key registry' +): AgentReleaseKeyRegistry { + let value: unknown + try { + value = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes) + ) as unknown + } catch (error) { + throw new Error(`${description} is invalid JSON`, { + cause: error + }) + } + try { + return parseAgentReleaseKeyRegistry(value) + } catch (error) { + throw new Error(`${description} contract is invalid`, { + cause: error + }) + } +} + export function agentManifestSignaturePayload( manifestBytes: Uint8Array ): Buffer { @@ -451,7 +478,7 @@ function importEd25519PublicKey(key: AgentReleaseKey) { return publicKey } -function parseAgentReleaseKeyRegistry( +export function parseAgentReleaseKeyRegistry( value: unknown ): AgentReleaseKeyRegistry { const registry = parseContract( diff --git a/src/main/remote-agent/agent-installation-manager.test.ts b/src/main/remote-agent/agent-installation-manager.test.ts index 4d064f10..272b3ccc 100644 --- a/src/main/remote-agent/agent-installation-manager.test.ts +++ b/src/main/remote-agent/agent-installation-manager.test.ts @@ -243,7 +243,10 @@ const registry: AgentReleaseKeyRegistry = { formatVersion: 1, keys: [{ keyId: 'test-key', - publicKeySpkiBase64: 'AAAA', + publicKeySpkiBase64: Buffer.from( + '302a300506032b6570032100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', + 'hex' + ).toString('base64'), environment: 'test' }], revocations: [] diff --git a/src/main/remote-agent/agent-installation-manager.ts b/src/main/remote-agent/agent-installation-manager.ts index 92c7b165..e9c59e80 100644 --- a/src/main/remote-agent/agent-installation-manager.ts +++ b/src/main/remote-agent/agent-installation-manager.ts @@ -1,7 +1,6 @@ import { createHash, randomUUID } from 'node:crypto' import { lstat, readFile } from 'node:fs/promises' import { - agentReleaseKeyRegistrySchema, type AgentArchitecture, type AgentBundleManifest, type AgentReleaseKeyRegistry @@ -31,6 +30,8 @@ import { import { assertAgentManifestMatchesRuntimeLock, canonicalAgentReleaseKeyRegistryBytes, + parseAgentReleaseKeyRegistry, + parseAgentReleaseKeyRegistryBytes, readAgentReleaseKeyRegistry, readAgentRuntimeLock, verifyAgentManifestSignature, @@ -599,7 +600,7 @@ export class AgentInstallationManager { ) } const canonicalRegistry = - agentReleaseKeyRegistrySchema.parse(loaded.registry) + parseAgentReleaseKeyRegistry(loaded.registry) return { bundle: loaded.bundle, registry: canonicalRegistry, @@ -627,7 +628,7 @@ export class AgentInstallationManager { verificationEnvironment: this.#verificationEnvironment } ) - const canonicalRegistry = agentReleaseKeyRegistrySchema.parse( + const canonicalRegistry = parseAgentReleaseKeyRegistry( registry ) return { @@ -648,14 +649,14 @@ export class AgentInstallationManager { }, signal: AbortSignal ): Promise { - const [canonicalRegistryBytes, runtimeLock] = await Promise.all([ - readFile(this.#resourcePaths.keyRegistryPath), + const [registry, runtimeLock] = await Promise.all([ + readAgentReleaseKeyRegistry( + this.#resourcePaths.keyRegistryPath + ), readAgentRuntimeLock(this.#resourcePaths.runtimeLockPath) ]) - const registry = parseCanonicalAgentReleaseKeyRegistry( - canonicalRegistryBytes, - 'Agent trusted key registry' - ) + const canonicalRegistryBytes = + canonicalAgentReleaseKeyRegistryBytes(registry) let metadataSftp: StagedSftp | undefined let installationSftp: StagedSftp | undefined @@ -706,7 +707,7 @@ export class AgentInstallationManager { signal ) const remoteReleaseKeyRegistry = - parseCanonicalAgentReleaseKeyRegistry( + parseAgentReleaseKeyRegistryBytes( remoteReleaseKeyBytes, 'Installed Agent release-key registry' ) @@ -851,21 +852,6 @@ function parseJsonBytes( } } -function parseCanonicalAgentReleaseKeyRegistry( - value: Buffer, - description: string -): AgentReleaseKeyRegistry { - const registry = agentReleaseKeyRegistrySchema.parse( - parseJsonBytes(value, description) - ) - const canonicalBytes = - canonicalAgentReleaseKeyRegistryBytes(registry) - if (!canonicalBytes.equals(value)) { - throw new Error(`${description} is not canonical`) - } - return registry -} - function decodeDetachedSignature( value: Buffer, description: string diff --git a/src/main/remote-agent/agent-package-manager.ts b/src/main/remote-agent/agent-package-manager.ts index 6f040e60..8a8ea98d 100644 --- a/src/main/remote-agent/agent-package-manager.ts +++ b/src/main/remote-agent/agent-package-manager.ts @@ -32,7 +32,6 @@ import { } from '../../shared/agent-protocol/contracts' import { agentArchitectureSchema, - agentReleaseKeyRegistrySchema, type AgentArchitecture, type AgentReleaseKeyRegistry } from '../../shared/agent-installation-contracts' @@ -49,7 +48,7 @@ import type { AgentInstallationBundleLoader } from './agent-installation-manager' import { - canonicalAgentReleaseKeyRegistryBytes + readAgentReleaseKeyRegistry } from './agent-bundle-verifier' import type { RemoteRuntimeInstallationBundleLoader, @@ -84,6 +83,18 @@ type InstalledRecord = { verified: VerifiedAgentPackage } +type CatalogState = + | { + state: 'available' + checkedAt: string + catalog: AgentPackageCatalog + } + | { + state: 'unavailable' + checkedAt: string + error: string + } + export type AgentPackageManagerOptions = { userDataPath: string desktopVersion: string @@ -111,6 +122,7 @@ export class AgentPackageManager { >() readonly #leaseCounts = new Map() readonly #pendingRemovals = new Set() + #catalogState?: CatalogState constructor(options: AgentPackageManagerOptions) { this.#rootDirectory = resolve( @@ -170,11 +182,17 @@ export class AgentPackageManager { } catch { state = 'invalid' } + const latestVersion = this.#latestVersion(architecture) return { platform: 'linux' as const, architecture, state, version: record?.verified.descriptor.version ?? null, + latestVersion, + updateAvailable: this.#updateAvailable( + latestVersion, + record + ), remoteRuntimeVersion: record?.verified.descriptor.remoteRuntime.version ?? null, agentProtocol: @@ -184,13 +202,15 @@ export class AgentPackageManager { ) return agentPackageInventorySchema.parse({ checkedAt: this.#now().toISOString(), + catalog: this.#publicCatalogState(), entries }) } - getSnapshot( + async getSnapshot( options: { refresh?: boolean } = {} ): Promise { + await this.#checkCatalog() return this.getInventory(options) } @@ -229,24 +249,34 @@ export class AgentPackageManager { totalBytes: null }) const source = await this.#getUpdateSource() - const catalog = await this.#loadCatalog(source) + const catalog = await this.#loadCatalogAndRemember(source) const entry = selectLatestCompatibleEntry( catalog, architecture, this.#desktopVersion ) const current = await this.#loadInstalled(architecture) + const comparison = current + ? compareSemanticVersions( + entry.version, + current.verified.descriptor.version + ) + : 1 if ( current && - compareSemanticVersions( - entry.version, - current.verified.descriptor.version - ) < 0 + comparison < 0 ) { throw new Error( 'Agent 发布目录不能降级已安装的兼容版本' ) } + if ( + current && + comparison === 0 && + current.archiveSha256 === entry.sha256 + ) { + return this.getInventory() + } const staging = await this.#createStaging(architecture) const archivePath = join(staging, entry.archive) try { @@ -710,6 +740,90 @@ export class AgentPackageManager { return catalog } + async #loadCatalogAndRemember( + source: UpdateSource + ): Promise { + const checkedAt = this.#now().toISOString() + try { + const catalog = await this.#loadCatalog(source) + this.#catalogState = { + state: 'available', + checkedAt, + catalog + } + return catalog + } catch (error) { + this.#catalogState = { + state: 'unavailable', + checkedAt, + error: boundedErrorMessage(error) + } + throw error + } + } + + async #checkCatalog(): Promise { + try { + await this.#loadCatalogAndRemember( + await this.#getUpdateSource() + ) + } catch { + // The failed catalog state is already recorded. Local packages remain usable. + } + } + + #latestVersion( + architecture: AgentArchitecture + ): string | null { + if (this.#catalogState?.state !== 'available') { + return null + } + try { + return selectLatestCompatibleEntry( + this.#catalogState.catalog, + architecture, + this.#desktopVersion + ).version + } catch { + return null + } + } + + #updateAvailable( + latestVersion: string | null, + installed: InstalledRecord | undefined + ): boolean { + return installed !== undefined && + latestVersion !== null && + compareSemanticVersions( + latestVersion, + installed.verified.descriptor.version + ) > 0 + } + + #publicCatalogState(): + AgentPackageInventory['catalog'] { + if (this.#catalogState === undefined) { + return { + state: 'not-checked', + checkedAt: null, + error: null + } + } + if (this.#catalogState.state === 'available') { + return { + state: 'available', + checkedAt: this.#catalogState.checkedAt, + error: null + } + } + return { + state: 'unavailable', + checkedAt: this.#catalogState.checkedAt, + error: this.#catalogState.error + } + } + async #fetchMirrorCatalog(): Promise<[Buffer, Buffer]> { const pointerBytes = await this.#fetchBounded( `${MIRROR_ROOT}latest.json` @@ -874,7 +988,7 @@ export class AgentPackageManager { } #loadTrustedRegistry(): Promise { - this.#trustedRegistry ??= readCanonicalRegistry( + this.#trustedRegistry ??= readAgentReleaseKeyRegistry( this.#keyRegistryPath ) return this.#trustedRegistry @@ -1010,23 +1124,6 @@ function assertCanonicalDownload( } } -async function readCanonicalRegistry( - filePath: string -): Promise { - const bytes = await readFile(filePath) - const registry = agentReleaseKeyRegistrySchema.parse( - JSON.parse( - new TextDecoder('utf-8', { fatal: true }).decode(bytes) - ) - ) - if ( - !canonicalAgentReleaseKeyRegistryBytes(registry).equals(bytes) - ) { - throw new Error('Agent 签名公钥目录不是规范格式') - } - return registry -} - function parseCanonicalCatalog(bytes: Buffer): AgentPackageCatalog { const catalog = agentPackageCatalogSchema.parse( JSON.parse( @@ -1125,6 +1222,16 @@ function verifyCatalogSignature( } } +function boundedErrorMessage(error: unknown): string { + const message = error instanceof Error + ? error.message + : String(error) + return ( + message.trim().slice(0, 2_000) || + 'Agent 发布目录检查失败' + ) +} + async function copyAndHashBoundedRegularFile( sourcePath: string, destinationPath: string, diff --git a/src/main/remote-agent/agent-package-verifier.ts b/src/main/remote-agent/agent-package-verifier.ts index eb8e4117..fb76bbce 100644 --- a/src/main/remote-agent/agent-package-verifier.ts +++ b/src/main/remote-agent/agent-package-verifier.ts @@ -38,12 +38,12 @@ import { canonicalJson } from '../../shared/agent-protocol/canonical' import { - agentReleaseKeyRegistrySchema, type AgentArchitecture, type AgentReleaseKeyRegistry } from '../../shared/agent-installation-contracts' import { - canonicalAgentReleaseKeyRegistryBytes, + parseAgentReleaseKeyRegistry, + parseAgentReleaseKeyRegistryBytes, verifyAgentBundleDirectory, type AgentVerificationEnvironment, type VerifiedAgentBundle @@ -156,8 +156,11 @@ export async function verifyExtractedAgentPackage(options: { 'Agent package is not compatible with this GoodBuddy version' ) } - const registry = parseCanonicalRegistry(registryBytes) - const trustedRegistry = agentReleaseKeyRegistrySchema.parse( + const registry = parseAgentReleaseKeyRegistryBytes( + registryBytes, + 'Agent package key registry' + ) + const trustedRegistry = parseAgentReleaseKeyRegistry( options.trustedRegistry ) verifyDescriptorSignature( @@ -545,20 +548,6 @@ function parseCanonicalDescriptor( return descriptor } -function parseCanonicalRegistry(bytes: Buffer) { - const registry = agentReleaseKeyRegistrySchema.parse( - JSON.parse( - new TextDecoder('utf-8', { fatal: true }).decode(bytes) - ) - ) - if ( - !canonicalAgentReleaseKeyRegistryBytes(registry).equals(bytes) - ) { - throw new Error('Agent package key registry is not canonical') - } - return registry -} - function verifyDescriptorSignature( descriptor: AgentPackageDescriptor, descriptorBytes: Buffer, diff --git a/src/main/remote-agent/remote-runtime-installation-manager.test.ts b/src/main/remote-agent/remote-runtime-installation-manager.test.ts index cf705270..af7091b2 100644 --- a/src/main/remote-agent/remote-runtime-installation-manager.test.ts +++ b/src/main/remote-agent/remote-runtime-installation-manager.test.ts @@ -236,7 +236,10 @@ const registry: AgentReleaseKeyRegistry = { formatVersion: 1, keys: [{ keyId: 'test-key', - publicKeySpkiBase64: 'AAAA', + publicKeySpkiBase64: Buffer.from( + '302a300506032b6570032100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', + 'hex' + ).toString('base64'), environment: 'test' }], revocations: [] diff --git a/src/main/remote-agent/remote-runtime-installation-manager.ts b/src/main/remote-agent/remote-runtime-installation-manager.ts index cb0964c2..713afdc5 100644 --- a/src/main/remote-agent/remote-runtime-installation-manager.ts +++ b/src/main/remote-agent/remote-runtime-installation-manager.ts @@ -35,6 +35,9 @@ import type { SshConnectionPoolTarget } from '../ssh/ssh-connection-pool' import { settleBoundedly } from './bounded-settlement' +import { + parseAgentReleaseKeyRegistryBytes +} from './agent-bundle-verifier' import { isMissingPathError } from './path-errors' const MAXIMUM_INSTALLATION_FILES = 200 @@ -711,18 +714,13 @@ export class RemoteRuntimeInstallationManager { metadataSftp.readFile(RELEASE_KEYS_PATH, signal), metadataSftp.readFile(RUNTIME_LOCK_PATH, signal) ]) - const remoteReleaseKeyRegistryBytes = canonicalJsonBytes( - remoteReleaseKeyBytes, - agentReleaseKeyRegistrySchema, + const remoteReleaseKeyRegistryBytes = + Buffer.from(remoteReleaseKeyBytes) + const remoteReleaseKeyRegistry = + parseAgentReleaseKeyRegistryBytes( + remoteReleaseKeyRegistryBytes, 'Installed Runtime release-key registry' ) - const remoteReleaseKeyRegistry = - agentReleaseKeyRegistrySchema.parse( - parseJsonBytes( - remoteReleaseKeyRegistryBytes, - 'Installed Runtime release-key registry' - ) - ) if (!remoteRuntimeLockFileBytes.equals(remoteRuntimeLockBytes)) { throw new Error( 'Installed Runtime lock does not match this GoodBuddy build' diff --git a/src/main/remote-agent/remote-runtime-resource-loader.test.ts b/src/main/remote-agent/remote-runtime-resource-loader.test.ts index 1a6c5011..f763db67 100644 --- a/src/main/remote-agent/remote-runtime-resource-loader.test.ts +++ b/src/main/remote-agent/remote-runtime-resource-loader.test.ts @@ -173,6 +173,23 @@ describe('remote Runtime resource loader', () => { }) }) + it('accepts equivalent key-registry formatting and returns canonical bytes', async () => { + const fixture = await createFixture() + await writeFile( + fixture.paths.keyRegistryPath, + Buffer.from( + canonical(registry).toString('utf8').replace(/\n/gu, '\r\n') + ) + ) + + await expect( + loadRemoteRuntimeVerificationMetadata(fixture.paths) + ).resolves.toMatchObject({ + releaseKeyRegistry: registry, + canonicalReleaseKeyRegistryBytes: canonical(registry) + }) + }) + it('rejects a symlinked digest directory', async () => { const fixture = await createFixture({ digestDirectories: 0 }) const target = join(fixture.root, 'target') @@ -226,11 +243,6 @@ describe('remote Runtime resource loader', () => { bytes: Buffer.from('not json\n'), error: 'invalid JSON' }, - { - name: 'noncanonical', - bytes: Buffer.from(JSON.stringify(registry)), - error: 'not canonical' - }, { name: 'oversize', bytes: Buffer.alloc(1024 * 1024 + 1, 0x20), diff --git a/src/main/remote-agent/remote-runtime-resource-loader.ts b/src/main/remote-agent/remote-runtime-resource-loader.ts index 87ff7ddb..8d82c76d 100644 --- a/src/main/remote-agent/remote-runtime-resource-loader.ts +++ b/src/main/remote-agent/remote-runtime-resource-loader.ts @@ -11,7 +11,6 @@ import { type VerifyRuntimeBundleOptions } from '../../shared/node/runtime-bundle-verifier' import { - agentReleaseKeyRegistrySchema, type AgentArchitecture, type AgentReleaseKeyRegistry } from '../../shared/agent-installation-contracts' @@ -30,6 +29,9 @@ import { getBundledRemoteRuntimeRoot, type BundledRemoteRuntimeResourcePaths } from './bundled-remote-runtime-resources' +import { + parseAgentReleaseKeyRegistry +} from './agent-bundle-verifier' import { isMissingPathError } from './path-errors' const MAXIMUM_METADATA_BYTES = 1024 * 1024 @@ -106,8 +108,9 @@ export async function loadRemoteRuntimeVerificationMetadata( const [keyRegistry, runtimeLock] = await Promise.all([ readCanonicalMetadata( paths.keyRegistryPath, - agentReleaseKeyRegistrySchema, - 'Runtime release-key registry' + { parse: parseAgentReleaseKeyRegistry }, + 'Runtime release-key registry', + true ), readCanonicalMetadata( paths.runtimeLockPath, @@ -139,7 +142,8 @@ function assertSafeVerificationInjection( async function readCanonicalMetadata( filePathInput: string, schema: { parse(value: unknown): T }, - label: string + label: string, + acceptEquivalentJsonFormatting = false ): Promise> { const filePath = resolve(filePathInput) const pathStat = await lstat(filePath) @@ -212,7 +216,10 @@ async function readCanonicalMetadata( `${JSON.stringify(value, null, 2)}\n`, 'utf8' ) - if (!canonicalBytes.equals(bytes)) { + if ( + !acceptEquivalentJsonFormatting && + !canonicalBytes.equals(bytes) + ) { throw new Error(`${label} is not canonical`) } return { bytes: canonicalBytes, value } diff --git a/src/main/runtime-settings-store.test.ts b/src/main/runtime-settings-store.test.ts index 4b931253..f33b8ce6 100644 --- a/src/main/runtime-settings-store.test.ts +++ b/src/main/runtime-settings-store.test.ts @@ -80,6 +80,63 @@ afterEach(async () => { }) describe('RuntimeSettingsStore', () => { + it('seeds a local provider-neutral model connection', async () => { + const { store } = await createStore() + + await expect(store.getPublicSettings()).resolves.toMatchObject({ + modelBaseUrl: 'http://127.0.0.1:11434/v1', + modelName: 'qwen3', + modelProtocol: 'openai-chat-completions', + modelAuthentication: 'none', + modelProfiles: [ + expect.objectContaining({ + id: '00000000-0000-4000-8000-000000000001', + baseUrl: 'http://127.0.0.1:11434/v1', + modelName: 'qwen3', + authentication: 'none' + }) + ] + }) + }) + + it('replaces only the untouched legacy BigToken seed', async () => { + const { filePath, store } = await createStore() + await store.update(settings()) + const version19 = JSON.parse( + await readFile(filePath, 'utf8') + ) as Record + version19.version = 19 + await writeFile(filePath, JSON.stringify(version19), 'utf8') + + const migrated = new RuntimeSettingsStore(filePath, cipher, {}) + await expect(migrated.getPublicSettings()).resolves.toMatchObject({ + modelBaseUrl: 'http://127.0.0.1:11434/v1', + modelName: 'qwen3', + modelAuthentication: 'none' + }) + }) + + it('preserves an explicitly credentialed BigToken connection', async () => { + const { filePath, store } = await createStore() + await store.update( + settings({ + apiKey: { action: 'replace', value: 'explicit-key' } + }) + ) + const version19 = JSON.parse( + await readFile(filePath, 'utf8') + ) as Record + version19.version = 19 + await writeFile(filePath, JSON.stringify(version19), 'utf8') + + const migrated = new RuntimeSettingsStore(filePath, cipher, {}) + await expect(migrated.getResolvedSettings()).resolves.toMatchObject({ + modelBaseUrl: 'https://bigtoken.ai', + modelName: 'sonnet-5', + apiKey: 'explicit-key' + }) + }) + it('restores an exact credential-bearing snapshot after a failed activation', async () => { const { store } = await createStore() await store.update( @@ -382,7 +439,7 @@ describe('RuntimeSettingsStore', () => { }) await expect(store.getResolvedSettings()).resolves.toMatchObject({ - modelProtocol: 'anthropic-messages', + modelProtocol: 'openai-chat-completions', deepseekHarnessModelProfile: { id: 'goodbuddy-platform-harness', name: '管理员预置模型', @@ -398,7 +455,9 @@ describe('RuntimeSettingsStore', () => { const publicSettings = await store.getPublicSettings() expect(JSON.stringify(publicSettings)).not.toContain(apiKey) - expect(publicSettings.modelProtocol).toBe('anthropic-messages') + expect(publicSettings.modelProtocol).toBe( + 'openai-chat-completions' + ) }) it.each([ @@ -481,7 +540,7 @@ describe('RuntimeSettingsStore', () => { const persisted = JSON.parse( await readFile(filePath, 'utf8') ) as Record - expect(persisted.version).toBe(19) + expect(persisted.version).toBe(20) expect(persisted).not.toHaveProperty( 'deepseekHarnessBinaryPath' ) @@ -760,7 +819,7 @@ describe('RuntimeSettingsStore', () => { const persisted = JSON.parse(await readFile(filePath, 'utf8')) as { version: number } - expect(persisted.version).toBe(19) + expect(persisted.version).toBe(20) }) it('migrates version 11 and removes the obsolete intranet toggle', async () => { @@ -780,7 +839,7 @@ describe('RuntimeSettingsStore', () => { version: number intranetCompatibilityEnabled?: boolean } - expect(persisted.version).toBe(19) + expect(persisted.version).toBe(20) expect(persisted).not.toHaveProperty('intranetCompatibilityEnabled') }) @@ -1058,7 +1117,7 @@ describe('RuntimeSettingsStore', () => { (JSON.parse(await readFile(filePath, 'utf8')) as { version: number }).version - ).toBe(19) + ).toBe(20) }) it('migrates version 13 with reranking disabled by default', async () => { @@ -1565,7 +1624,7 @@ describe('RuntimeSettingsStore', () => { version: number modelProfiles: Array> } - expect(persisted.version).toBe(19) + expect(persisted.version).toBe(20) expect(persisted.modelProfiles).toContainEqual( expect.objectContaining({ id: imageId, @@ -1653,7 +1712,7 @@ describe('RuntimeSettingsStore', () => { }) }) - it('does not mix an environment key with a stored base URL', async () => { + it('keeps provider-specific environment aliases for existing deployments', async () => { const { filePath, store } = await createStore() await store.update( settings({ @@ -1663,11 +1722,14 @@ describe('RuntimeSettingsStore', () => { ) const environmentStore = new RuntimeSettingsStore(filePath, cipher, { - GOODBUDDY_BIGTOKEN_API_KEY: 'YOUR_API_KEY_HERE' + GOODBUDDY_BIGTOKEN_API_KEY: 'legacy-key', + GOODBUDDY_BIGTOKEN_BASE_URL: 'https://legacy.example', + GOODBUDDY_BIGTOKEN_MODEL: 'legacy-model' }) await expect(environmentStore.getResolvedSettings()).resolves.toMatchObject({ - apiKey: 'YOUR_API_KEY_HERE', - modelBaseUrl: 'https://bigtoken.ai' + apiKey: 'legacy-key', + modelBaseUrl: 'https://legacy.example', + modelName: 'legacy-model' }) }) @@ -1724,7 +1786,7 @@ describe('RuntimeSettingsStore', () => { }) }) - it('prefers generic model environment variables over legacy fallbacks', async () => { + it('prefers generic model environment variables over legacy aliases', async () => { const { filePath } = await createStore() const store = new RuntimeSettingsStore(filePath, cipher, { GOODBUDDY_MODEL_API_KEY: 'generic-key', @@ -1864,7 +1926,7 @@ describe('RuntimeSettingsStore', () => { unknown > expect(saved).toMatchObject({ - version: 19, + version: 20, provider: 'model', continueBinaryPath: '', continueMode: 'chat', @@ -2143,7 +2205,7 @@ describe('RuntimeSettingsStore', () => { version: number modelProfiles: Array> } - expect(persisted.version).toBe(19) + expect(persisted.version).toBe(20) expect(persisted.modelProfiles[0]).not.toHaveProperty('credential') }) diff --git a/src/main/runtime-settings-store.ts b/src/main/runtime-settings-store.ts index c75b03fd..398a5167 100644 --- a/src/main/runtime-settings-store.ts +++ b/src/main/runtime-settings-store.ts @@ -259,7 +259,7 @@ const storedEmbeddingConnectionSchema = z.discriminatedUnion('kind', [ .strict() ]) -const storedSettingsSchema = version18StoredSettingsSchema +const version19StoredSettingsSchema = version18StoredSettingsSchema .omit({ version: true }) .extend({ version: z.literal(19), @@ -270,6 +270,12 @@ const storedSettingsSchema = version18StoredSettingsSchema activeEmbeddingConnectionId: z.string().uuid() }) +const storedSettingsSchema = version19StoredSettingsSchema + .omit({ version: true }) + .extend({ + version: z.literal(20) + }) + type StoredSettings = z.infer export type RuntimeSettingsRollback = { publicSettings: RuntimeSettings @@ -281,6 +287,9 @@ type Version17StoredSettings = z.infer< type Version18StoredSettings = z.infer< typeof version18StoredSettingsSchema > +type Version19StoredSettings = z.infer< + typeof version19StoredSettingsSchema +> type Version16StoredSettings = z.infer< typeof version16StoredSettingsSchema > @@ -449,7 +458,7 @@ type ResolvedModelCredential = { } const defaultSettings: StoredSettings = { - version: 19, + version: 20, provider: defaultRuntimeSettings.provider, modelProfiles: [ { @@ -653,7 +662,7 @@ function migrateVersion17( function migrateVersion18( settings: Version18StoredSettings ): StoredSettings { - return { + return migrateVersion19({ ...settings, version: 19, embeddingConnections: [ @@ -673,6 +682,36 @@ function migrateVersion18( } ], activeEmbeddingConnectionId: legacyEmbeddingConnectionId + }) +} + +function migrateVersion19( + settings: Version19StoredSettings +): StoredSettings { + return { + ...settings, + version: 20, + modelProfiles: settings.modelProfiles.map((profile) => + profile.id === defaultModelProfileId && + profile.name === '默认模型' && + profile.baseUrl === 'https://bigtoken.ai' && + profile.modelName === 'sonnet-5' && + profile.protocol === 'anthropic-messages' && + profile.authentication === 'api-key' && + profile.supportsImageInput === false && + profile.imageGenerationQuality === 'auto' && + profile.contextWindowTokens === undefined && + profile.credential === undefined + ? { + ...profile, + baseUrl: defaultRuntimeSettings.modelBaseUrl, + modelName: defaultRuntimeSettings.modelName, + protocol: defaultRuntimeSettings.modelProtocol, + authentication: + defaultRuntimeSettings.modelAuthentication + } + : profile + ) } } @@ -995,13 +1034,17 @@ export class RuntimeSettingsStore { const parsed: unknown = JSON.parse(contents) assertSupportedSettingsVersion( parsed, - 19, + 20, (version) => `当前 GoodBuddy 不支持 Runtime 设置版本 ${version},请升级应用后重试` ) const current = storedSettingsSchema.safeParse(parsed) + const version19 = + version19StoredSettingsSchema.safeParse(parsed) if (current.success) { this.settings = current.data + } else if (version19.success) { + this.settings = migrateVersion19(version19.data) } else { const version18 = version18StoredSettingsSchema.safeParse(parsed) @@ -1377,10 +1420,7 @@ export class RuntimeSettingsStore { settings.modelProfiles.find( (profile) => profile.id === settings.defaultModelProfileId ) ?? settings.modelProfiles[0] - const environmentApiKey = - defaultProfile?.authentication === 'api-key' - ? this.getEnvironmentApiKey() - : undefined + const environmentApiKey = this.getEnvironmentApiKey() return new Map( settings.modelProfiles.map((profile) => { const environmentManaged = @@ -1401,11 +1441,11 @@ export class RuntimeSettingsStore { profile.id, { activeApiKey: - profile.authentication === 'api-key' - ? environmentManaged - ? environmentApiKey - : storedApiKey - : undefined, + environmentManaged + ? environmentApiKey + : profile.authentication === 'api-key' + ? storedApiKey + : undefined, configured: environmentManaged || Boolean(storedApiKey), source } @@ -1457,7 +1497,9 @@ export class RuntimeSettingsStore { baseUrl, model, protocol: profile.protocol, - authentication: profile.authentication, + authentication: environmentManaged + ? 'api-key' + : profile.authentication, supportsImageInput: profile.supportsImageInput, contextWindowTokens: profile.contextWindowTokens, imageGenerationQuality: profile.imageGenerationQuality, @@ -2228,7 +2270,7 @@ export class RuntimeSettingsStore { const next: StoredSettings = { ...current, - version: 19, + version: 20, provider: input.provider, modelProfiles, defaultModelProfileId, @@ -2289,7 +2331,7 @@ export class RuntimeSettingsStore { const current = await this.load() const next: StoredSettings = { ...current, - version: 19, + version: 20, runtimeCustomization: parsed } await writeJsonFileAtomically(this.filePath, next) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 677b26e8..b8979550 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -6148,12 +6148,19 @@ describe('App', () => { })), getAgentPackageInventory: vi.fn(async () => ({ checkedAt: '2026-08-24T00:00:00.000Z', + catalog: { + state: 'available' as const, + checkedAt: '2026-08-24T00:00:00.000Z', + error: null + }, entries: [ { platform: 'linux' as const, architecture: 'x64' as const, state: 'verified' as const, version: '0.11.2', + latestVersion: '0.11.4', + updateAvailable: true, agentProtocol: { major: 2, minor: 0 }, remoteRuntimeVersion: '1.18.9' }, @@ -6162,6 +6169,8 @@ describe('App', () => { architecture: 'arm64' as const, state: 'not-downloaded' as const, version: null, + latestVersion: '0.11.4', + updateAvailable: false, agentProtocol: null, remoteRuntimeVersion: null } diff --git a/src/renderer/src/PlatformFeaturesSettingsSection.tsx b/src/renderer/src/PlatformFeaturesSettingsSection.tsx index be1b8b20..9c5e8cc5 100644 --- a/src/renderer/src/PlatformFeaturesSettingsSection.tsx +++ b/src/renderer/src/PlatformFeaturesSettingsSection.tsx @@ -930,10 +930,26 @@ export function PlatformFeaturesSettingsSection({ Linux {entry.architecture} {t( - `platformFeatures.remoteProjects.agentInventory.states.${entry.state}` + entry.updateAvailable + ? 'platformFeatures.remoteProjects.agentInventory.states.updateAvailable' + : agentInventory.catalog.state === + 'available' && + entry.state === 'verified' && + entry.latestVersion !== null + ? 'platformFeatures.remoteProjects.agentInventory.states.upToDate' + : `platformFeatures.remoteProjects.agentInventory.states.${entry.state}` )} @@ -946,6 +962,14 @@ export function PlatformFeaturesSettingsSection({
{entry.version ?? '—'}
+
+
+ {t( + 'platformFeatures.remoteProjects.agentInventory.fields.latestVersion' + )} +
+
{entry.latestVersion ?? '—'}
+
{t( @@ -985,23 +1009,36 @@ export function PlatformFeaturesSettingsSection({

)}
- + {( + entry.updateAvailable || + ( + entry.state !== 'verified' && + entry.latestVersion !== null + ) || + agentInventory.catalog.state !== 'available' + ) && ( + + )} {entry.state === 'verified' && (