From 6eb9fa092f2b2c5e0a73cc2fcdc2f650c9cfa044 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:35:47 +0800 Subject: [PATCH 01/63] docs: add migration plan --- docs/wails-v3-migration-plan.md | 422 ++++++++++++++++++++++++++++++++ 1 file changed, 422 insertions(+) create mode 100644 docs/wails-v3-migration-plan.md diff --git a/docs/wails-v3-migration-plan.md b/docs/wails-v3-migration-plan.md new file mode 100644 index 00000000..5363d587 --- /dev/null +++ b/docs/wails-v3-migration-plan.md @@ -0,0 +1,422 @@ +# OneAgent 全量迁移至 Wails v3 规划方案 + +- 状态:Proposed +- 日期:2026-07-29 +- 目标版本:下一主版本(建议 `0.3.0-dev` 开始迁移) +- 适用范围:桌面应用、Go 核心、CLI、前端通信、测试、构建、发布和公开站数据生成 + +## 1. 结论 + +OneAgent 应迁移为“Wails v3 桌面壳 + Go 领域核心 + React/Vite 前端 + 独立 Go CLI”的结构。生产桌面应用不再启动 localhost HTTP Server,React 只通过 Wails 自动生成的 TypeScript binding 调用已注册 Go service;CLI 与桌面 service 共用同一套 Go use case,不复制安装、配置或安全逻辑。 + +本次“去除 Python 依赖”按仓库级目标执行: + +- OneAgent 的源码开发、测试、构建、运行和发布均不要求 Python。 +- 删除所有受版本控制的 `.py`、PyInstaller、wheel/setuptools 和 Python CI 步骤。 +- `site/` 保持 Astro 静态站,不塞入 Wails;它当前依赖的 Python 数据生成器改为 TypeScript。 +- Aider 是唯一需要单独解释的外部边界:Aider 上游本身是 Python 工具。建议保留“检测、配置已安装 Aider”和“用户明确选择时通过 `uv` 安装”的能力,但将 Python 3.12 明确标记为 **Aider 的可选上游前置条件**,不是 OneAgent 的运行依赖。若产品要求目标机器绝对不能存在 Python,则必须把 Aider 自动安装降为 guide-only;两者不能同时成立。 + +迁移采用契约优先、分阶段切换,不做一次性重写。Python 生产入口只在 Go 实现尚未达到行为等价时保留;正式切换后不提供 Python fallback,否则无法证明已经完成去依赖。 + +## 2. 迁移目标与非目标 + +### 2.1 必须达成 + +1. React 生产代码中不存在 `fetch("/api/...")`、Cookie 会话或 localhost API fallback。 +2. Wails 仅注册明确列出的 service;不使用 `RawMessageHandler`,不为业务 service 配置 HTTP `Route`。 +3. 当前 Python 核心的行为契约保持不变:版本锁定、npm integrity 校验、Provider 多协议探测、原子写入、备份、Unix mode、Windows ACL、配置保留合并、密钥脱敏和稳定错误码。 +4. `agents.lock.json` 继续作为 Agent 行为和版本的唯一真源,使用 `go:embed` 进入桌面与 CLI 产物。 +5. 桌面应用与 CLI 共用 Go 核心;CLI 不依赖 GTK/WebKit,适合 headless 和自动化环境。 +6. macOS arm64/x64、Windows x64、Linux x64 均在目标系统原生构建并取得 cleanroom 证据。 +7. 迁移完成后,未安装 Python 的干净环境可以构建、测试并运行 OneAgent 的全部非 Aider-install 流程。 + +### 2.2 不在本次范围 + +- 不把公开站 `site/` 改成桌面应用或 Wails Server 部署。 +- 不新增统一网关、代理、VPN、后台 daemon、遥测或共享 API Key。 +- 不顺带引入自动更新、应用商店、Stable 签名策略变更或新的 Agent。 +- 不借迁移重设计七页向导、profile schema、Agent 配置格式或错误码。 +- 不把 guide-only Agent 变成自动配置 Agent。 + +## 3. 当前基线与不可丢失的契约 + +| 当前模块 | 当前职责 | 迁移后的归属 | +| --- | --- | --- | +| `oneagent/catalog.py` | manifest、Provider、平台和 HOME 解析 | `internal/catalog`、`internal/platform` | +| `oneagent/providers.py` | URL 校验、模型发现、三协议探测 | `internal/provider` | +| `oneagent/installer.py` | 安装、配置适配、权限、备份、profile、status | `internal/install`、`internal/config`、`internal/securefs`、`internal/profile` | +| `oneagent/server.py` | 静态资源、本地 HTTP API、Cookie/Origin | Wails Assets + Go services;HTTP 层删除 | +| `oneagent/cli.py` | 结构化 CLI | `cmd/oneagent` | +| `oneagent/entrypoint.py`、`scripts/gui.py` | GUI/CLI 分流和源码入口 | `cmd/oneagent-desktop`、`cmd/oneagent` | +| `frontend/src/api/client.ts` | fetch、HTTP 错误归一化 | `frontend/src/backend/wails.ts` | +| `packaging/oneagent.spec` | PyInstaller onedir | Wails v3 Taskfile 与 `build/config.yml` | +| Python 发布脚本 | 构建、校验、notice、manifest、官网数据 | Go release tools + `site/scripts/*.ts` | + +以下是迁移验收契约,不是实现细节: + +- Key 不进入 profile、Agent binding、URL、日志、事件、React reducer、浏览器存储或测试报告。 +- 写入顺序保持为:私有目录 -> 备份 -> 同目录临时文件 -> 权限收紧 -> 原子替换。 +- 密钥备份如果无法收紧权限,必须删除并失败,不能留下宽权限副本。 +- Codex TOML 与 Claude/OpenCode/Kilo JSON 保留非 OneAgent 管理字段;无法无损处理的 JSONC 注释继续明确拒绝,不能静默覆盖。 +- 子进程只使用参数数组和受控环境,禁止 shell 拼接;超时和取消必须终止子进程。 +- npm 镜像必须显式选择,HTTPS、无 URL 凭据,并把 registry 返回的 integrity 与 lock 中的 `sha512-` 值比较。 +- Codex、Claude Code、OpenCode/Kilo/Aider 分别按 Responses、Anthropic、OpenAI-compatible 协议验证,不能用 `/v1/models` 代替真实协议探测。 +- `INVALID_REQUEST`、`PREREQUISITE_MISSING`、`CONFIG_WRITE_FAILED` 等既有错误码和 CLI 退出码保持;`INVALID_ORIGIN` 在 HTTP 删除后保留为已废弃保留值,不复用。 + +## 4. Wails v3 官方约束与本项目决策 + +以下结论核对自 2026-07-29 的 [Wails v3 官方文档](https://v3.wails.io/): + +- Wails v3 仍为 Alpha,当前 changelog 最新条目为 [`v3.0.0-alpha2.118`](https://v3.wails.io/changelog)。官方状态页的下一目标仍是 Beta。 +- 当前安装文档要求 Go 1.25+。 +- service 通过 `application.NewService(...)` 或 `application.NewServiceWithOptions(...)` 注册,只有导出方法进入 binding。 +- `wails3 generate bindings -ts` 生成 TypeScript;`wails3 dev` 在 Go 代码变化时自动重生 binding。 +- JSON tag 控制前端字段名;struct、slice、map、指针和命名常量可生成对应 TypeScript 模型。 +- Go 方法返回的自定义 error 会成为 `Call.RuntimeError`,结构化 JSON 位于 `error.cause`;`ServiceOptions.MarshalError` 可固定序列化格式。 +- 每个 binding 调用运行在独立 goroutine,service 是跨窗口共享的 singleton,状态与写操作必须自行同步。 +- 方法可接收 `context.Context`;前端断开时会自动取消,适合向 `net/http` 和 `exec.CommandContext` 传递。 +- Go 到前端的流式通知使用 `app.Event.Emit`,前端使用 `Events.On`;前端发起请求仍应使用 service binding。 +- 生产资源由 `go:embed` 和 `application.AssetFileServerFS` 内嵌;桌面模式不开放网络端口。 +- `server` build tag 会启用纯 HTTP Server,适合 CI 浏览器测试,但绝不能进入生产构建。 +- Linux 默认栈要求 WebKitGTK 6.0;Ubuntu 22.04 只可走 legacy `gtk3` build tag,默认 GTK4 栈要求 Ubuntu 24.04+。 + +重点参考:[Installation](https://v3.wails.io/quick-start/installation/)、[Method Bindings](https://v3.wails.io/features/bindings/methods/)、[Services](https://v3.wails.io/features/bindings/services/)、[Server Build](https://v3.wails.io/guides/server-build/)、[Status](https://v3.wails.io/status/) 和 [Changelog](https://v3.wails.io/changelog/)。 + +据此作出四项默认决策: + +1. **版本锁定**:第 0 阶段以 `v3.0.0-alpha2.118` 为候选基线,完成三个操作系统、四个原生构建目标的 spike 后,把 Go module、Wails CLI 和 `@wailsio/runtime` 锁到相互兼容的精确版本;源码、CI 和文档均不得使用 `@latest`。 +2. **发布渠道**:Wails 仍为 Alpha 时,OneAgent 只发布 `technical-preview-unsigned`;不得因为应用功能完成而自动提升 Stable。 +3. **Linux 兼容**:迁移期保留 ADR-003 的 Ubuntu 22.04+ 承诺,Linux 正式产物使用 `gtk3` tag,并在 Ubuntu 22.04 cleanroom 验证。改为 GTK4/Ubuntu 24.04+ 必须另立 ADR。 +4. **双入口**:发布 Wails 桌面入口和纯 Go CLI 两个二进制。不要让 headless CLI 链接 Wails/GTK,以免“去 Python”后又引入不必要的 GUI 运行依赖。 + +## 5. 目标架构 + +```text +React 19 + TypeScript + Vite + | + | generated Wails TypeScript bindings + v +StatusService / ProviderService / AgentService / ProfileService + | + v + Go application use cases + | + +------+------+---------+ + | | | + catalog/provider install profile/config + | | | + +------ securefs/process/platform -----+ + +Pure Go CLI --------------------^ (same use cases) + +Astro public site -------------- release manifests/catalog only +``` + +### 5.1 建议目录 + +```text +cmd/ + oneagent-desktop/ Wails application and window setup + oneagent/ headless CLI + oneagent-release/ build, manifest, checksum, notice checks +internal/ + app/ use cases and operation coordinator + binding/ Wails services and transport DTOs only + catalog/ embedded agents.lock.json and provider catalog + provider/ URL validation, model listing, protocol probes + install/ orchestration and locked package installation + config/ per-Agent readers/writers and merge logic + profile/ profile, secret and Agent binding stores + securefs/ backup, mode/ACL and atomic replace + process/ injectable command runner + platform/ HOME, OS, arch, shell and path resolution +frontend/ + bindings/ generated by Wails; never edited manually + src/backend/ thin production adapter and error normalisation +site/scripts/ TypeScript release-index/catalog generators +build/ Wails Taskfiles, config, icons and platform metadata +``` + +`internal/binding` 只做输入 DTO、错误翻译和 use case 调用,不放安装业务。CLI 直接调用 `internal/app`,不能反向调用 Wails service。 + +### 5.2 Service binding 契约 + +| 现有 HTTP 契约 | Wails 方法 | 说明 | +| --- | --- | --- | +| `GET /api/status` | `StatusService.GetStatus(ctx)` | 返回现有 `StatusResponse` | +| `GET /api/profiles` | `ProfileService.ListProfiles(ctx)` | 返回公开摘要,不含 Key | +| `POST /api/profiles` | `ProfileService.SaveProfile(ctx, request)` | 保存模板和可选 secret | +| `POST /api/probe` | `ProviderService.Probe(ctx, request)` | 按所选 Agent 协议探测 | +| `POST /api/models` | `ProviderService.ListModels(ctx, request)` | 返回模型列表与稳定诊断 | +| `POST /api/install` | `AgentService.Install(ctx, request)` | 保持一次调用、最终逐 Agent 结果 | +| `POST /api/agents//activate` | `AgentService.Activate(ctx, request)` | `agentId` 改为 DTO 字段 | +| `POST /api/open-register` | `ProviderService.OpenRegistration(ctx, request)` | Go 端按 Provider ID 解析 URL | + +迁移期保持现有字段名和 null/缺省语义,使用 Go JSON tag 让生成的 TypeScript 与现有 React 契约一致。不要同时做 snake_case/camelCase 清理。`frontend/src/types/api.ts` 中属于后端 DTO 的手写类型最终删除或改成生成类型的薄别名,避免 Go 与 TypeScript 两份真源。 + +### 5.3 错误契约 + +Go 中实现可 JSON 序列化的 `OneAgentError`: + +```go +type OneAgentError struct { + Code string `json:"error_code"` + Message string `json:"message"` + Status int `json:"status"` + Retryable bool `json:"retryable"` + ExitCode int `json:"exit_code"` +} +``` + +- `Error()` 只返回脱敏后的 `Message`。 +- Wails service 用 `MarshalError` 固定 `cause` 的 JSON 结构。 +- 前端 adapter 只在捕获到 `Call.RuntimeError` 时读取 `cause`;参数类型错误、binding 不存在和其他 bridge 故障统一映射为 `INTERNAL_ERROR`。 +- CLI 继续输出现有 JSON shape 和退出码。 +- release 模式不得启用会记录 binding 参数的 Debug 日志;API Key 不得进入 Wails logger、panic、event 或 DevTools。 + +### 5.4 并发、取消与进度 + +当前 `HTTPServer` 是串行的,而 Wails binding 默认并发。Go 端必须新增共享 `OperationCoordinator`: + +- `Install`、`Activate`、`SaveProfile` 和任何状态迁移共用写锁,避免两个调用同时备份或覆盖同一配置。 +- `Status`、`ListProfiles` 等读操作只读取完整文件;原子替换保证不会看到半写状态。 +- Provider 请求和子进程都接收 binding 的 `context.Context`,分别传给 `http.NewRequestWithContext` 与 `exec.CommandContext`。 +- 第一版保持当前同步语义:前端 await 一个 Promise,显示不定进度和最终结果,不伪造百分比。 +- 只有确有逐 Agent 阶段信息时才增加 `oneagent:install:progress` 事件;事件载荷不得包含 Key、完整环境、stdout/stderr 或配置内容,并必须在 React unmount 时调用 unsubscribe。 + +### 5.5 桌面安全边界 + +HTTP 删除后,Cookie、Host 和 Origin 校验也随之删除,但不能简单删掉安全测试: + +- 生产包不得监听 TCP 端口;`server` tag 只允许出现在 `e2e`/CI task。 +- 只注册四个业务 service,不配置 HTTP Route,不启用 Raw Message Handler。 +- 所有 binding 输入仍在 Go 端完整校验,不能信任 React 类型。 +- `OpenRegistration` 只接收 Provider ID,由 Go catalog 解析并校验 `http/https` URL,再调用 `app.Browser.OpenURL`;不允许前端传任意 URL。 +- 保留严格生产 CSP,允许 Wails runtime、自有资源和 data image,禁止远程脚本、远程字体、frame 和 object。开发 CSP 单独允许 Vite HMR。 +- release 构建关闭 DevTools 和 bridge Debug 日志;产物扫描 source map、远程资源、secret 和测试 adapter。 + +## 6. Go 核心迁移设计 + +### 6.1 Runtime 注入 + +把当前 Python `Runtime` 拆成小接口,而不是做一个无边界的全局容器: + +- `Runner`:`Run(ctx, argv, env, timeout)` 和 `LookPath`。 +- `Platform`:OS、arch、HOME、shell、环境变量快照。 +- `FileSecurity`:私有目录、私有文件、ACL/mode、原子替换。 +- `Clock`:UTC 时间与备份时间戳。 +- `HTTPDoer`:Provider 请求,测试可替换为本地 mock transport。 + +测试通过临时目录和 fake runner 覆盖 npm、uv、Windows/macOS/Linux,不修改真实 HOME。 + +### 6.2 securefs + +- Unix 使用 `0700` 目录、`0600` 文件。 +- Windows 使用平台文件实现 ACL;可以继续以参数数组调用系统 `icacls`,或使用 `golang.org/x/sys/windows`,但验收必须检查真实 ACE 只含当前用户和 SYSTEM。 +- 临时文件必须建在目标目录;先写入、flush/close、收紧权限,再替换目标。 +- Windows 的覆盖替换单独实现并测试,不假设 `os.Rename` 与 Python `os.replace` 完全等价。 +- 备份命名、冲突递增、metadata 和 secret 失败清理保持现状。 + +### 6.3 配置适配器 + +- 继续按 `config_adapter` 注册适配器,不按 Agent ID 散落分支。 +- Codex TOML 使用 Go TOML parser 做语法验证,沿用“只替换顶层 `model/model_provider` 与 OneAgent table”的保留式合并。 +- Claude/OpenCode/Kilo 使用 `encoding/json` 合并对象;对带注释 JSONC 继续 fail closed,除非另一个独立变更先证明可无损保留注释。 +- Aider env/PowerShell quoting、Agent 独立 env、native env 和 shared legacy env 保持 golden fixture 等价。 +- profile v1 -> v2 migration、路径校验、ID 正则和 Agent binding schema 保持可回滚兼容。 + +### 6.4 安装与 Provider + +- 使用 `exec.CommandContext`,禁止 `cmd /c`、`sh -c` 和字符串拼接。 +- 保留锁定版本、`latest` 仅显式生效、npm mirror integrity 校验和最多 600 字符的脱敏失败摘要。 +- Go `net/http` 设置总 timeout、响应体上限和 context;不关闭 TLS 校验,不自动代理或切换镜像。 +- 三种协议请求、状态码分类、`PROTOCOL_UNSUPPORTED` 判定和模型 fallback 用当前测试样例固化。 +- 内置 Provider 与公开字段继续显式投影,避免内部 fallback model 泄漏到前端。 + +## 7. 分阶段实施 + +### 阶段 0:ADR、版本锁定与四个原生构建目标 spike + +交付: + +- 新 ADR 只替换 ADR-003 中“Python 核心、本地 HTTP、PyInstaller”的决策,其余产品边界继续有效。 +- 创建最小 Wails v3 React 项目,验证 `application.NewService`、`generate bindings -ts`、自定义 error `cause`、`context.Context` 取消、`go:embed` 和 `Browser.OpenURL`。 +- 在 Windows x64、macOS arm64/x64、Ubuntu 22.04 `gtk3` 各构建并启动一次。 +- 锁定 Go、Wails module、Wails CLI、`@wailsio/runtime` 和 Taskfile 版本。 + +退出门禁:四个原生构建目标 spike 全绿;确认 Alpha 版本可接受;确认生成 binding 的实际 import 形态和错误序列化;确认 Ubuntu 22.04 路径。 + +### 阶段 1:Go 骨架与 catalog + +交付: + +- 建立 `go.mod`、`cmd/`、`internal/`、Wails `Taskfile.yml` 和 `build/config.yml`。 +- 嵌入并校验 `agents.lock.json`,移植 catalog、Provider 公开投影、平台/HOME 解析和错误码。 +- 建立纯 Go CLI 骨架与 Wails 空壳,尚不切换生产入口。 + +退出门禁:catalog/status fixture 与 Python 输出等价;Go 测试可在无 Python 环境运行。 + +### 阶段 2:Provider、securefs、profile 与配置适配器 + +交付: + +- 移植 URL 校验、模型发现和三协议 probe。 +- 移植原子写、备份、Unix mode、Windows ACL、profile、secret store、Agent binding store。 +- 移植五个配置 adapter 和配置发现读取器。 + +退出门禁:每个 adapter 的新文件、合并、损坏输入、备份和权限 fixture 等价;Windows 真实 ACL 与 Unix mode 测试通过;fuzz 不产生路径逃逸或 secret 泄漏。 + +### 阶段 3:安装编排与 CLI + +交付: + +- 移植 prerequisite、版本解析、npm/uv 安装、integrity、registry、日志脱敏、`install_many` 与单 Agent activate。 +- 完成 `cmd/oneagent`,兼容现有 flags、`agent list/set`、JSON 输出和退出码。 +- `scripts/install.sh`/`.ps1` 暂时保留为纯转发兼容层,改为调用 Go CLI,不再定位 Python;一个发行周期后再评估删除。 + +退出门禁:fake npm/uv 契约、真实锁定 Agent 安装、CLI 快照和取消/超时全部通过;Aider 边界已按第 1 节落地。 + +### 阶段 4:Wails service 与 React binding 切换 + +交付: + +- 注册 `StatusService`、`ProviderService`、`AgentService`、`ProfileService`,共享同一 use case 和协调锁。 +- 生成并提交 `frontend/bindings/`;生成物禁止手改,CI 重生后执行 diff 检查。 +- 新增 `frontend/src/backend/wails.ts`,页面和 `WizardContext` 经此薄 adapter 调用生成 binding。 +- 使用 `Call.RuntimeError.cause` 恢复稳定错误码;删除 fetch、HTTP status 和 Cookie 语义。 +- `HashRouter` 保留,Wails window 加载 `http://wails.localhost/` 内嵌资源。 + +退出门禁:生产前端没有 `/api/` 或 fetch;TypeScript 不再手写后端 DTO;七页流程、总览和 Agent 详情行为不变;生产进程无监听端口。 + +### 阶段 5:测试链路切换 + +交付: + +- Python unit/contract tests 逐项转为 Go table tests、golden tests、fuzz tests 和平台 integration tests。 +- Vitest mock `src/backend`,不直接伪造 HTTP。 +- Playwright 使用 Wails 官方 `server` build tag + `e2e` fake runner 在 localhost 运行完整 binding 流程;生产 task 明确禁止 `server` tag。 +- 每个平台增加打包后原生 smoke:临时 HOME、启动 window、调用一次真实 `GetStatus` binding、验证资源和退出。 + +退出门禁:旧测试表达的行为都有新测试归属;不是简单删除 Cookie/Origin 测试,而是由“无生产监听端口 + binding allowlist + 外链白名单”替代。 + +### 阶段 6:构建、发布与官网工具去 Python + +交付: + +- Wails Taskfile 取代 PyInstaller spec;`go:embed` 取代 resource staging。 +- `cmd/oneagent-release` 取代 build/check/notices/lock verification Python 脚本。 +- `site/scripts/build-release-index.ts` 和 `build-site-catalog.ts` 取代两个 Python 生成器;Playwright 用 Astro preview,不再用 `python -m http.server`。 +- release manifest schema 升级,删除 `python` 字段,增加精确 `go`、`wails`、`frontend` 和 system WebView 要求。 +- 第三方 notice 同时覆盖 Go modules、Wails、npm 前端依赖和锁定 Agent 元数据。 + +退出门禁:官网、manifest、SHA-256、notice、签名检查和渠道一致性测试全部由 Go/Node 完成;产物不含 Python runtime、`.py`、wheel 或 PyInstaller 文件。 + +### 阶段 7:最终切换与清理 + +交付: + +- 删除 `oneagent/*.py`、`setup.py`、`pyproject.toml`、`packaging/oneagent.spec`、全部 Python scripts/tests 和 wheel 流程。 +- 更新 README、CLAUDE.md、ADR、安装文档、AI Agent Kit、官网下载字段和 CI。 +- 删除本地 HTTP 端口、Cookie/Origin、PyInstaller 和 Python 3.12 的当前态说明;历史 ADR 保留但标记 Superseded。 +- 发布第一版 Wails `technical-preview-unsigned`,执行真实 Agent + Provider RC。 + +退出门禁:第 11 节全部满足;Python 旧实现不再参与任何 build/test/release/runtime 路径。 + +## 8. 测试与质量门禁 + +| 层级 | 门禁 | +| --- | --- | +| Go 单元 | `go test ./...`;整体语句覆盖率 >= 85% | +| 安全关键包 | `securefs/config/install` 100% 语句覆盖 + 明确错误分支矩阵;Go 无原生 branch coverage,不能只用一个百分比替代旧分支门禁 | +| 并发 | `go test -race ./...`;并发 Install/Activate/Status 用例 | +| Fuzz | Agent/profile ID、URL、registry、TOML 合并、JSON/JSONC、version output、path traversal | +| Binding | `wails3 generate bindings -ts` 后无 diff;TypeScript build;暴露方法 allowlist | +| React | Vitest 覆盖 `src/backend` 与 `src/state` >= 85%;Key 不进入 reducer/storage/DOM 回显 | +| Browser E2E | `server,e2e` build tag,fake runner + 临时 HOME,覆盖完整向导、重试、按 Agent 配置和错误 cause | +| 原生 smoke | Windows、macOS arm64/x64、Ubuntu 22.04 `gtk3` 启动打包产物并完成至少一次 binding 调用 | +| Cleanroom | PATH 中无 Python,完成 catalog、status、CLI、桌面启动和非 Aider 安装契约 | +| Release Candidate | 五个锁定 Agent 真实安装;PPIO/Novita 三协议和真实 Agent 首次请求 | +| 静态检查 | `go vet`、`staticcheck`、`govulncheck`、npm audit 策略、secret/source-map/remote-asset scan | + +测试迁移使用“冻结 fixture -> Go 等价 -> 删除 Python”的顺序。禁止在同一个真实 HOME 上让 Python 和 Go 双写做对比;所有差分测试只操作独立临时目录。 + +## 9. 发布与兼容策略 + +### 9.1 产物 + +- macOS:Wails `.app`,按现有渠道压缩发布;arm64/x64 分别原生验证,是否追加 universal 包另行决定。 +- Windows:Wails `.exe` 桌面应用 + 纯 Go `oneagent.exe` CLI,先维持 ZIP 技术预览;NSIS/MSIX 不混入本次迁移。 +- Linux:`gtk3` Wails 桌面二进制 + 纯 Go CLI,明确系统 GTK3/WebKitGTK 4.1 依赖;先维持归档包。 +- Source ZIP 可保留;wheel/PyPI 渠道删除。 + +每个产物仍需 release manifest、SHA-256、Agent 版本清单、第三方 notices 和原生 cleanroom 证据。同一版本跨渠道保持字节一致,不由网盘或官网重新打包。 + +### 9.2 数据兼容 + +- 保持 `~/.oneagent`、Agent 配置路径、profile schema v2、secret 文件和 backup 命名可读。 +- Go 首次启动只执行已有 v1 -> v2 profile migration,不新增不可逆 schema migration。 +- 回滚到最后一个 Python technical preview 时,旧版本仍可读取 Go 写出的状态;这是切换前必须验证的双向兼容门禁。 + +### 9.3 回滚 + +- 阶段 0-6 均以独立 PR 合入,生产入口仍指向最后一个已验收实现。 +- 阶段 7 切换前打迁移前 release tag,并保存四个原生构建目标的产物与 manifest。 +- 切换后若出现阻断问题,回滚发布版本而不是在新包中临时恢复 Python fallback。 +- 用户配置格式不变,回滚不应要求手工删除 `~/.oneagent`。 + +## 10. Python 文件清理映射 + +| 待删除 | 替代 | +| --- | --- | +| `oneagent/*.py` | `internal/*` + `cmd/oneagent*` | +| `scripts/gui.py` | `wails3 dev` / Wails desktop binary | +| `scripts/build_release.py`、`check_release.py`、`verify_locked_agents.py` | `cmd/oneagent-release` 子命令 | +| `packaging/generate_notices.py` | Go module/npm/Agent notice 生成器 | +| `scripts/build_release_index.py`、`build_site_catalog.py` | `site/scripts/*.ts` | +| `scripts/provider_rc_smoke.py`、`agent_e2e_smoke.py`、`agent_config_adopted_check.py` | Go integration commands/tests | +| `scripts/stage_resources.py` | `go:embed` | +| `scripts/verify_wheel.py`、`setup.py`、`pyproject.toml`、`.spec` | Wails build/package/smoke | +| `tests/*.py` 和 shell 中 inline Python | Go tests/helpers;shell 只保留最薄入口 smoke | +| Docker/CI 的 setup-python、coverage、PyInstaller | Go 1.25、Wails、Node 和平台 WebView build dependencies | + +本地被 `.gitignore` 忽略的 `.venv/` 不属于迁移提交,不自动删除用户环境;验收针对受版本控制文件和构建路径。 + +## 11. 最终验收清单 + +### 11.1 去 Python + +- `git ls-files '*.py'` 为空。 +- active workflow、Taskfile、package script、Dockerfile 和当前 README 命令不调用 `python`、pip、wheel 或 PyInstaller。 +- 从 PATH 移除 Python 后,Go 测试、前端测试、官网 build、Wails build、CLI 和桌面 smoke 通过。 +- 发行包扫描不到 Python runtime、stdlib、`.pyc`、wheel metadata 或 PyInstaller bootloader。 +- Aider 的 Python 要求只在选择 Aider 安装时作为外部 prerequisite 出现。 + +### 11.2 Binding 与安全 + +- `frontend/src` 不含 `fetch(`/XHR/`/api/` 业务调用。 +- 生产二进制不使用 `server` tag,不监听 TCP 端口。 +- 生成 binding 是后端 DTO 的唯一 TypeScript 真源,CI 可复现且无 diff。 +- 自定义错误在前端保留 `error_code/status/retryable`;CLI 退出码不变。 +- 并发写不会丢配置、覆盖备份或混用 Agent Key。 +- API Key 不出现在日志、event、panic、DevTools、profile、Agent binding、URL 或测试附件。 +- 外链只能由 Go 端 catalog 白名单解析并通过系统浏览器打开。 + +### 11.3 行为与发布 + +- 五个 auto Agent 的检测、锁定安装、配置发现、配置写入、重启提示和 per-Agent Provider 行为等价。 +- guide-only Agent 不安装、不写配置、不启动服务。 +- profile v1/v2、现有用户字段、备份与权限兼容。 +- Windows、macOS、Linux 原生构建和 cleanroom 通过;Linux 产物与声明的 GTK/WebKit 最低版本一致。 +- 官网 release index、manifest、SHA-256、notice、签名与跨渠道一致性门禁通过。 +- Wails 仍为 Alpha 时只允许 technical preview;Stable 仍需 macOS 签名/公证和 Windows Authenticode 的真实产物证据。 + +## 12. 开工前必须确认的四项决策 + +建议直接采用下列默认值,避免实现中途重新切边界: + +1. **Aider**:允许其安装流程拥有外部 Python 3.12 prerequisite;OneAgent 自身仍为零 Python。若不接受,则 Aider install 降为 guide-only。 +2. **Linux**:保持 Ubuntu 22.04+,正式构建固定 `gtk3` tag;未来升级 GTK4 另立 ADR。 +3. **CLI**:发布独立纯 Go CLI,与 Wails 桌面应用同包但不同二进制;兼容 wrapper 保留一个周期。 +4. **Wails Alpha**:候选锁定 `v3.0.0-alpha2.118`,只进入 technical preview;每次升级 Wails 必须重跑 binding diff、四个原生构建目标的 native smoke 和 release scan。 + +四项确认后按阶段 0 -> 7 推进。任何阶段只有在自己的退出门禁全绿后才能删除对应 Python 实现;阶段 7 才允许宣告迁移完成。 From 8819fbdf3daa4af0e8f7bed76e9acebf63340565 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:03:42 +0800 Subject: [PATCH 02/63] chore: add Go and Wails migration foundation --- .gitignore | 3 +- CLAUDE.md | 5 + README.md | 4 + Taskfile.yml | 43 +++ build/Taskfile.yml | 12 + build/config.yml | 35 +++ build/tool-versions.env | 8 + cmd/oneagent-desktop/main.go | 12 + cmd/oneagent-desktop/main_wails.go | 56 ++++ cmd/oneagent/main.go | 162 ++++++++++++ .../ADR-007-wails-v3-go-migration.md | 47 ++++ docs/wails-v3-migration-plan.md | 6 +- frontend/dist/.keep | 0 go.mod | 5 + go.sum | 2 + internal/app/status.go | 250 ++++++++++++++++++ internal/app/status_test.go | 83 ++++++ internal/binding/services.go | 226 ++++++++++++++++ internal/catalog/catalog_test.go | 68 +++++ internal/catalog/manifest.go | 168 ++++++++++++ internal/catalog/public.go | 141 ++++++++++ internal/catalog/types.go | 71 +++++ internal/errors/errors.go | 157 +++++++++++ internal/errors/errors_test.go | 42 +++ internal/platform/platform.go | 103 ++++++++ internal/platform/platform_test.go | 47 ++++ internal/version/version.go | 5 + manifest_embed.go | 23 ++ 28 files changed, 1782 insertions(+), 2 deletions(-) create mode 100644 Taskfile.yml create mode 100644 build/Taskfile.yml create mode 100644 build/config.yml create mode 100644 build/tool-versions.env create mode 100644 cmd/oneagent-desktop/main.go create mode 100644 cmd/oneagent-desktop/main_wails.go create mode 100644 cmd/oneagent/main.go create mode 100644 docs/decisions/ADR-007-wails-v3-go-migration.md create mode 100644 frontend/dist/.keep create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/status.go create mode 100644 internal/app/status_test.go create mode 100644 internal/binding/services.go create mode 100644 internal/catalog/catalog_test.go create mode 100644 internal/catalog/manifest.go create mode 100644 internal/catalog/public.go create mode 100644 internal/catalog/types.go create mode 100644 internal/errors/errors.go create mode 100644 internal/errors/errors_test.go create mode 100644 internal/platform/platform.go create mode 100644 internal/platform/platform_test.go create mode 100644 internal/version/version.go create mode 100644 manifest_embed.go diff --git a/.gitignore b/.gitignore index 90350cc5..3e0675c8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,8 @@ build/ dist/ release/ frontend/node_modules/ -frontend/dist/ +frontend/dist/* +!frontend/dist/.keep frontend/coverage/ frontend/test-results/ frontend/playwright-report/ diff --git a/CLAUDE.md b/CLAUDE.md index fd5f9628..8a0f2b4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,11 @@ OneAgent:本地 AI 开发环境激活器。Python 3.12 标准库内核 + React 当前 `0.2.0-dev`,发行渠道只能标记 `technical-preview-unsigned`。功能说明与发行流程见 [README.md](README.md)。 +Wails v3 迁移已在当前分支开始,但尚未切换生产入口。阶段 0/1 的 Go 骨架位于 +`internal/`、`cmd/oneagent` 和 `cmd/oneagent-desktop`;默认桌面命令只有在显式使用 +`wails` build tag 时才链接 Wails。Python 核心、HTTP GUI、Python 测试和发布流程在 +迁移完成前继续保留并作为当前生产路径。 + ## 沟通语言 **回复一律使用简体中文,没有例外。** 包括分析、结论、计划、代码审查意见和确认提问;用户用英文提问也不切换。代码、标识符、提交信息和代码注释保持英文(与现有代码库一致);README、`docs/` 和 ADR 保持中文。 diff --git a/README.md b/README.md index 9e5d3528..83b304c2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ OneAgent 不重新分发 Agent 二进制,不捆绑 Node.js、Python、Git Bash 当前版本为 `0.2.0-dev`,当前发行目标是可直接下载运行的 `technical-preview-unsigned` 二进制包。不以四平台同时分发作为产品阶段门槛;每个实际发布的平台仍须在对应操作系统原生构建,并以 CI cleanroom 作业作为验收证据。各平台最低目标见 [ADR-003](docs/decisions/ADR-003-three-platform-python-core-and-release-policy.md)。 +Wails v3 迁移已在当前分支开始,当前仍处于阶段 0/1:Go catalog、错误/平台基础包、 +纯 Go CLI 和带 `wails` 标签的桌面空壳已建立,但 Python 核心和本地 HTTP GUI 仍是 +生产路径。迁移完成前不会删除或旁路任何 Python 源码、测试、脚本或打包流程。 + 发行渠道不限定为 GitHub。官网、GitHub Release、网盘和企业云盘可以作为同一官方构建的镜像,但同一版本必须保持文件内容和 SHA-256 一致,渠道方不得重新打包或加入渠道专属内容。每个产物只声明实际构建和验证过的目标环境。 仍未取得证据的部分: diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 00000000..0f810c48 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,43 @@ +version: '3' + +vars: + WAILS_MODULE: github.com/wailsapp/wails/v3 + WAILS_VERSION: v3.0.0-alpha2.119 + +tasks: + test:go: + summary: Run the Python-independent Go migration tests + cmds: + - go test ./... + + build:cli: + summary: Build the headless Go CLI + cmds: + - mkdir -p bin + - go build -o bin/oneagent ./cmd/oneagent + + generate:bindings: + summary: Regenerate Wails TypeScript bindings + cmds: + - go run {{.WAILS_MODULE}}/cmd/wails3@{{.WAILS_VERSION}} generate bindings -f wails -ts -d frontend/bindings ./cmd/oneagent-desktop + + build:frontend: + summary: Build the existing React frontend for the migration shell + dir: frontend + cmds: + - npm ci + - npm run build + + build:desktop: + summary: Build the opt-in Wails desktop shell + deps: + - build:frontend + - generate:bindings + cmds: + - mkdir -p bin + - go run {{.WAILS_MODULE}}/cmd/wails3@{{.WAILS_VERSION}} build -tags wails + + dev:desktop: + summary: Run the Wails development shell + cmds: + - go run {{.WAILS_MODULE}}/cmd/wails3@{{.WAILS_VERSION}} dev -config build/config.yml diff --git a/build/Taskfile.yml b/build/Taskfile.yml new file mode 100644 index 00000000..c0dedcf1 --- /dev/null +++ b/build/Taskfile.yml @@ -0,0 +1,12 @@ +version: '3' + +tasks: + test:go: + summary: Run the Go migration tests + cmds: + - go test ./... + + generate:bindings: + summary: Generate bindings using the pinned Wails version + cmds: + - go run github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.119 generate bindings -f wails -ts -d frontend/bindings ./cmd/oneagent-desktop diff --git a/build/config.yml b/build/config.yml new file mode 100644 index 00000000..f0478df1 --- /dev/null +++ b/build/config.yml @@ -0,0 +1,35 @@ +version: '3' + +info: + companyName: "MaimoryLab" + productName: "OneAgent" + productIdentifier: "com.maimorylab.oneagent" + description: "Local AI development environment activator" + copyright: "(c) 2026 MaimoryLab" + version: "0.3.0-dev" + +dev_mode: + root_path: . + log_level: warn + debounce: 500 + ignore: + dir: + - .git + - frontend/node_modules + - frontend/dist + - bin + file: + - .DS_Store + - .gitkeep + - .keep + - "*_test.go" + watched_extension: + - "*.go" + git_ignore: true + executes: + - cmd: task build:frontend + type: blocking + - cmd: task generate:bindings + type: blocking + - cmd: go run -tags wails ./cmd/oneagent-desktop + type: primary diff --git a/build/tool-versions.env b/build/tool-versions.env new file mode 100644 index 00000000..341dead8 --- /dev/null +++ b/build/tool-versions.env @@ -0,0 +1,8 @@ +# Pinned migration toolchain. Update these values only after the native spike +# and binding-diff checks have been rerun on all four supported targets. +GO_VERSION=1.25 +NODE_VERSION=22 +WAILS_VERSION=v3.0.0-alpha2.119 +WAILS_CLI_VERSION=v3.0.0-alpha2.119 +WAILS_RUNTIME_VERSION=3.0.0-alpha2.117 +TASK_VERSION=v3.40.1-patched3 diff --git a/cmd/oneagent-desktop/main.go b/cmd/oneagent-desktop/main.go new file mode 100644 index 00000000..35a7ff8b --- /dev/null +++ b/cmd/oneagent-desktop/main.go @@ -0,0 +1,12 @@ +//go:build !wails + +package main + +import "fmt" + +func main() { + // The default build is intentionally headless and dependency-free. Native + // Wails builds opt into the wails tag so Go tests and the CLI do not link a + // platform WebView by accident. + fmt.Println("OneAgent desktop shell: build with -tags wails") +} diff --git a/cmd/oneagent-desktop/main_wails.go b/cmd/oneagent-desktop/main_wails.go new file mode 100644 index 00000000..16b155c0 --- /dev/null +++ b/cmd/oneagent-desktop/main_wails.go @@ -0,0 +1,56 @@ +//go:build wails + +package main + +import ( + "log/slog" + "os" + + oneagent "github.com/MaimoryLab/OneAgent" + "github.com/MaimoryLab/OneAgent/internal/app" + "github.com/MaimoryLab/OneAgent/internal/binding" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/wailsapp/wails/v3/pkg/application" +) + +func main() { + core := app.NewUseCasesFromEnvironment() + services := binding.NewServices(core, func(url string) error { + current := application.Get() + if current == nil || current.Browser == nil { + return oneerrors.New(oneerrors.InternalError, "Desktop browser is not ready") + } + return current.Browser.OpenURL(url) + }) + + // No Route or RawMessageHandler is configured. The default Wails transport + // is internal IPC; the production app does not expose a business HTTP port. + appInstance := application.New(application.Options{ + Name: "OneAgent", + Description: "Local AI development environment activator", + LogLevel: slog.LevelInfo, + Services: []application.Service{ + application.NewServiceWithOptions(services.Status, application.ServiceOptions{MarshalError: oneerrors.Marshal}), + application.NewServiceWithOptions(services.Provider, application.ServiceOptions{MarshalError: oneerrors.Marshal}), + application.NewServiceWithOptions(services.Agent, application.ServiceOptions{MarshalError: oneerrors.Marshal}), + application.NewServiceWithOptions(services.Profile, application.ServiceOptions{MarshalError: oneerrors.Marshal}), + }, + MarshalError: oneerrors.Marshal, + Assets: application.AssetOptions{ + Handler: application.AssetFileServerFS(oneagent.FrontendAssets), + DisableLogging: true, + }, + Mac: application.MacOptions{ApplicationShouldTerminateAfterLastWindowClosed: true}, + }) + appInstance.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "OneAgent", + Width: 1180, + Height: 760, + URL: "/", + }) + if err := appInstance.Run(); err != nil { + // Do not print an arbitrary Wails error containing binding arguments. + _, _ = os.Stderr.WriteString("OneAgent desktop failed to start\n") + os.Exit(1) + } +} diff --git a/cmd/oneagent/main.go b/cmd/oneagent/main.go new file mode 100644 index 00000000..3f2ee2f2 --- /dev/null +++ b/cmd/oneagent/main.go @@ -0,0 +1,162 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/MaimoryLab/OneAgent/internal/app" + "github.com/MaimoryLab/OneAgent/internal/catalog" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/version" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + printUsage(stdout) + return 0 + } + if args[0] == "--version" || args[0] == "version" { + _, _ = fmt.Fprintln(stdout, version.Version) + return 0 + } + switch args[0] { + case "status": + return runStatus(args[1:], stdout, stderr) + case "agent": + return runAgent(args[1:], stdout, stderr) + default: + // Keep the common check-only compatibility flags available while the + // Python wrapper still points at its existing implementation. This Go + // command is safe to opt into and never installs or writes credentials. + return runCompatibilityFlags(args, stdout, stderr) + } +} + +func runStatus(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("status", flag.ContinueOnError) + flags.SetOutput(stderr) + jsonOutput := flags.Bool("json", false, "write JSON") + home := flags.String("home", "", "override the home directory") + if err := flags.Parse(args); err != nil { + return oneerrors.ExitCodes[oneerrors.InvalidRequest] + } + info := platform.Current() + core := app.NewUseCases(app.StatusOptions{Home: *home, Platform: info}) + status, err := core.GetStatus(flagsContext()) + if err != nil { + return writeError(stdout, err, *jsonOutput) + } + return writeValue(stdout, status, *jsonOutput) +} + +func runAgent(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 || args[0] == "list" { + flags := flag.NewFlagSet("agent list", flag.ContinueOnError) + flags.SetOutput(stderr) + jsonOutput := flags.Bool("json", false, "write JSON") + remaining := args + if len(args) > 0 && args[0] == "list" { + remaining = args[1:] + } + if err := flags.Parse(remaining); err != nil { + return oneerrors.ExitCodes[oneerrors.InvalidRequest] + } + manifest, err := catalog.LoadEmbedded() + if err != nil { + return writeError(stdout, err, *jsonOutput) + } + items := catalog.PublicCatalog(manifest, platform.Current().OS) + if *jsonOutput { + return writeJSON(stdout, map[string]any{"agents": items}) + } + for _, item := range items { + _, _ = fmt.Fprintf(stdout, "%s\t%s\n", item.ID, item.Name) + } + return 0 + } + return writeError(stdout, oneerrors.New(oneerrors.InvalidRequest, "Unknown agent command"), false) +} + +func runCompatibilityFlags(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("oneagent", flag.ContinueOnError) + flags.SetOutput(stderr) + agentID := flags.String("agent", "", "Agent ID") + checkOnly := flags.Bool("check-agent-only", false, "only inspect the Agent") + jsonOutput := flags.Bool("json", false, "write JSON") + home := flags.String("home", "", "override the home directory") + if err := flags.Parse(args); err != nil { + return oneerrors.ExitCodes[oneerrors.InvalidRequest] + } + if !*checkOnly { + return writeError(stdout, oneerrors.New(oneerrors.InvalidRequest, "The migration CLI currently supports status and check-agent-only only"), *jsonOutput) + } + manifest, err := catalog.LoadEmbedded() + if err != nil { + return writeError(stdout, err, *jsonOutput) + } + if *agentID == "" { + return writeError(stdout, oneerrors.New(oneerrors.InvalidRequest, "--agent is required with --check-agent-only"), *jsonOutput) + } + agent, ok := manifest.Agents[*agentID] + if !ok { + return writeError(stdout, oneerrors.New(oneerrors.InvalidRequest, "Unknown Agent: "+*agentID), *jsonOutput) + } + _, installed := app.NewUseCases(app.StatusOptions{Home: *home, Platform: platform.Current()}).LookupForCLI(agent.Command) + payload := map[string]any{ + "ok": true, + "agent": *agentID, + "installed": installed, + "guideOnly": agent.ConfigMode == "guide", + } + if *jsonOutput { + return writeJSON(stdout, payload) + } + _, _ = fmt.Fprintf(stdout, "%s: %t\n", agent.Name, installed) + return 0 +} + +func writeValue(stdout io.Writer, value any, jsonOutput bool) int { + if jsonOutput { + return writeJSON(stdout, value) + } + _, _ = fmt.Fprintln(stdout, "OneAgent status is available with --json") + return 0 +} + +func writeJSON(stdout io.Writer, value any) int { + encoder := json.NewEncoder(stdout) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return oneerrors.ExitCodes[oneerrors.InternalError] + } + return 0 +} + +func writeError(stdout io.Writer, err error, jsonOutput bool) int { + oneErr := oneerrors.As(err) + if jsonOutput { + _ = writeJSON(stdout, oneErr.APIShape()) + } else { + _, _ = fmt.Fprintln(stdout, oneErr.Message) + } + return oneErr.ExitCode +} + +func printUsage(stdout io.Writer) { + _, _ = fmt.Fprintln(stdout, "OneAgent Go migration CLI") + _, _ = fmt.Fprintln(stdout, "Usage: oneagent status [--json] | oneagent agent list [--json]") + _, _ = fmt.Fprintln(stdout, " oneagent --agent --check-agent-only [--json]") +} + +// flagsContext is kept as a function so future CLI cancellation wiring can be +// added without changing command handlers. +func flagsContext() context.Context { return context.Background() } diff --git a/docs/decisions/ADR-007-wails-v3-go-migration.md b/docs/decisions/ADR-007-wails-v3-go-migration.md new file mode 100644 index 00000000..5db15d7e --- /dev/null +++ b/docs/decisions/ADR-007-wails-v3-go-migration.md @@ -0,0 +1,47 @@ +# ADR-007: Wails v3 Desktop Shell and Go Core Migration + +- Status: Accepted for staged implementation +- Date: 2026-07-30 +- Supersedes: the Python-core, localhost-HTTP and PyInstaller decisions in ADR-003 only + +## Context + +The migration plan calls for a Wails v3 desktop shell, a transport-independent +Go core, and a separate headless CLI. Wails v3 is still Alpha, so the first +increment must be reversible and must not change the shipped Python entry +points before the Go behavior contracts are complete. + +## Decision + +1. The migration line uses Go 1.25+ and pins Wails v3 to + `v3.0.0-alpha2.119` for the initial native spike. The matching CLI uses the + same module version. The browser runtime candidate is pinned separately to + `3.0.0-alpha2.117`, the version shipped by that Wails module; it will be + added to the frontend lockfile when bindings enter the production bundle. +2. `agents.lock.json` remains the only hand-edited Agent catalog source. The + root Go package embeds that file once, and `internal/catalog` parses the + embedded bytes for both the CLI and desktop shell. +3. `internal/app`, `internal/catalog`, `internal/platform`, + `internal/errors`, and `internal/binding` are introduced before any + production entry-point switch. The default desktop command is a dependency- + free stub; the native Wails implementation is enabled explicitly with the + `wails` build tag. +4. The first Wails shell registers only `StatusService`, `ProviderService`, + `AgentService`, and `ProfileService`. It does not configure `Route` or + `RawMessageHandler`, and it does not open a business HTTP listener. +5. All Python source, tests, packaging metadata, wrappers, and CI paths remain + intact until the phase-specific Go replacement has equivalent fixtures and + its exit gate is green. This ADR does not authorize deleting or bypassing + the Python implementation. + +## Consequences + +- The Go status/catalog path can be tested in an environment with no Python. +- Native Wails builds currently require the platform WebView toolchain and a + generated frontend bundle; the default `go test ./...` does not link either. +- The Go service methods that have not reached behavioral parity return a + stable not-ready error instead of silently falling back to Python. The + production UI continues to use the existing Python HTTP path during this + stage, so users do not encounter that error yet. +- Updating Wails or the runtime requires rerunning binding generation and the + four-target native spike before changing the pins. diff --git a/docs/wails-v3-migration-plan.md b/docs/wails-v3-migration-plan.md index 5363d587..e3b33372 100644 --- a/docs/wails-v3-migration-plan.md +++ b/docs/wails-v3-migration-plan.md @@ -1,10 +1,14 @@ # OneAgent 全量迁移至 Wails v3 规划方案 -- 状态:Proposed +- 状态:In Progress(阶段 0/1 骨架已开始,生产入口尚未切换) - 日期:2026-07-29 - 目标版本:下一主版本(建议 `0.3.0-dev` 开始迁移) - 适用范围:桌面应用、Go 核心、CLI、前端通信、测试、构建、发布和公开站数据生成 +当前进度:已建立 Go module、嵌入式 Agent catalog、稳定错误/平台类型、纯 Go CLI +和带 `wails` 构建标签的桌面空壳。现有 Python 核心、HTTP GUI、包装脚本、测试和 +发布流程仍是当前生产路径;在阶段 2-6 的行为等价门禁通过前不得删除或旁路它们。 + ## 1. 结论 OneAgent 应迁移为“Wails v3 桌面壳 + Go 领域核心 + React/Vite 前端 + 独立 Go CLI”的结构。生产桌面应用不再启动 localhost HTTP Server,React 只通过 Wails 自动生成的 TypeScript binding 调用已注册 Go service;CLI 与桌面 service 共用同一套 Go use case,不复制安装、配置或安全逻辑。 diff --git a/frontend/dist/.keep b/frontend/dist/.keep new file mode 100644 index 00000000..e69de29b diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..34f969d7 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/MaimoryLab/OneAgent + +go 1.25.0 + +require github.com/wailsapp/wails/v3 v3.0.0-alpha2.119 diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..fa35dd3a --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/wailsapp/wails/v3 v3.0.0-alpha2.119 h1:+y9H2QxqxSiaq3i4h3e77k1/nFW0uGhcWWl1bz/DFFg= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.119/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= diff --git a/internal/app/status.go b/internal/app/status.go new file mode 100644 index 00000000..25f766d6 --- /dev/null +++ b/internal/app/status.go @@ -0,0 +1,250 @@ +// Package app contains transport-independent use cases. Only the small status +// slice is moved in this first migration increment; installation and config +// writes remain in Python until their Go equivalents pass their own gates. +package app + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/MaimoryLab/OneAgent/internal/catalog" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/platform" +) + +type CommandLookup func(string) (string, bool) + +type StatusOptions struct { + Home string + Platform platform.Info + Lookup CommandLookup +} + +type UseCases struct { + status StatusOptions +} + +func NewUseCases(options StatusOptions) *UseCases { + if options.Platform.OS == "" { + options.Platform = platform.Current() + } + if options.Home == "" { + options.Home = platform.ResolveHome(nil, options.Platform.OS) + } + if options.Lookup == nil { + options.Lookup = defaultLookup + } + return &UseCases{status: options} +} + +func NewUseCasesFromEnvironment() *UseCases { + info := platform.Current() + return NewUseCases(StatusOptions{ + Home: platform.ResolveHome(nil, info.OS), + Platform: info, + }) +} + +// LookupForCLI exposes only command presence to the compatibility command. It +// does not expose the injected resolver or any environment values. +func (u *UseCases) LookupForCLI(command string) (string, bool) { + if u == nil || u.status.Lookup == nil { + return "", false + } + return u.status.Lookup(command) +} + +func defaultLookup(command string) (string, bool) { + path, err := exec.LookPath(command) + return path, err == nil +} + +type StatusResponse struct { + APIVersion int `json:"apiVersion"` + Platform platform.Info `json:"platform"` + Capabilities Capabilities `json:"capabilities"` + Agents map[string]AgentStatus `json:"agents"` + Catalog []catalog.CatalogItem `json:"catalog"` + Groups []catalog.Group `json:"groups"` + Providers map[string]catalog.Provider `json:"providers"` + Mirrors []catalog.Mirror `json:"mirrors"` + Paths map[string]string `json:"paths"` + Backups map[string]bool `json:"backups"` + Profiles []ProfileSummary `json:"profiles"` + ActiveProfile *string `json:"activeProfile"` + Environment any `json:"environment"` + EnvironmentError *string `json:"environmentError"` +} + +type Capabilities struct { + CanInstall map[string]bool `json:"canInstall"` + SupportedAgentIDs []string `json:"supportedAgentIds"` +} + +type AgentStatus struct { + Installed bool `json:"installed"` + Configured bool `json:"configured"` + GuideOnly bool `json:"guideOnly"` + Config string `json:"config"` + Version *string `json:"version"` + LockedVersion *string `json:"lockedVersion"` + CanInstall bool `json:"canInstall"` + Provider *string `json:"provider"` + Model *string `json:"model"` + BaseURL *string `json:"baseUrl"` + UpdatedAt *string `json:"updatedAt"` + Detected *DetectedConfig `json:"detected"` +} + +type DetectedConfig struct { + BaseURL string `json:"baseUrl"` + Model string `json:"model"` + ManagedByOneAgent bool `json:"managedByOneAgent"` + Unreadable *string `json:"unreadable"` +} + +// ProfileSummary is intentionally a public projection. It has no credential +// field; hasKey only reports whether a secret exists in the secure store. +type ProfileSummary struct { + ID string `json:"id"` + Label string `json:"label"` + Provider string `json:"provider"` + BaseURL *string `json:"baseUrl"` + Model *string `json:"model"` + AgentIDs []string `json:"agentIds"` + ActivatedAt *string `json:"activatedAt"` + HasKey bool `json:"hasKey"` +} + +func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { + if err := ctx.Err(); err != nil { + return StatusResponse{}, oneerrors.New(oneerrors.Timeout, "Status request was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + manifest, err := catalog.LoadEmbedded() + if err != nil { + return StatusResponse{}, err + } + options := u.status + paths := map[string]string{ + "env_file": filepath.Join(options.Home, ".oneagent", envFilename(options.Platform.OS)), + "profile": filepath.Join(options.Home, ".oneagent", "profile.json"), + } + capabilities := Capabilities{ + CanInstall: make(map[string]bool, len(manifest.Agents)), + SupportedAgentIDs: make([]string, 0, len(manifest.Agents)), + } + statuses := make(map[string]AgentStatus, len(manifest.Agents)) + for id, agent := range manifest.Agents { + configPath := configPath(options.Home, options.Platform.OS, agent) + if configPath != "" { + paths[id+"_config"] = configPath + } + installed := false + if agent.Command != "" { + _, installed = options.Lookup(agent.Command) + } + canInstall := false + if agent.Package != nil { + _, canInstall = options.Lookup(agent.Package.Manager) + if agent.Package.Manager == "npm" { + _, canInstall = options.Lookup("npm") + } else if agent.Package.Manager == "uv" { + _, canInstall = options.Lookup("uv") + } + } + if options.Platform.OS == "windows" { + for _, prerequisite := range agent.WindowsPrerequisites { + _, present := options.Lookup(prerequisite) + canInstall = canInstall && present + } + } + if contains(agent.Platforms, options.Platform.OS) { + capabilities.SupportedAgentIDs = append(capabilities.SupportedAgentIDs, id) + } + capabilities.CanInstall[id] = canInstall + var lockedVersion *string + if agent.Package != nil { + version := agent.Package.Version + lockedVersion = &version + } + statuses[id] = AgentStatus{ + Installed: installed, + Configured: fileExists(configPath), + GuideOnly: agent.ConfigMode == "guide", + Config: configPath, + LockedVersion: lockedVersion, + CanInstall: canInstall, + } + } + sort.Strings(capabilities.SupportedAgentIDs) + return StatusResponse{ + APIVersion: 1, + Platform: options.Platform, + Capabilities: capabilities, + Agents: statuses, + Catalog: catalog.PublicCatalog(manifest, options.Platform.OS), + Groups: catalog.Groups(), + Providers: catalog.PublicProviders(), + Mirrors: catalog.Mirrors(), + Paths: paths, + Backups: backupState(options.Home, options.Platform.OS, manifest), + Profiles: []ProfileSummary{}, + Environment: nil, + }, nil +} + +func envFilename(osID string) string { + if osID == "windows" { + return "env.ps1" + } + return "env" +} + +func configPath(home, osID string, agent catalog.Agent) string { + if agent.ConfigPath == "" { + return "" + } + relative := agent.ConfigPath + if osID == "windows" && agent.WindowsConfigPath != "" { + relative = agent.WindowsConfigPath + } + return filepath.Join(home, filepath.FromSlash(relative)) +} + +func fileExists(path string) bool { + if path == "" { + return false + } + _, err := os.Stat(path) + return err == nil +} + +func backupState(home, osID string, manifest catalog.Manifest) map[string]bool { + result := make(map[string]bool) + for id, agent := range manifest.Agents { + path := configPath(home, osID, agent) + if path == "" { + continue + } + matches, err := filepath.Glob(path + ".backup-*") + result[id] = err == nil && len(matches) > 0 + } + envMatches, err := filepath.Glob(filepath.Join(home, ".oneagent", "env.backup-*")) + result["env"] = err == nil && len(envMatches) > 0 + profileMatches, err := filepath.Glob(filepath.Join(home, ".oneagent", "profile.json.backup-*")) + result["profile"] = err == nil && len(profileMatches) > 0 + return result +} + +func contains(values []string, wanted string) bool { + for _, value := range values { + if strings.EqualFold(value, wanted) { + return true + } + } + return false +} diff --git a/internal/app/status_test.go b/internal/app/status_test.go new file mode 100644 index 00000000..47d61c6b --- /dev/null +++ b/internal/app/status_test.go @@ -0,0 +1,83 @@ +package app + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/MaimoryLab/OneAgent/internal/platform" +) + +func TestStatusUsesInjectedHomeAndCommandLookup(t *testing.T) { + home := t.TempDir() + lookup := func(command string) (string, bool) { + if command == "npm" || command == "codex" { + return "/fake/" + command, true + } + return "", false + } + core := NewUseCases(StatusOptions{ + Home: home, + Platform: platform.For("linux", "amd64"), + Lookup: lookup, + }) + status, err := core.GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status.APIVersion != 1 || status.Platform.OS != "linux" { + t.Fatalf("unexpected status header: %#v", status) + } + if !status.Agents["codex"].Installed || !status.Capabilities.CanInstall["codex"] { + t.Fatalf("injected command lookup was not used: %#v", status.Agents["codex"]) + } + if status.Paths["profile"] != filepath.Join(home, ".oneagent", "profile.json") { + t.Fatalf("profile path escaped injected home: %q", status.Paths["profile"]) + } + wire, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + if string(wire) == "" || hasSubstring(string(wire), "api_key") || hasSubstring(string(wire), "fallback") { + t.Fatalf("status contains a secret/internal field: %s", wire) + } +} + +func TestStatusReportsExistingConfigWithoutWriting(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("model = 'local'\n"), 0o600); err != nil { + t.Fatal(err) + } + core := NewUseCases(StatusOptions{Home: home, Platform: platform.For("linux", "amd64"), Lookup: func(string) (string, bool) { return "", false }}) + status, err := core.GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if !status.Agents["codex"].Configured { + t.Fatal("existing config was not observed") + } +} + +func TestStatusHonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := NewUseCases(StatusOptions{Platform: platform.For("linux", "amd64")}).GetStatus(ctx) + if err == nil || !hasSubstring(err.Error(), "cancelled") { + t.Fatalf("cancellation was not mapped: %v", err) + } +} + +func hasSubstring(value, needle string) bool { + for index := 0; index+len(needle) <= len(value); index++ { + if value[index:index+len(needle)] == needle { + return true + } + } + return false +} diff --git a/internal/binding/services.go b/internal/binding/services.go new file mode 100644 index 00000000..88fcda01 --- /dev/null +++ b/internal/binding/services.go @@ -0,0 +1,226 @@ +// Package binding contains transport DTOs and the narrow Wails-facing service +// layer. Business logic belongs in internal/app and is not duplicated here. +package binding + +import ( + "context" + "net/url" + "strings" + + "github.com/MaimoryLab/OneAgent/internal/app" + "github.com/MaimoryLab/OneAgent/internal/catalog" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" +) + +type Services struct { + Status *StatusService + Provider *ProviderService + Agent *AgentService + Profile *ProfileService +} + +func NewServices(core *app.UseCases, opener BrowserOpener) *Services { + return &Services{ + Status: &StatusService{core: core}, + Provider: &ProviderService{opener: opener}, + Agent: &AgentService{}, + Profile: &ProfileService{}, + } +} + +type StatusService struct { + core *app.UseCases +} + +func (s *StatusService) GetStatus(ctx context.Context) (app.StatusResponse, error) { + if s == nil || s.core == nil { + return app.StatusResponse{}, notReady("Status service is not configured") + } + return s.core.GetStatus(ctx) +} + +type BrowserOpener func(string) error + +type ProviderService struct { + opener BrowserOpener +} + +func (s *ProviderService) ListProviders(ctx context.Context) (map[string]catalog.Provider, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + return catalog.PublicProviders(), nil +} + +func (s *ProviderService) Probe(ctx context.Context, request ProbeRequest) (ProbeResponse, error) { + if err := contextError(ctx); err != nil { + return ProbeResponse{}, err + } + return ProbeResponse{}, notReady("Provider probing is not available in the migration foundation") +} + +func (s *ProviderService) ListModels(ctx context.Context, request ModelsRequest) (ModelsResponse, error) { + if err := contextError(ctx); err != nil { + return ModelsResponse{}, err + } + return ModelsResponse{}, notReady("Model discovery is not available in the migration foundation") +} + +func (s *ProviderService) OpenRegistration(ctx context.Context, request OpenRegistrationRequest) (OpenRegistrationResponse, error) { + if err := contextError(ctx); err != nil { + return OpenRegistrationResponse{}, err + } + provider, ok := catalog.ProviderByID(request.Provider) + if !ok { + return OpenRegistrationResponse{}, oneerrors.New(oneerrors.InvalidRequest, "Registration is only available for an allowlisted Provider") + } + parsed, err := url.Parse(provider.Home) + if err != nil || parsed.User != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return OpenRegistrationResponse{}, oneerrors.New(oneerrors.InvalidRequest, "Provider registration URL is invalid") + } + if s == nil || s.opener == nil { + return OpenRegistrationResponse{OK: true, URL: provider.Home, Message: "Provider registration URL validated"}, nil + } + if err := s.opener(provider.Home); err != nil { + return OpenRegistrationResponse{}, oneerrors.New(oneerrors.InternalError, "Unable to open Provider registration", oneerrors.WithStatus(500), oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + return OpenRegistrationResponse{OK: true, URL: provider.Home, Message: "Provider registration opened"}, nil +} + +type AgentService struct{} + +func (s *AgentService) Install(ctx context.Context, request InstallRequest) (InstallResponse, error) { + if err := contextError(ctx); err != nil { + return InstallResponse{}, err + } + return InstallResponse{}, notReady("Agent installation is not available in the migration foundation") +} + +func (s *AgentService) Activate(ctx context.Context, request ActivateRequest) (ActivateResponse, error) { + if err := contextError(ctx); err != nil { + return ActivateResponse{}, err + } + return ActivateResponse{}, notReady("Agent activation is not available in the migration foundation") +} + +type ProfileService struct{} + +func (s *ProfileService) ListProfiles(ctx context.Context) ([]app.ProfileSummary, error) { + if err := contextError(ctx); err != nil { + return nil, err + } + return []app.ProfileSummary{}, nil +} + +func (s *ProfileService) SaveProfile(ctx context.Context, request SaveProfileRequest) (app.ProfileSummary, error) { + if err := contextError(ctx); err != nil { + return app.ProfileSummary{}, err + } + return app.ProfileSummary{}, notReady("Profile writes are not available in the migration foundation") +} + +type ProbeRequest struct { + Provider string `json:"provider"` + APIBaseURL string `json:"api_base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + Agents []string `json:"agents"` +} + +type ModelsRequest struct { + Provider string `json:"provider"` + APIBaseURL string `json:"api_base_url"` + APIKey string `json:"api_key"` +} + +type OpenRegistrationRequest struct { + Provider string `json:"provider"` + Agents []string `json:"agents"` +} + +type OpenRegistrationResponse struct { + OK bool `json:"ok"` + URL string `json:"url"` + Message string `json:"message"` +} + +type ProbeResponse struct { + OK bool `json:"ok"` + Reachable bool `json:"reachable"` + Status int `json:"status"` + Message string `json:"message"` + ErrorCode string `json:"error_code"` + Retryable bool `json:"retryable"` + Protocol string `json:"protocol"` +} + +type ModelsResponse struct { + ProbeResponse + Models []string `json:"models"` +} + +type InstallRequest struct { + Agents []string `json:"agents"` + Provider string `json:"provider"` + APIBaseURL string `json:"api_base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + SmallFastModel string `json:"small_fast_model"` + ProfileID string `json:"profile_id"` + Configure bool `json:"configure"` + InstallAgent bool `json:"install_agent"` + LockedVersion bool `json:"locked_version"` + Latest bool `json:"latest"` + SkipTest bool `json:"skip_test"` + Timeout int `json:"timeout"` +} + +type InstallResponse struct { + OK bool `json:"ok"` + Code int `json:"code"` + Results []any `json:"results"` + Log string `json:"log"` + Next string `json:"next"` +} + +type ActivateRequest struct { + AgentID string `json:"agent_id"` + Provider string `json:"provider"` + APIBaseURL string `json:"api_base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + ProfileID string `json:"profile_id"` + SmallFastModel string `json:"small_fast_model"` +} + +type ActivateResponse struct { + OK bool `json:"ok"` + Agent string `json:"agent"` + Config string `json:"config"` + Provider string `json:"provider"` + Model string `json:"model"` + Restart string `json:"restart"` + Next string `json:"next"` +} + +type SaveProfileRequest struct { + ID string `json:"id"` + Label string `json:"label"` + Provider string `json:"provider"` + APIBaseURL string `json:"api_base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + ConfigMode string `json:"config_mode"` + AgentIDs []string `json:"agent_ids"` +} + +func contextError(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return oneerrors.New(oneerrors.Timeout, "Request was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + return nil +} + +func notReady(message string) error { + return oneerrors.New(oneerrors.InternalError, strings.TrimSpace(message), oneerrors.WithStatus(501)) +} diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go new file mode 100644 index 00000000..ba8ebe7b --- /dev/null +++ b/internal/catalog/catalog_test.go @@ -0,0 +1,68 @@ +package catalog + +import ( + "encoding/json" + "testing" +) + +func TestEmbeddedManifestMatchesCurrentCatalogContract(t *testing.T) { + manifest, err := LoadEmbedded() + if err != nil { + t.Fatal(err) + } + if manifest.SchemaVersion != SchemaVersion { + t.Fatalf("schema = %d", manifest.SchemaVersion) + } + automatic := 0 + for id, agent := range manifest.Agents { + if agent.ConfigMode == "auto" { + automatic++ + if agent.Package == nil || agent.ConfigAdapter == "" { + t.Errorf("auto Agent %s has incomplete contract", id) + } + } + } + if automatic != 5 { + t.Fatalf("automatic Agent count = %d, want 5", automatic) + } + items := PublicCatalog(manifest, "windows") + if len(items) != len(manifest.Agents) { + t.Fatalf("catalog length = %d", len(items)) + } + for index := 1; index < len(items); index++ { + if items[index-1].Rank > items[index].Rank || (items[index-1].Rank == items[index].Rank && items[index-1].ID > items[index].ID) { + t.Fatalf("catalog is not deterministic: %#v", items) + } + } +} + +func TestPublicProjectionDoesNotExposeFallbackModel(t *testing.T) { + providers := PublicProviders() + data, err := json.Marshal(providers) + if err != nil { + t.Fatal(err) + } + if string(data) == "" || contains(string(data), "fallback") || contains(string(data), "deepseek") { + t.Fatalf("internal provider fields leaked: %s", data) + } +} + +func TestParseRejectsInvalidManifest(t *testing.T) { + for _, data := range []string{ + `{"schema_version":2,"agents":{}}`, + `{"schema_version":1,"agents":{"bad id":{"name":"Bad","config_mode":"guide","guide":"x","platforms":["linux"],"rank":1}}}`, + } { + if _, err := Parse([]byte(data)); err == nil { + t.Errorf("Parse(%s) unexpectedly succeeded", data) + } + } +} + +func contains(value, needle string) bool { + for index := 0; index+len(needle) <= len(value); index++ { + if value[index:index+len(needle)] == needle { + return true + } + } + return false +} diff --git a/internal/catalog/manifest.go b/internal/catalog/manifest.go new file mode 100644 index 00000000..4abebf0e --- /dev/null +++ b/internal/catalog/manifest.go @@ -0,0 +1,168 @@ +package catalog + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "regexp" + "sort" + "strings" + + oneagent "github.com/MaimoryLab/OneAgent" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" +) + +const SchemaVersion = 1 + +var agentIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +func LoadEmbedded() (Manifest, error) { + data, err := oneagent.EmbeddedAgentLock() + if err != nil { + return Manifest{}, oneerrors.New( + oneerrors.InvalidRequest, + "Cannot load embedded Agent lock manifest", + oneerrors.WithCause(err), + ) + } + return Parse(data) +} + +func Load(path string) (Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return Manifest{}, oneerrors.New( + oneerrors.InvalidRequest, + fmt.Sprintf("Cannot load Agent lock manifest: %v", err), + oneerrors.WithCause(err), + ) + } + return Parse(data) +} + +func Parse(data []byte) (Manifest, error) { + var manifest Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + return Manifest{}, oneerrors.New( + oneerrors.InvalidRequest, + fmt.Sprintf("Cannot load Agent lock manifest: %v", err), + oneerrors.WithCause(err), + ) + } + if err := validate(manifest); err != nil { + return Manifest{}, err + } + return cloneManifest(manifest), nil +} + +func validate(manifest Manifest) error { + if manifest.SchemaVersion != SchemaVersion || manifest.Agents == nil { + return oneerrors.New(oneerrors.InvalidRequest, "Unsupported Agent lock manifest schema") + } + if len(manifest.Agents) == 0 { + return oneerrors.New(oneerrors.InvalidRequest, "Agent lock manifest has no Agents") + } + for id, agent := range manifest.Agents { + if !agentIDPattern.MatchString(id) { + return invalidManifest(id, "invalid Agent ID") + } + if strings.TrimSpace(agent.Name) == "" { + return invalidManifest(id, "name is required") + } + if agent.ConfigMode != "auto" && agent.ConfigMode != "guide" { + return invalidManifest(id, "config_mode must be auto or guide") + } + if agent.Rank <= 0 { + return invalidManifest(id, "rank must be positive") + } + if len(agent.Platforms) == 0 { + return invalidManifest(id, "platforms must not be empty") + } + for _, platformID := range agent.Platforms { + if platformID != "macos" && platformID != "linux" && platformID != "windows" { + return invalidManifest(id, "platforms contains an unsupported value") + } + } + if agent.ConfigMode == "guide" { + if agent.Package != nil || agent.ConfigAdapter != "" || strings.TrimSpace(agent.Guide) == "" { + return invalidManifest(id, "guide Agent has an installation contract") + } + continue + } + if agent.Command == "" || agent.ConfigPath == "" || agent.ConfigAdapter == "" || agent.Package == nil { + return invalidManifest(id, "auto Agent is missing an installation field") + } + if err := validatePackage(id, *agent.Package); err != nil { + return err + } + } + return nil +} + +func validatePackage(agentID string, pkg Package) error { + if pkg.Manager != "npm" && pkg.Manager != "uv" { + return invalidManifest(agentID, "package manager is not allowlisted") + } + if pkg.Name == "" || pkg.Version == "" || pkg.Version == "latest" || pkg.License == "" { + return invalidManifest(agentID, "package metadata is incomplete") + } + if !httpsURL(pkg.Source) || !httpsURL(pkg.LicenseURL) { + return invalidManifest(agentID, "package source and license URL must use HTTPS") + } + if pkg.Manager == "npm" && (pkg.Integrity == nil || !strings.HasPrefix(*pkg.Integrity, "sha512-")) { + return invalidManifest(agentID, "npm package integrity must use sha512") + } + return nil +} + +func httpsURL(value string) bool { + parsed, err := url.Parse(value) + return err == nil && parsed.Scheme == "https" && parsed.Host != "" && parsed.User == nil +} + +func invalidManifest(agentID, message string) error { + return oneerrors.New(oneerrors.InvalidRequest, fmt.Sprintf("Invalid Agent lock manifest entry %s: %s", agentID, message)) +} + +func cloneManifest(source Manifest) Manifest { + result := source + result.Agents = make(map[string]Agent, len(source.Agents)) + for id, agent := range source.Agents { + copyAgent := agent + copyAgent.EnvVars = cloneMap(agent.EnvVars) + copyAgent.VersionArgs = append([]string(nil), agent.VersionArgs...) + copyAgent.Platforms = append([]string(nil), agent.Platforms...) + copyAgent.WindowsPrerequisites = append([]string(nil), agent.WindowsPrerequisites...) + if agent.Package != nil { + copyPackage := *agent.Package + if agent.Package.Integrity != nil { + integrity := *agent.Package.Integrity + copyPackage.Integrity = &integrity + } + copyAgent.Package = ©Package + } + result.Agents[id] = copyAgent + } + return result +} + +func cloneMap(source map[string]string) map[string]string { + if source == nil { + return nil + } + result := make(map[string]string, len(source)) + for key, value := range source { + result[key] = value + } + return result +} + +func AgentIDs(manifest Manifest) []string { + ids := make([]string, 0, len(manifest.Agents)) + for id := range manifest.Agents { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} diff --git a/internal/catalog/public.go b/internal/catalog/public.go new file mode 100644 index 00000000..121d3c36 --- /dev/null +++ b/internal/catalog/public.go @@ -0,0 +1,141 @@ +package catalog + +import "sort" + +const ( + ProtocolOpenAI = "openai" + ProtocolAnthropic = "anthropic" + ProtocolResponses = "responses" +) + +var adapterProtocols = map[string]string{ + "codex": ProtocolResponses, + "claude-code": ProtocolAnthropic, + "opencode": ProtocolOpenAI, + "kilo-cli": ProtocolOpenAI, + "aider": ProtocolOpenAI, +} + +var providerDefinitions = map[string]Provider{ + "ppio": { + Name: "PPIO", + Home: "https://ppio.com/", + BaseURL: "https://api.ppio.com/openai", + AnthropicBaseURL: "https://api.ppio.com/anthropic", + fallbackModel: "deepseek/deepseek-v3", + }, + "novita": { + Name: "Novita", + Home: "https://novita.ai/", + BaseURL: "https://api.novita.ai/openai", + AnthropicBaseURL: "https://api.novita.ai/anthropic", + fallbackModel: "deepseek/deepseek_v3", + }, +} + +var groups = []Group{ + {ID: "auto", Name: "One-click configurable"}, + {ID: "gateway", Name: "Gateway agents"}, + {ID: "platform", Name: "Official account agents"}, + {ID: "ide", Name: "IDE extensions"}, +} + +const officialNPMRegistry = "https://registry.npmjs.org/" + +var mirrors = []Mirror{ + { + ID: "official", + Name: "官方源", + Registry: officialNPMRegistry, + Upstream: officialNPMRegistry, + Note: "npm 官方 registry,默认使用。", + }, + { + ID: "npmmirror", + Name: "npmmirror(阿里云)", + Registry: "https://registry.npmmirror.com/", + Upstream: officialNPMRegistry, + Note: "官方源的公开只读镜像,包体与校验值均与官方一致;官方源不可达时可用。", + }, +} + +func ProtocolForAdapter(adapter string) string { + if protocol, ok := adapterProtocols[adapter]; ok { + return protocol + } + return ProtocolOpenAI +} + +func FallbackProbeModel(providerID string) string { + if provider, ok := providerDefinitions[providerID]; ok { + return provider.fallbackModel + } + return "deepseek/deepseek-v3" +} + +func PublicProviders() map[string]Provider { + result := make(map[string]Provider, len(providerDefinitions)) + for id, provider := range providerDefinitions { + // fallbackModel is unexported and therefore cannot leak through JSON, + // but clear it here as an additional boundary for non-JSON callers. + provider.fallbackModel = "" + result[id] = provider + } + return result +} + +func ProviderByID(providerID string) (Provider, bool) { + provider, ok := providerDefinitions[providerID] + if !ok { + return Provider{}, false + } + provider.fallbackModel = "" + return provider, true +} + +func Groups() []Group { + return append([]Group(nil), groups...) +} + +func Mirrors() []Mirror { + return append([]Mirror(nil), mirrors...) +} + +func PublicCatalog(manifest Manifest, platformID string) []CatalogItem { + items := make([]CatalogItem, 0, len(manifest.Agents)) + for id, agent := range manifest.Agents { + var lockedVersion *string + if agent.Package != nil { + version := agent.Package.Version + lockedVersion = &version + } + var protocol *string + if agent.ConfigMode == "auto" { + value := ProtocolForAdapter(agent.ConfigAdapter) + protocol = &value + } + platformNote := "" + if platformID == "windows" { + platformNote = agent.WindowsNote + } + items = append(items, CatalogItem{ + ID: id, + Name: agent.Name, + Group: agent.Group, + ConfigMode: agent.ConfigMode, + GuideOnly: agent.ConfigMode == "guide", + LockedVersion: lockedVersion, + Protocol: protocol, + Platforms: append([]string(nil), agent.Platforms...), + PlatformNote: platformNote, + Rank: agent.Rank, + }) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Rank != items[j].Rank { + return items[i].Rank < items[j].Rank + } + return items[i].ID < items[j].ID + }) + return items +} diff --git a/internal/catalog/types.go b/internal/catalog/types.go new file mode 100644 index 00000000..354c5345 --- /dev/null +++ b/internal/catalog/types.go @@ -0,0 +1,71 @@ +package catalog + +type Manifest struct { + SchemaVersion int `json:"schema_version"` + OneAgentVersion string `json:"oneagent_version"` + GeneratedAt string `json:"generated_at"` + Agents map[string]Agent `json:"agents"` +} + +type Agent struct { + Name string `json:"name"` + Group string `json:"group"` + Command string `json:"command"` + ConfigMode string `json:"config_mode"` + ConfigAdapter string `json:"config_adapter"` + CredentialDelivery string `json:"credential_delivery"` + EnvVars map[string]string `json:"env_vars"` + ConfigPath string `json:"config_path"` + WindowsConfigPath string `json:"windows_config_path"` + Package *Package `json:"package"` + VersionArgs []string `json:"version_args"` + Platforms []string `json:"platforms"` + WindowsPrerequisites []string `json:"windows_prerequisites"` + WindowsNote string `json:"windows_note"` + Guide string `json:"guide"` + Rank int `json:"rank"` +} + +type Package struct { + Manager string `json:"manager"` + Name string `json:"name"` + Version string `json:"version"` + Integrity *string `json:"integrity"` + Source string `json:"source"` + License string `json:"license"` + LicenseURL string `json:"license_url"` +} + +type CatalogItem struct { + ID string `json:"id"` + Name string `json:"name"` + Group string `json:"group"` + ConfigMode string `json:"configMode"` + GuideOnly bool `json:"guideOnly"` + LockedVersion *string `json:"lockedVersion"` + Protocol *string `json:"protocol"` + Platforms []string `json:"platforms"` + PlatformNote string `json:"platformNote"` + Rank int `json:"rank"` +} + +type Group struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Provider struct { + Name string `json:"name"` + Home string `json:"home"` + BaseURL string `json:"base_url"` + AnthropicBaseURL string `json:"anthropic_base_url,omitempty"` + fallbackModel string +} + +type Mirror struct { + ID string `json:"id"` + Name string `json:"name"` + Registry string `json:"registry"` + Upstream string `json:"upstream"` + Note string `json:"note"` +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 00000000..04a59fc0 --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,157 @@ +// Package oneerrors defines the stable error contract shared by the Go core, +// Wails services, and the standalone CLI. +package oneerrors + +import ( + "encoding/json" + "errors" + "fmt" +) + +const ( + InvalidRequest = "INVALID_REQUEST" + InvalidOrigin = "INVALID_ORIGIN" // Retained for compatibility; HTTP is being removed. + PrerequisiteMissing = "PREREQUISITE_MISSING" + AgentInstallFailed = "AGENT_INSTALL_FAILED" + ConfigWriteFailed = "CONFIG_WRITE_FAILED" + APIKeyRejected = "API_KEY_REJECTED" + ProviderUnreachable = "PROVIDER_UNREACHABLE" + ModelsUnsupported = "MODELS_UNSUPPORTED" + ProtocolUnsupported = "PROTOCOL_UNSUPPORTED" + Timeout = "TIMEOUT" + InternalError = "INTERNAL_ERROR" +) + +// ExitCodes is intentionally kept compatible with oneagent/errors.py. The +// map is copied by callers when they need to expose it to a CLI parser. +var ExitCodes = map[string]int{ + InvalidRequest: 2, + InvalidOrigin: 2, + PrerequisiteMissing: 3, + AgentInstallFailed: 4, + ConfigWriteFailed: 5, + APIKeyRejected: 6, + ProviderUnreachable: 6, + ModelsUnsupported: 6, + ProtocolUnsupported: 7, + Timeout: 8, + InternalError: 10, +} + +// OneAgentError is safe to serialize across the Wails bridge. Message must +// already be free of credentials, file contents, and process output. +type OneAgentError struct { + Code string `json:"error_code"` + Message string `json:"message"` + Status int `json:"status"` + Retryable bool `json:"retryable"` + ExitCode int `json:"exit_code"` + cause error +} + +func New(code, message string, options ...Option) *OneAgentError { + err := &OneAgentError{ + Code: code, + Message: message, + Status: 400, + ExitCode: ExitCodes[code], + } + if err.ExitCode == 0 { + err.Code = InternalError + err.ExitCode = ExitCodes[InternalError] + } + for _, option := range options { + option(err) + } + return err +} + +// Option customizes an error without exposing mutable transport details. +type Option func(*OneAgentError) + +func WithStatus(status int) Option { + return func(err *OneAgentError) { err.Status = status } +} + +func WithRetryable(retryable bool) Option { + return func(err *OneAgentError) { err.Retryable = retryable } +} + +func WithCause(cause error) Option { + return func(err *OneAgentError) { err.cause = cause } +} + +func (e *OneAgentError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func (e *OneAgentError) Unwrap() error { + if e == nil { + return nil + } + return e.cause +} + +// APIShape preserves the current HTTP response fields while the transport is +// being replaced by Wails bindings. It deliberately contains no cause or key. +func (e *OneAgentError) APIShape() map[string]any { + if e == nil { + return map[string]any{ + "ok": false, + "error_code": InternalError, + "message": "Unexpected OneAgent failure", + "error": "Unexpected OneAgent failure", + "status": 500, + "retryable": true, + } + } + return map[string]any{ + "ok": false, + "error": e.Message, + "message": e.Message, + "status": e.Status, + "error_code": e.Code, + "retryable": e.Retryable, + } +} + +// MarshalJSON keeps the bridge payload restricted to the documented fields. +func (e *OneAgentError) MarshalJSON() ([]byte, error) { + type wire struct { + Code string `json:"error_code"` + Message string `json:"message"` + Status int `json:"status"` + Retryable bool `json:"retryable"` + ExitCode int `json:"exit_code"` + } + if e == nil { + return json.Marshal(wire{Code: InternalError, Message: "Unexpected OneAgent failure", Status: 500, Retryable: true, ExitCode: ExitCodes[InternalError]}) + } + return json.Marshal(wire{Code: e.Code, Message: e.Message, Status: e.Status, Retryable: e.Retryable, ExitCode: e.ExitCode}) +} + +// As converts an arbitrary error to the stable transport type. Unknown errors +// are intentionally generalized so internal details cannot reach the UI. +func As(err error) *OneAgentError { + if err == nil { + return nil + } + var oneErr *OneAgentError + if errors.As(err, &oneErr) { + return oneErr + } + return New(InternalError, "Unexpected OneAgent failure", WithStatus(500), WithRetryable(true), WithCause(err)) +} + +func Marshal(err error) []byte { + payload, marshalErr := json.Marshal(As(err)) + if marshalErr != nil { + // The shape above only contains primitive values; this is a final guard + // for a Wails callback, which cannot return an error itself. + return []byte(fmt.Sprintf(`{"error_code":%q,"message":%q,"status":500,"retryable":true,"exit_code":10}`, InternalError, "Unexpected OneAgent failure")) + } + return payload +} diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go new file mode 100644 index 00000000..546897a5 --- /dev/null +++ b/internal/errors/errors_test.go @@ -0,0 +1,42 @@ +package oneerrors + +import ( + "encoding/json" + "errors" + "testing" +) + +func TestStableErrorShapeAndExitCode(t *testing.T) { + err := New(ConfigWriteFailed, "configuration write failed", WithStatus(422), WithRetryable(true)) + if err.ExitCode != 5 || err.Status != 422 || !err.Retryable { + t.Fatalf("unexpected error: %#v", err) + } + var wire map[string]any + if marshalErr := json.Unmarshal(Marshal(err), &wire); marshalErr != nil { + t.Fatal(marshalErr) + } + if wire["error_code"] != ConfigWriteFailed || wire["exit_code"] != float64(5) { + t.Fatalf("unexpected wire shape: %#v", wire) + } + if _, exists := wire["cause"]; exists { + t.Fatal("internal cause must not cross the bridge") + } +} + +func TestAsGeneralizesUnknownErrors(t *testing.T) { + sentinel := errors.New("secret process output") + err := As(sentinel) + if err.Code != InternalError || err.ExitCode != 10 || !errors.Is(err, sentinel) { + t.Fatalf("unexpected generalized error: %#v", err) + } + if err.Message == sentinel.Error() { + t.Fatal("unknown error details leaked into the stable message") + } +} + +func TestUnknownCodeFallsBackToInternal(t *testing.T) { + err := New("NOT_A_CODE", "ignored") + if err.Code != InternalError || err.ExitCode != ExitCodes[InternalError] { + t.Fatalf("unexpected fallback: %#v", err) + } +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go new file mode 100644 index 00000000..74659c9c --- /dev/null +++ b/internal/platform/platform.go @@ -0,0 +1,103 @@ +// Package platform centralizes operating-system and home-directory decisions. +// It is deliberately injectable so catalog and status tests never touch the +// developer's real HOME. +package platform + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +type Info struct { + OS string `json:"os"` + Arch string `json:"arch"` + Shell string `json:"shell"` +} + +func Current() Info { + return For(runtime.GOOS, runtime.GOARCH) +} + +func For(goos, goarch string) Info { + osID := normalizeOS(goos) + arch := "x64" + if goarch == "arm64" || goarch == "aarch64" { + arch = "arm64" + } + shell := "bash" + if osID == "windows" { + shell = "powershell" + } + return Info{OS: osID, Arch: arch, Shell: shell} +} + +func normalizeOS(goos string) string { + switch strings.ToLower(goos) { + case "darwin", "macos": + return "macos" + case "windows", "win32": + return "windows" + default: + // The product currently has a Linux target for every non-Darwin, + // non-Windows Unix platform. This mirrors the Python baseline. + return "linux" + } +} + +// ResolveHome follows the existing precedence exactly: an explicit +// ONEAGENT_HOME override, then the native Windows variables, then HOME, then +// the process home fallback. +func ResolveHome(env map[string]string, osID string) string { + values := env + if values == nil { + values = environ() + } + if value := strings.TrimSpace(values["ONEAGENT_HOME"]); value != "" { + return expandUser(value, values, osID) + } + if osID == "windows" { + if value := values["USERPROFILE"]; value != "" { + return value + } + if drive, path := values["HOMEDRIVE"], values["HOMEPATH"]; drive != "" && path != "" { + return drive + path + } + } + if value := values["HOME"]; value != "" { + return value + } + if home, err := os.UserHomeDir(); err == nil { + return home + } + return "." +} + +func environ() map[string]string { + values := make(map[string]string) + for _, item := range os.Environ() { + key, value, ok := strings.Cut(item, "=") + if ok { + values[key] = value + } + } + return values +} + +func expandUser(value string, env map[string]string, osID string) string { + if value != "~" && !strings.HasPrefix(value, "~/") && !strings.HasPrefix(value, `~\`) { + return filepath.Clean(value) + } + home := env["HOME"] + if osID == "windows" && env["USERPROFILE"] != "" { + home = env["USERPROFILE"] + } + if home == "" { + home, _ = os.UserHomeDir() + } + if value == "~" { + return home + } + return filepath.Join(home, strings.TrimLeft(value[2:], `/\`)) +} diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go new file mode 100644 index 00000000..c7268998 --- /dev/null +++ b/internal/platform/platform_test.go @@ -0,0 +1,47 @@ +package platform + +import "testing" + +func TestForMapsSupportedTargets(t *testing.T) { + tests := []struct { + goos, goarch, wantOS, wantArch, wantShell string + }{ + {"darwin", "arm64", "macos", "arm64", "bash"}, + {"windows", "amd64", "windows", "x64", "powershell"}, + {"linux", "amd64", "linux", "x64", "bash"}, + } + for _, test := range tests { + got := For(test.goos, test.goarch) + if got.OS != test.wantOS || got.Arch != test.wantArch || got.Shell != test.wantShell { + t.Errorf("For(%q, %q) = %#v", test.goos, test.goarch, got) + } + } +} + +func TestResolveHomeWindowsPrecedence(t *testing.T) { + env := map[string]string{ + "ONEAGENT_HOME": "/override", + "USERPROFILE": `C:\Users\测试 用户`, + "HOME": "/wrong", + } + if got := ResolveHome(env, "windows"); got != "/override" { + t.Fatalf("ONEAGENT_HOME precedence = %q", got) + } + delete(env, "ONEAGENT_HOME") + if got := ResolveHome(env, "windows"); got != `C:\Users\测试 用户` { + t.Fatalf("USERPROFILE precedence = %q", got) + } + delete(env, "USERPROFILE") + env["HOMEDRIVE"] = "D:" + env["HOMEPATH"] = `\Users\fallback` + if got := ResolveHome(env, "windows"); got != `D:\Users\fallback` { + t.Fatalf("HOMEDRIVE/HOMEPATH precedence = %q", got) + } +} + +func TestResolveHomeExpandsTilde(t *testing.T) { + env := map[string]string{"HOME": "/tmp/test-home"} + if got := ResolveHome(map[string]string{"ONEAGENT_HOME": "~/oneagent", "HOME": env["HOME"]}, "linux"); got != "/tmp/test-home/oneagent" { + t.Fatalf("expanded home = %q", got) + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 00000000..5a09e105 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,5 @@ +package version + +// Version is the first migration line. The Python 0.2.x entry points remain +// the production path until the later migration gates are complete. +const Version = "0.3.0-dev" diff --git a/manifest_embed.go b/manifest_embed.go new file mode 100644 index 00000000..eaf87bc7 --- /dev/null +++ b/manifest_embed.go @@ -0,0 +1,23 @@ +package oneagent + +import "embed" + +// AgentLockManifest is the single embedded copy of the repository's Agent +// lock file. Keeping the embed at the module root lets internal packages use +// the root manifest without maintaining a second hand-edited catalog file. +// +//go:embed agents.lock.json +var AgentLockManifest embed.FS + +// FrontendAssets is the asset tree consumed by the Wails shell. The checked-in +// .keep file makes the stage-0 shell buildable before Vite has produced dist; +// the build task replaces that directory with the real bundle. +// +//go:embed all:frontend/dist +var FrontendAssets embed.FS + +// EmbeddedAgentLock returns a fresh copy of agents.lock.json for parsers and +// callers that need to retain or modify the returned bytes. +func EmbeddedAgentLock() ([]byte, error) { + return AgentLockManifest.ReadFile("agents.lock.json") +} From fc86de9e3d94625d8f7b00da5308e6509731bfb8 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:15:41 +0800 Subject: [PATCH 03/63] build: pin Wails bindings and runtime --- Taskfile.yml | 2 +- build/Taskfile.yml | 2 +- frontend/bindings/README.md | 14 +++ .../MaimoryLab/OneAgent/internal/app/index.ts | 10 ++ .../OneAgent/internal/app/models.ts | 68 ++++++++++++ .../OneAgent/internal/binding/agentservice.ts | 18 +++ .../OneAgent/internal/binding/index.ts | 27 +++++ .../OneAgent/internal/binding/models.ts | 103 ++++++++++++++++++ .../internal/binding/profileservice.ts | 22 ++++ .../internal/binding/providerservice.ts | 30 +++++ .../internal/binding/statusservice.ts | 14 +++ .../OneAgent/internal/catalog/index.ts | 9 ++ .../OneAgent/internal/catalog/models.ts | 35 ++++++ .../OneAgent/internal/platform/index.ts | 6 + .../OneAgent/internal/platform/models.ts | 8 ++ .../wailsapp/wails/v3/internal/eventcreate.ts | 9 ++ .../wailsapp/wails/v3/internal/eventdata.d.ts | 2 + frontend/package-lock.json | 7 ++ frontend/package.json | 1 + go.mod | 11 ++ go.sum | 19 ++++ internal/binding/services_test.go | 63 +++++++++++ scripts/check_wails_bindings.sh | 20 ++++ 23 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 frontend/bindings/README.md create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/index.ts create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.ts create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.ts create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.ts create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.ts create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/index.ts create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/models.ts create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/index.ts create mode 100644 frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/models.ts create mode 100644 frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts create mode 100644 frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts create mode 100644 internal/binding/services_test.go create mode 100755 scripts/check_wails_bindings.sh diff --git a/Taskfile.yml b/Taskfile.yml index 0f810c48..f889c3ba 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -19,7 +19,7 @@ tasks: generate:bindings: summary: Regenerate Wails TypeScript bindings cmds: - - go run {{.WAILS_MODULE}}/cmd/wails3@{{.WAILS_VERSION}} generate bindings -f wails -ts -d frontend/bindings ./cmd/oneagent-desktop + - go run {{.WAILS_MODULE}}/cmd/wails3@{{.WAILS_VERSION}} generate bindings -f "-tags wails" -ts -i -d frontend/bindings ./cmd/oneagent-desktop build:frontend: summary: Build the existing React frontend for the migration shell diff --git a/build/Taskfile.yml b/build/Taskfile.yml index c0dedcf1..1a6c04f2 100644 --- a/build/Taskfile.yml +++ b/build/Taskfile.yml @@ -9,4 +9,4 @@ tasks: generate:bindings: summary: Generate bindings using the pinned Wails version cmds: - - go run github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.119 generate bindings -f wails -ts -d frontend/bindings ./cmd/oneagent-desktop + - go run github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.119 generate bindings -f "-tags wails" -ts -i -d frontend/bindings ./cmd/oneagent-desktop diff --git a/frontend/bindings/README.md b/frontend/bindings/README.md new file mode 100644 index 00000000..5881026b --- /dev/null +++ b/frontend/bindings/README.md @@ -0,0 +1,14 @@ +# Generated Wails bindings + +This directory is generated from the Go services. Do not edit the generated +TypeScript files by hand. + +Regenerate with the pinned CLI and build tag: + +```text +wails3 generate bindings -f "-tags wails" -ts -i -d frontend/bindings ./cmd/oneagent-desktop +``` + +The current production frontend still uses `frontend/src/api/client.ts`; these +bindings are staged for the later service-switch phase. Until that phase is +complete, the Python HTTP path remains authoritative. diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/index.ts new file mode 100644 index 00000000..70b76f49 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/index.ts @@ -0,0 +1,10 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export type { + AgentStatus, + Capabilities, + DetectedConfig, + ProfileSummary, + StatusResponse +} from "./models.js"; diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts new file mode 100644 index 00000000..e91be61d --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/app/models.ts @@ -0,0 +1,68 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as catalog$0 from "../catalog/models.js"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as platform$0 from "../platform/models.js"; + +export interface AgentStatus { + "installed": boolean; + "configured": boolean; + "guideOnly": boolean; + "config": string; + "version": string | null; + "lockedVersion": string | null; + "canInstall": boolean; + "provider": string | null; + "model": string | null; + "baseUrl": string | null; + "updatedAt": string | null; + "detected": DetectedConfig | null; +} + +export interface Capabilities { + "canInstall": { [_ in string]?: boolean } | null; + "supportedAgentIds": string[] | null; +} + +export interface DetectedConfig { + "baseUrl": string; + "model": string; + "managedByOneAgent": boolean; + "unreadable": string | null; +} + +/** + * ProfileSummary is intentionally a public projection. It has no credential + * field; hasKey only reports whether a secret exists in the secure store. + */ +export interface ProfileSummary { + "id": string; + "label": string; + "provider": string; + "baseUrl": string | null; + "model": string | null; + "agentIds": string[] | null; + "activatedAt": string | null; + "hasKey": boolean; +} + +export interface StatusResponse { + "apiVersion": number; + "platform": platform$0.Info; + "capabilities": Capabilities; + "agents": { [_ in string]?: AgentStatus } | null; + "catalog": catalog$0.CatalogItem[] | null; + "groups": catalog$0.Group[] | null; + "providers": { [_ in string]?: catalog$0.Provider } | null; + "mirrors": catalog$0.Mirror[] | null; + "paths": { [_ in string]?: string } | null; + "backups": { [_ in string]?: boolean } | null; + "profiles": ProfileSummary[] | null; + "activeProfile": string | null; + "environment": any; + "environmentError": string | null; +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.ts new file mode 100644 index 00000000..58904d42 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.ts @@ -0,0 +1,18 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function Activate(request: $models.ActivateRequest): $CancellablePromise<$models.ActivateResponse> { + return $Call.ByID(1962001654, request); +} + +export function Install(request: $models.InstallRequest): $CancellablePromise<$models.InstallResponse> { + return $Call.ByID(3913480362, request); +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts new file mode 100644 index 00000000..5bd39fcd --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts @@ -0,0 +1,27 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as AgentService from "./agentservice.js"; +import * as ProfileService from "./profileservice.js"; +import * as ProviderService from "./providerservice.js"; +import * as StatusService from "./statusservice.js"; +export { + AgentService, + ProfileService, + ProviderService, + StatusService +}; + +export type { + ActivateRequest, + ActivateResponse, + InstallRequest, + InstallResponse, + ModelsRequest, + ModelsResponse, + OpenRegistrationRequest, + OpenRegistrationResponse, + ProbeRequest, + ProbeResponse, + SaveProfileRequest +} from "./models.js"; diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts new file mode 100644 index 00000000..82db96d0 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts @@ -0,0 +1,103 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export interface ActivateRequest { + "agent_id": string; + "provider": string; + "api_base_url": string; + "api_key": string; + "model": string; + "profile_id": string; + "small_fast_model": string; +} + +export interface ActivateResponse { + "ok": boolean; + "agent": string; + "config": string; + "provider": string; + "model": string; + "restart": string; + "next": string; +} + +export interface InstallRequest { + "agents": string[] | null; + "provider": string; + "api_base_url": string; + "api_key": string; + "model": string; + "small_fast_model": string; + "profile_id": string; + "configure": boolean; + "install_agent": boolean; + "locked_version": boolean; + "latest": boolean; + "skip_test": boolean; + "timeout": number; +} + +export interface InstallResponse { + "ok": boolean; + "code": number; + "results": any[] | null; + "log": string; + "next": string; +} + +export interface ModelsRequest { + "provider": string; + "api_base_url": string; + "api_key": string; +} + +export interface ModelsResponse { + "ok": boolean; + "reachable": boolean; + "status": number; + "message": string; + "error_code": string; + "retryable": boolean; + "protocol": string; + "models": string[] | null; +} + +export interface OpenRegistrationRequest { + "provider": string; + "agents": string[] | null; +} + +export interface OpenRegistrationResponse { + "ok": boolean; + "url": string; + "message": string; +} + +export interface ProbeRequest { + "provider": string; + "api_base_url": string; + "api_key": string; + "model": string; + "agents": string[] | null; +} + +export interface ProbeResponse { + "ok": boolean; + "reachable": boolean; + "status": number; + "message": string; + "error_code": string; + "retryable": boolean; + "protocol": string; +} + +export interface SaveProfileRequest { + "id": string; + "label": string; + "provider": string; + "api_base_url": string; + "api_key": string; + "model": string; + "config_mode": string; + "agent_ids": string[] | null; +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.ts new file mode 100644 index 00000000..7b851e68 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.ts @@ -0,0 +1,22 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as app$0 from "../app/models.js"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function ListProfiles(): $CancellablePromise { + return $Call.ByID(192725737); +} + +export function SaveProfile(request: $models.SaveProfileRequest): $CancellablePromise { + return $Call.ByID(972252911, request); +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.ts new file mode 100644 index 00000000..f0baf1f2 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.ts @@ -0,0 +1,30 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as catalog$0 from "../catalog/models.js"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +export function ListModels(request: $models.ModelsRequest): $CancellablePromise<$models.ModelsResponse> { + return $Call.ByID(1201530915, request); +} + +export function ListProviders(): $CancellablePromise<{ [_ in string]?: catalog$0.Provider } | null> { + return $Call.ByID(3942954229); +} + +export function OpenRegistration(request: $models.OpenRegistrationRequest): $CancellablePromise<$models.OpenRegistrationResponse> { + return $Call.ByID(745368128, request); +} + +export function Probe(request: $models.ProbeRequest): $CancellablePromise<$models.ProbeResponse> { + return $Call.ByID(2223638197, request); +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.ts new file mode 100644 index 00000000..0d91fc57 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.ts @@ -0,0 +1,14 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as app$0 from "../app/models.js"; + +export function GetStatus(): $CancellablePromise { + return $Call.ByID(1555230240); +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/index.ts new file mode 100644 index 00000000..462e2750 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/index.ts @@ -0,0 +1,9 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export type { + CatalogItem, + Group, + Mirror, + Provider +} from "./models.js"; diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/models.ts new file mode 100644 index 00000000..b5a90312 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/catalog/models.ts @@ -0,0 +1,35 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export interface CatalogItem { + "id": string; + "name": string; + "group": string; + "configMode": string; + "guideOnly": boolean; + "lockedVersion": string | null; + "protocol": string | null; + "platforms": string[] | null; + "platformNote": string; + "rank": number; +} + +export interface Group { + "id": string; + "name": string; +} + +export interface Mirror { + "id": string; + "name": string; + "registry": string; + "upstream": string; + "note": string; +} + +export interface Provider { + "name": string; + "home": string; + "base_url": string; + "anthropic_base_url"?: string; +} diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/index.ts new file mode 100644 index 00000000..2aad9941 --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/index.ts @@ -0,0 +1,6 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export type { + Info +} from "./models.js"; diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/models.ts new file mode 100644 index 00000000..daa33c6d --- /dev/null +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/platform/models.ts @@ -0,0 +1,8 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export interface Info { + "os": string; + "arch": string; + "shell": string; +} diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts new file mode 100644 index 00000000..1ea10585 --- /dev/null +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts @@ -0,0 +1,9 @@ +//@ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Create as $Create } from "@wailsio/runtime"; + +Object.freeze($Create.Events); diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts new file mode 100644 index 00000000..3dd1807b --- /dev/null +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts @@ -0,0 +1,2 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 41bc8865..2ee89d6a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "oneagent-frontend", "version": "0.2.0-dev", "dependencies": { + "@wailsio/runtime": "3.0.0-alpha2.117", "lucide-react": "1.25.0", "react": "19.2.8", "react-dom": "19.2.8", @@ -1399,6 +1400,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@wailsio/runtime": { + "version": "3.0.0-alpha2.117", + "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha2.117.tgz", + "integrity": "sha512-RZr6cncIXjdTbn2IqJ6AZXPm9WooaZEsNlNfaUH+Ru/YlH5sKDDeRdWpbcvsgoMLh4AJ+O8aBzZEiTYWDipGxw==", + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 07f683fc..bd9ce6bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "e2e": "playwright test" }, "dependencies": { + "@wailsio/runtime": "3.0.0-alpha2.117", "lucide-react": "1.25.0", "react": "19.2.8", "react-dom": "19.2.8", diff --git a/go.mod b/go.mod index 34f969d7..5d5e0826 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,14 @@ module github.com/MaimoryLab/OneAgent go 1.25.0 require github.com/wailsapp/wails/v3 v3.0.0-alpha2.119 + +require ( + github.com/adrg/xdg v0.5.3 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.43.0 // indirect +) diff --git a/go.sum b/go.sum index fa35dd3a..a9e1e58b 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,21 @@ +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/wailsapp/wails/v3 v3.0.0-alpha2.119 h1:+y9H2QxqxSiaq3i4h3e77k1/nFW0uGhcWWl1bz/DFFg= github.com/wailsapp/wails/v3 v3.0.0-alpha2.119/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go new file mode 100644 index 00000000..36fd4cb1 --- /dev/null +++ b/internal/binding/services_test.go @@ -0,0 +1,63 @@ +package binding + +import ( + "context" + "reflect" + "sort" + "testing" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" +) + +func TestServiceMethodAllowlist(t *testing.T) { + tests := []struct { + service any + want []string + }{ + {&StatusService{}, []string{"GetStatus"}}, + {&ProviderService{}, []string{"ListModels", "ListProviders", "OpenRegistration", "Probe"}}, + {&AgentService{}, []string{"Activate", "Install"}}, + {&ProfileService{}, []string{"ListProfiles", "SaveProfile"}}, + } + for _, test := range tests { + typeOf := reflect.TypeOf(test.service) + got := make([]string, 0, typeOf.NumMethod()) + for index := 0; index < typeOf.NumMethod(); index++ { + got = append(got, typeOf.Method(index).Name) + } + sort.Strings(got) + sort.Strings(test.want) + if !reflect.DeepEqual(got, test.want) { + t.Errorf("%s methods = %v, want %v", typeOf, got, test.want) + } + } +} + +func TestOpenRegistrationUsesCatalogURLOnly(t *testing.T) { + var opened string + service := &ProviderService{opener: func(value string) error { + opened = value + return nil + }} + response, err := service.OpenRegistration(context.Background(), OpenRegistrationRequest{Provider: "ppio"}) + if err != nil { + t.Fatal(err) + } + if opened != "https://ppio.com/" || response.URL != opened { + t.Fatalf("unexpected registration URL: opened=%q response=%#v", opened, response) + } + + _, err = service.OpenRegistration(context.Background(), OpenRegistrationRequest{Provider: "https://example.com"}) + if err == nil || oneerrors.As(err).Code != oneerrors.InvalidRequest { + t.Fatalf("arbitrary URL was not rejected: %v", err) + } +} + +func TestServiceCancellationUsesStableTimeoutCode(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := (&ProviderService{}).ListProviders(ctx) + if err == nil || oneerrors.As(err).Code != oneerrors.Timeout { + t.Fatalf("cancellation error = %v", err) + } +} diff --git a/scripts/check_wails_bindings.sh b/scripts/check_wails_bindings.sh new file mode 100755 index 00000000..c80d79c2 --- /dev/null +++ b/scripts/check_wails_bindings.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +wails_bin="${WAILS3_BIN:-wails3}" +expected_version="$(sed -n 's/^WAILS_CLI_VERSION=//p' "$repo_root/build/tool-versions.env")" +actual_version="$($wails_bin version 2>&1 | tail -n 1 | tr -d '\r')" +if [[ "$actual_version" != "$expected_version" ]]; then + printf 'Wails CLI version %s does not match pinned %s\n' "$actual_version" "$expected_version" >&2 + exit 1 +fi + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT +( + cd "$repo_root" + "$wails_bin" generate bindings -f "-tags wails" -ts -i -d "$tmp_dir" -clean=true ./cmd/oneagent-desktop +) + +diff -ru --exclude=README.md "$repo_root/frontend/bindings" "$tmp_dir" From a8f9d224629692bb47e4e085717de6ab08e37363 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:24:50 +0800 Subject: [PATCH 04/63] test: freeze Python status baseline for Go catalog --- docs/wails-v3-migration-plan.md | 6 +- internal/app/status.go | 8 +- internal/app/status_test.go | 57 +++ .../testdata/status-empty-linux-arm64.json | 468 ++++++++++++++++++ internal/catalog/catalog_test.go | 18 + internal/catalog/manifest.go | 70 ++- internal/catalog/types.go | 1 + 7 files changed, 620 insertions(+), 8 deletions(-) create mode 100644 internal/app/testdata/status-empty-linux-arm64.json diff --git a/docs/wails-v3-migration-plan.md b/docs/wails-v3-migration-plan.md index e3b33372..9cb02217 100644 --- a/docs/wails-v3-migration-plan.md +++ b/docs/wails-v3-migration-plan.md @@ -6,8 +6,10 @@ - 适用范围:桌面应用、Go 核心、CLI、前端通信、测试、构建、发布和公开站数据生成 当前进度:已建立 Go module、嵌入式 Agent catalog、稳定错误/平台类型、纯 Go CLI -和带 `wails` 构建标签的桌面空壳。现有 Python 核心、HTTP GUI、包装脚本、测试和 -发布流程仍是当前生产路径;在阶段 2-6 的行为等价门禁通过前不得删除或旁路它们。 +和带 `wails` 构建标签的桌面空壳,并已用 Python 当前实现冻结空 HOME、Linux arm64、 +无命令环境的 status/catalog fixture;Go 测试独立读取该 fixture。现有 Python 核心、 +HTTP GUI、包装脚本、测试和发布流程仍是当前生产路径;在阶段 2-6 的行为等价门禁 +通过前不得删除或旁路它们。 ## 1. 结论 diff --git a/internal/app/status.go b/internal/app/status.go index 25f766d6..6d658894 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -8,7 +8,6 @@ import ( "os" "os/exec" "path/filepath" - "sort" "strings" "github.com/MaimoryLab/OneAgent/internal/catalog" @@ -138,7 +137,8 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { SupportedAgentIDs: make([]string, 0, len(manifest.Agents)), } statuses := make(map[string]AgentStatus, len(manifest.Agents)) - for id, agent := range manifest.Agents { + for _, id := range catalog.AgentIDs(manifest) { + agent := manifest.Agents[id] configPath := configPath(options.Home, options.Platform.OS, agent) if configPath != "" { paths[id+"_config"] = configPath @@ -180,7 +180,6 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { CanInstall: canInstall, } } - sort.Strings(capabilities.SupportedAgentIDs) return StatusResponse{ APIVersion: 1, Platform: options.Platform, @@ -225,7 +224,8 @@ func fileExists(path string) bool { func backupState(home, osID string, manifest catalog.Manifest) map[string]bool { result := make(map[string]bool) - for id, agent := range manifest.Agents { + for _, id := range catalog.AgentIDs(manifest) { + agent := manifest.Agents[id] path := configPath(home, osID, agent) if path == "" { continue diff --git a/internal/app/status_test.go b/internal/app/status_test.go index 47d61c6b..dce173ac 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" + "strings" "testing" "github.com/MaimoryLab/OneAgent/internal/platform" @@ -64,6 +66,41 @@ func TestStatusReportsExistingConfigWithoutWriting(t *testing.T) { } } +func TestStatusMatchesPythonEmptyLinuxARM64Fixture(t *testing.T) { + home := t.TempDir() + core := NewUseCases(StatusOptions{ + Home: home, + Platform: platform.For("linux", "arm64"), + Lookup: func(string) (string, bool) { return "", false }, + }) + status, err := core.GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + actualData, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + fixtureData, err := os.ReadFile(filepath.Join("testdata", "status-empty-linux-arm64.json")) + if err != nil { + t.Fatal(err) + } + var actual any + var expected any + if err := json.Unmarshal(actualData, &actual); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(fixtureData, &expected); err != nil { + t.Fatal(err) + } + actual = normalizeFixtureHome(actual, home) + if !reflect.DeepEqual(actual, expected) { + actualPretty, _ := json.MarshalIndent(actual, "", " ") + expectedPretty, _ := json.MarshalIndent(expected, "", " ") + t.Fatalf("status diverged from the frozen Python fixture\nwant:\n%s\ngot:\n%s", expectedPretty, actualPretty) + } +} + func TestStatusHonorsCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -81,3 +118,23 @@ func hasSubstring(value, needle string) bool { } return false } + +func normalizeFixtureHome(value any, home string) any { + switch item := value.(type) { + case string: + normalized := strings.ReplaceAll(item, home, "${HOME}") + if strings.HasPrefix(normalized, "${HOME}") { + return filepath.ToSlash(normalized) + } + return normalized + case []any: + for index := range item { + item[index] = normalizeFixtureHome(item[index], home) + } + case map[string]any: + for key := range item { + item[key] = normalizeFixtureHome(item[key], home) + } + } + return value +} diff --git a/internal/app/testdata/status-empty-linux-arm64.json b/internal/app/testdata/status-empty-linux-arm64.json new file mode 100644 index 00000000..a633334a --- /dev/null +++ b/internal/app/testdata/status-empty-linux-arm64.json @@ -0,0 +1,468 @@ +{ + "apiVersion": 1, + "platform": { + "os": "linux", + "arch": "arm64", + "shell": "bash" + }, + "capabilities": { + "canInstall": { + "codex": false, + "claude-code": false, + "opencode": false, + "kilo-cli": false, + "aider": false, + "openclaw": false, + "hermes": false, + "cursor": false, + "kiro": false, + "gemini-cli": false, + "cline": false, + "continue": false, + "qwen-code": false, + "kilo-vscode": false + }, + "supportedAgentIds": [ + "codex", + "claude-code", + "opencode", + "kilo-cli", + "aider", + "openclaw", + "hermes", + "cursor", + "kiro", + "gemini-cli", + "cline", + "continue", + "qwen-code", + "kilo-vscode" + ] + }, + "agents": { + "codex": { + "installed": false, + "config": "${HOME}/.codex/config.toml", + "configured": false, + "guideOnly": false, + "version": null, + "lockedVersion": "0.145.0", + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "claude-code": { + "installed": false, + "config": "${HOME}/.claude/settings.json", + "configured": false, + "guideOnly": false, + "version": null, + "lockedVersion": "2.1.217", + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "opencode": { + "installed": false, + "config": "${HOME}/.config/opencode/opencode.jsonc", + "configured": false, + "guideOnly": false, + "version": null, + "lockedVersion": "1.18.4", + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "kilo-cli": { + "installed": false, + "config": "${HOME}/.config/kilo/kilo.jsonc", + "configured": false, + "guideOnly": false, + "version": null, + "lockedVersion": "7.4.11", + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "aider": { + "installed": false, + "config": "${HOME}/.oneagent/aider.env", + "configured": false, + "guideOnly": false, + "version": null, + "lockedVersion": "0.86.2", + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "openclaw": { + "installed": false, + "config": "", + "configured": false, + "guideOnly": true, + "version": null, + "lockedVersion": null, + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "hermes": { + "installed": false, + "config": "", + "configured": false, + "guideOnly": true, + "version": null, + "lockedVersion": null, + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "cursor": { + "installed": false, + "config": "", + "configured": false, + "guideOnly": true, + "version": null, + "lockedVersion": null, + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "kiro": { + "installed": false, + "config": "", + "configured": false, + "guideOnly": true, + "version": null, + "lockedVersion": null, + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "gemini-cli": { + "installed": false, + "config": "", + "configured": false, + "guideOnly": true, + "version": null, + "lockedVersion": null, + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "cline": { + "installed": false, + "config": "", + "configured": false, + "guideOnly": true, + "version": null, + "lockedVersion": null, + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "continue": { + "installed": false, + "config": "", + "configured": false, + "guideOnly": true, + "version": null, + "lockedVersion": null, + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "qwen-code": { + "installed": false, + "config": "", + "configured": false, + "guideOnly": true, + "version": null, + "lockedVersion": null, + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + }, + "kilo-vscode": { + "installed": false, + "config": "", + "configured": false, + "guideOnly": true, + "version": null, + "lockedVersion": null, + "canInstall": false, + "provider": null, + "model": null, + "baseUrl": null, + "updatedAt": null, + "detected": null + } + }, + "catalog": [ + { + "id": "codex", + "name": "Codex", + "group": "auto", + "configMode": "auto", + "guideOnly": false, + "lockedVersion": "0.145.0", + "protocol": "responses", + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 1 + }, + { + "id": "claude-code", + "name": "Claude Code", + "group": "auto", + "configMode": "auto", + "guideOnly": false, + "lockedVersion": "2.1.217", + "protocol": "anthropic", + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 2 + }, + { + "id": "cursor", + "name": "Cursor", + "group": "platform", + "configMode": "guide", + "guideOnly": true, + "lockedVersion": null, + "protocol": null, + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 3 + }, + { + "id": "opencode", + "name": "OpenCode", + "group": "auto", + "configMode": "auto", + "guideOnly": false, + "lockedVersion": "1.18.4", + "protocol": "openai", + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 4 + }, + { + "id": "openclaw", + "name": "OpenClaw", + "group": "gateway", + "configMode": "guide", + "guideOnly": true, + "lockedVersion": null, + "protocol": null, + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 5 + }, + { + "id": "hermes", + "name": "Hermes", + "group": "gateway", + "configMode": "guide", + "guideOnly": true, + "lockedVersion": null, + "protocol": null, + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 6 + }, + { + "id": "gemini-cli", + "name": "Gemini CLI", + "group": "platform", + "configMode": "guide", + "guideOnly": true, + "lockedVersion": null, + "protocol": null, + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 7 + }, + { + "id": "kilo-cli", + "name": "Kilo CLI", + "group": "auto", + "configMode": "auto", + "guideOnly": false, + "lockedVersion": "7.4.11", + "protocol": "openai", + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 8 + }, + { + "id": "aider", + "name": "Aider", + "group": "auto", + "configMode": "auto", + "guideOnly": false, + "lockedVersion": "0.86.2", + "protocol": "openai", + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 9 + }, + { + "id": "kiro", + "name": "Kiro", + "group": "platform", + "configMode": "guide", + "guideOnly": true, + "lockedVersion": null, + "protocol": null, + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 10 + }, + { + "id": "cline", + "name": "Cline", + "group": "ide", + "configMode": "guide", + "guideOnly": true, + "lockedVersion": null, + "protocol": null, + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 11 + }, + { + "id": "continue", + "name": "Continue", + "group": "ide", + "configMode": "guide", + "guideOnly": true, + "lockedVersion": null, + "protocol": null, + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 12 + }, + { + "id": "qwen-code", + "name": "Qwen Code", + "group": "ide", + "configMode": "guide", + "guideOnly": true, + "lockedVersion": null, + "protocol": null, + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 13 + }, + { + "id": "kilo-vscode", + "name": "Kilo VS Code", + "group": "ide", + "configMode": "guide", + "guideOnly": true, + "lockedVersion": null, + "protocol": null, + "platforms": ["macos", "linux", "windows"], + "platformNote": "", + "rank": 14 + } + ], + "groups": [ + {"id": "auto", "name": "One-click configurable"}, + {"id": "gateway", "name": "Gateway agents"}, + {"id": "platform", "name": "Official account agents"}, + {"id": "ide", "name": "IDE extensions"} + ], + "providers": { + "ppio": { + "name": "PPIO", + "home": "https://ppio.com/", + "base_url": "https://api.ppio.com/openai", + "anthropic_base_url": "https://api.ppio.com/anthropic" + }, + "novita": { + "name": "Novita", + "home": "https://novita.ai/", + "base_url": "https://api.novita.ai/openai", + "anthropic_base_url": "https://api.novita.ai/anthropic" + } + }, + "mirrors": [ + { + "id": "official", + "name": "官方源", + "registry": "https://registry.npmjs.org/", + "upstream": "https://registry.npmjs.org/", + "note": "npm 官方 registry,默认使用。" + }, + { + "id": "npmmirror", + "name": "npmmirror(阿里云)", + "registry": "https://registry.npmmirror.com/", + "upstream": "https://registry.npmjs.org/", + "note": "官方源的公开只读镜像,包体与校验值均与官方一致;官方源不可达时可用。" + } + ], + "paths": { + "env_file": "${HOME}/.oneagent/env", + "profile": "${HOME}/.oneagent/profile.json", + "codex_config": "${HOME}/.codex/config.toml", + "claude-code_config": "${HOME}/.claude/settings.json", + "opencode_config": "${HOME}/.config/opencode/opencode.jsonc", + "kilo-cli_config": "${HOME}/.config/kilo/kilo.jsonc", + "aider_config": "${HOME}/.oneagent/aider.env" + }, + "backups": { + "codex": false, + "claude-code": false, + "opencode": false, + "kilo-cli": false, + "aider": false, + "env": false, + "profile": false + }, + "profiles": [], + "activeProfile": null, + "environment": null, + "environmentError": null +} diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index ba8ebe7b..4c69a750 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -2,6 +2,7 @@ package catalog import ( "encoding/json" + "reflect" "testing" ) @@ -58,6 +59,23 @@ func TestParseRejectsInvalidManifest(t *testing.T) { } } +func TestParsePreservesDeclaredAgentOrder(t *testing.T) { + manifest, err := Parse([]byte(`{ + "schema_version": 1, + "agents": { + "z-agent": {"name":"Z","config_mode":"guide","guide":"z","platforms":["linux"],"rank":2}, + "a-agent": {"name":"A","config_mode":"guide","guide":"a","platforms":["linux"],"rank":1} + } + }`)) + if err != nil { + t.Fatal(err) + } + want := []string{"z-agent", "a-agent"} + if got := AgentIDs(manifest); !reflect.DeepEqual(got, want) { + t.Fatalf("AgentIDs() = %v, want declaration order %v", got, want) + } +} + func contains(value, needle string) bool { for index := 0; index+len(needle) <= len(value); index++ { if value[index:index+len(needle)] == needle { diff --git a/internal/catalog/manifest.go b/internal/catalog/manifest.go index 4abebf0e..afda5cdc 100644 --- a/internal/catalog/manifest.go +++ b/internal/catalog/manifest.go @@ -1,6 +1,7 @@ package catalog import ( + "bytes" "encoding/json" "fmt" "net/url" @@ -50,12 +51,65 @@ func Parse(data []byte) (Manifest, error) { oneerrors.WithCause(err), ) } + order, err := decodeAgentOrder(data) + if err != nil { + return Manifest{}, oneerrors.New( + oneerrors.InvalidRequest, + fmt.Sprintf("Cannot load Agent lock manifest: %v", err), + oneerrors.WithCause(err), + ) + } + manifest.AgentOrder = order if err := validate(manifest); err != nil { return Manifest{}, err } return cloneManifest(manifest), nil } +func decodeAgentOrder(data []byte) ([]string, error) { + var document struct { + Agents json.RawMessage `json:"agents"` + } + if err := json.Unmarshal(data, &document); err != nil { + return nil, err + } + if len(document.Agents) == 0 || bytes.Equal(bytes.TrimSpace(document.Agents), []byte("null")) { + return nil, nil + } + decoder := json.NewDecoder(bytes.NewReader(document.Agents)) + token, err := decoder.Token() + if err != nil { + return nil, err + } + if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { + return nil, fmt.Errorf("agents must be an object") + } + order := make([]string, 0) + seen := make(map[string]bool) + for decoder.More() { + key, err := decoder.Token() + if err != nil { + return nil, err + } + id, ok := key.(string) + if !ok { + return nil, fmt.Errorf("Agent ID must be a string") + } + if !seen[id] { + order = append(order, id) + seen[id] = true + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, err + } + } + if _, err := decoder.Token(); err != nil { + return nil, err + } + return order, nil +} + func validate(manifest Manifest) error { if manifest.SchemaVersion != SchemaVersion || manifest.Agents == nil { return oneerrors.New(oneerrors.InvalidRequest, "Unsupported Agent lock manifest schema") @@ -127,6 +181,7 @@ func invalidManifest(agentID, message string) error { func cloneManifest(source Manifest) Manifest { result := source + result.AgentOrder = append([]string(nil), source.AgentOrder...) result.Agents = make(map[string]Agent, len(source.Agents)) for id, agent := range source.Agents { copyAgent := agent @@ -160,9 +215,20 @@ func cloneMap(source map[string]string) map[string]string { func AgentIDs(manifest Manifest) []string { ids := make([]string, 0, len(manifest.Agents)) + seen := make(map[string]bool, len(manifest.Agents)) + for _, id := range manifest.AgentOrder { + if _, ok := manifest.Agents[id]; ok && !seen[id] { + ids = append(ids, id) + seen[id] = true + } + } + missing := make([]string, 0, len(manifest.Agents)-len(ids)) for id := range manifest.Agents { - ids = append(ids, id) + if !seen[id] { + missing = append(missing, id) + } } - sort.Strings(ids) + sort.Strings(missing) + ids = append(ids, missing...) return ids } diff --git a/internal/catalog/types.go b/internal/catalog/types.go index 354c5345..a77cb9a7 100644 --- a/internal/catalog/types.go +++ b/internal/catalog/types.go @@ -5,6 +5,7 @@ type Manifest struct { OneAgentVersion string `json:"oneagent_version"` GeneratedAt string `json:"generated_at"` Agents map[string]Agent `json:"agents"` + AgentOrder []string `json:"-"` } type Agent struct { From fad650574ccff2d7e8d6e114ae073fbd1f819f07 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:29:33 +0800 Subject: [PATCH 05/63] feat: add Go provider URL and protocol rules --- internal/provider/provider.go | 151 +++++++++++++++++++++++++++++ internal/provider/provider_test.go | 96 ++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/provider_test.go diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 00000000..d4d51135 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,151 @@ +// Package provider contains transport-independent Provider and protocol +// rules. Network clients are kept separate so these checks can be reused by +// the CLI and Wails bindings without opening a request during validation. +package provider + +import ( + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/MaimoryLab/OneAgent/internal/catalog" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" +) + +const ( + ProtocolOpenAI = catalog.ProtocolOpenAI + ProtocolAnthropic = catalog.ProtocolAnthropic + ProtocolResponses = catalog.ProtocolResponses +) + +var nonChatModel = regexp.MustCompile(`(?i)(^|[-_/.])(embed(ding)?s?|rerank(er)?s?|ocr|whisper|asr|tts|speech|vl|vision|image|sdx?|flux|guard(rail)?s?|moderation|sql)([-_/.]|$)`) + +// ValidateBaseURL accepts an explicit HTTP(S) origin or path and rejects the +// forms that could smuggle credentials or control characters into requests. +func ValidateBaseURL(value string) (string, error) { + if value == "" { + return "", oneerrors.New(oneerrors.InvalidRequest, "Custom base URL is required") + } + for _, character := range value { + if character < 32 || character == 127 { + return "", oneerrors.New(oneerrors.InvalidRequest, "Custom base URL contains control characters") + } + } + parsed, err := url.Parse(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return "", oneerrors.New(oneerrors.InvalidRequest, "Custom base URL must start with http:// or https://") + } + if parsed.User != nil { + return "", oneerrors.New(oneerrors.InvalidRequest, "Custom base URL must not contain credentials") + } + return strings.TrimRight(value, "/"), nil +} + +// ProviderBase resolves a catalog Provider or validates a custom endpoint. +// Built-in Providers may still receive an explicit override, matching the +// existing CLI behavior. +func ProviderBase(providerID, customBase string) (string, error) { + if providerID != "ppio" && providerID != "novita" && providerID != "custom" { + return "", oneerrors.New(oneerrors.InvalidRequest, "Provider must be ppio, novita, or custom") + } + if customBase != "" || providerID == "custom" { + return ValidateBaseURL(customBase) + } + meta, ok := catalog.ProviderByID(providerID) + if !ok { + return "", oneerrors.New(oneerrors.InvalidRequest, "Provider must be ppio, novita, or custom") + } + return meta.BaseURL, nil +} + +// ProviderConfigBase selects the protocol-specific built-in endpoint. Custom +// endpoints are left untouched because the user owns their protocol contract. +func ProviderConfigBase(providerID, customBase, protocol string) (string, error) { + base, err := ProviderBase(providerID, customBase) + if err != nil { + return "", err + } + if protocol == ProtocolAnthropic && customBase == "" && providerID != "custom" { + meta, ok := catalog.ProviderByID(providerID) + if ok && meta.AnthropicBaseURL != "" { + return meta.AnthropicBaseURL, nil + } + } + return base, nil +} + +func ProviderHome(providerID string) (string, error) { + meta, ok := catalog.ProviderByID(providerID) + if !ok { + return "", oneerrors.New(oneerrors.InvalidRequest, "Registration is only available for ppio or novita") + } + return meta.Home, nil +} + +func ProtocolForAdapter(adapter string) string { + return catalog.ProtocolForAdapter(adapter) +} + +func FallbackProbeModel(providerID string) string { + return catalog.FallbackProbeModel(providerID) +} + +func ProtocolLabel(protocol string) string { + switch protocol { + case ProtocolOpenAI: + return "OpenAI Chat Completions" + case ProtocolAnthropic: + return "Anthropic Messages" + case ProtocolResponses: + return "OpenAI Responses" + default: + return protocol + } +} + +func OpenAIBaseURL(baseURL string) string { + base := strings.TrimRight(baseURL, "/") + for _, suffix := range []string{"/chat/completions", "/responses", "/models"} { + if strings.HasSuffix(base, suffix) { + base = strings.TrimRight(strings.TrimSuffix(base, suffix), "/") + break + } + } + if strings.HasSuffix(base, "/v1") { + return base + } + return base + "/v1" +} + +func AnthropicMessagesURL(baseURL string) string { + base := strings.TrimRight(baseURL, "/") + for _, suffix := range []string{"/v1/messages", "/messages"} { + if strings.HasSuffix(base, suffix) { + base = strings.TrimRight(strings.TrimSuffix(base, suffix), "/") + break + } + } + if strings.HasSuffix(base, "/v1") { + return base + "/messages" + } + return base + "/v1/messages" +} + +// PickChatModel keeps provider ordering while skipping IDs that clearly refer +// to embeddings, rerankers, speech, vision, or other non-chat endpoints. +func PickChatModel(models []string) string { + for _, model := range models { + if !nonChatModel.MatchString(model) { + return model + } + } + if len(models) > 0 { + return models[0] + } + return "" +} + +func invalidProtocol(protocol string) error { + return oneerrors.New(oneerrors.InvalidRequest, fmt.Sprintf("Unknown inference protocol: %s", protocol)) +} diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go new file mode 100644 index 00000000..8aee87a5 --- /dev/null +++ b/internal/provider/provider_test.go @@ -0,0 +1,96 @@ +package provider + +import ( + "reflect" + "testing" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" +) + +func TestValidateBaseURL(t *testing.T) { + for _, value := range []string{"", "ftp://example.com", "https:///missing-host", "https://user:pass@example.com", "https://example.com\n"} { + if _, err := ValidateBaseURL(value); err == nil { + t.Errorf("ValidateBaseURL(%q) unexpectedly succeeded", value) + } + } + got, err := ValidateBaseURL("https://example.com///") + if err != nil || got != "https://example.com" { + t.Fatalf("ValidateBaseURL() = %q, %v", got, err) + } +} + +func TestProviderResolution(t *testing.T) { + tests := []struct { + provider, custom, protocol, want string + }{ + {"ppio", "", ProtocolOpenAI, "https://api.ppio.com/openai"}, + {"ppio", "", ProtocolAnthropic, "https://api.ppio.com/anthropic"}, + {"novita", "", ProtocolAnthropic, "https://api.novita.ai/anthropic"}, + {"custom", "http://127.0.0.1:9000", ProtocolAnthropic, "http://127.0.0.1:9000"}, + {"ppio", "https://override.example/", ProtocolAnthropic, "https://override.example"}, + } + for _, test := range tests { + got, err := ProviderConfigBase(test.provider, test.custom, test.protocol) + if err != nil || got != test.want { + t.Errorf("ProviderConfigBase(%q, %q, %q) = %q, %v; want %q", test.provider, test.custom, test.protocol, got, err, test.want) + } + } + if _, err := ProviderBase("custom", ""); err == nil || oneerrors.As(err).Code != oneerrors.InvalidRequest { + t.Fatalf("missing custom URL error = %v", err) + } + if _, err := ProviderHome("custom"); err == nil { + t.Fatal("custom registration unexpectedly allowed") + } +} + +func TestEndpointNormalizers(t *testing.T) { + openAI := map[string]string{ + "https://example.com/v1/responses": "https://example.com/v1", + "https://example.com/v1/models": "https://example.com/v1", + "https://example.com/v1/chat/completions": "https://example.com/v1", + "https://example.com": "https://example.com/v1", + } + for input, want := range openAI { + if got := OpenAIBaseURL(input); got != want { + t.Errorf("OpenAIBaseURL(%q) = %q, want %q", input, got, want) + } + } + anthropic := map[string]string{ + "https://api.ppio.com/anthropic": "https://api.ppio.com/anthropic/v1/messages", + "https://proxy.test/v1": "https://proxy.test/v1/messages", + "https://proxy.test/v1/messages": "https://proxy.test/v1/messages", + "https://proxy.test/messages": "https://proxy.test/v1/messages", + "https://proxy.test/anthropic///": "https://proxy.test/anthropic/v1/messages", + } + for input, want := range anthropic { + if got := AnthropicMessagesURL(input); got != want { + t.Errorf("AnthropicMessagesURL(%q) = %q, want %q", input, got, want) + } + } +} + +func TestProtocolAndModelProjection(t *testing.T) { + if ProtocolForAdapter("codex") != ProtocolResponses || ProtocolForAdapter("claude-code") != ProtocolAnthropic || ProtocolForAdapter("new") != ProtocolOpenAI { + t.Fatal("adapter protocol projection diverged") + } + if ProtocolLabel(ProtocolResponses) != "OpenAI Responses" || ProtocolLabel("future") != "future" { + t.Fatal("protocol labels diverged") + } + got := []string{ + PickChatModel([]string{"whisper-large-v3", "bge-reranker-v2", "deepseek-v3", "gpt-5.6-terra"}), + PickChatModel([]string{"resolver-1", "evolve-chat"}), + } + if !reflect.DeepEqual(got, []string{"deepseek-v3", "resolver-1"}) { + t.Fatalf("PickChatModel() = %v", got) + } + if PickChatModel([]string{"text-embedding-3-small", "whisper-1"}) != "text-embedding-3-small" { + t.Fatal("all non-chat models should fall back to the first ID") + } +} + +func TestInvalidProtocolErrorIsStable(t *testing.T) { + err := invalidProtocol("grpc") + if oneerrors.As(err).Code != oneerrors.InvalidRequest || err.Error() != "Unknown inference protocol: grpc" { + t.Fatalf("invalid protocol error = %v", err) + } +} From 8fc157a272e0ad663e211a264b3dd34ad4618b2e Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:34:50 +0800 Subject: [PATCH 06/63] feat: port provider discovery and protocol probes --- internal/provider/client.go | 409 +++++++++++++++++++++++++++++++ internal/provider/client_test.go | 209 ++++++++++++++++ 2 files changed, 618 insertions(+) create mode 100644 internal/provider/client.go create mode 100644 internal/provider/client_test.go diff --git a/internal/provider/client.go b/internal/provider/client.go new file mode 100644 index 00000000..2727d44d --- /dev/null +++ b/internal/provider/client.go @@ -0,0 +1,409 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" +) + +const ( + defaultTimeout = 10 * time.Second + defaultMaxBody = 1 << 20 + unsupportedStatus = 0 +) + +// HTTPDoer is deliberately smaller than *http.Client so tests and callers can +// provide a transport without opening a real network connection. +type HTTPDoer interface { + Do(*http.Request) (*http.Response, error) +} + +type Client struct { + doer HTTPDoer + timeout time.Duration + maxBody int64 +} + +func NewClient(doer HTTPDoer) *Client { + if doer == nil { + doer = &http.Client{} + } + return &Client{doer: doer, timeout: defaultTimeout, maxBody: defaultMaxBody} +} + +// NewClientWithLimits is useful for integration tests and keeps operational +// limits explicit at the one place where requests are created. +func NewClientWithLimits(doer HTTPDoer, timeout time.Duration, maxBody int64) *Client { + client := NewClient(doer) + if timeout > 0 { + client.timeout = timeout + } + if maxBody > 0 { + client.maxBody = maxBody + } + return client +} + +type ProbeResult struct { + OK bool `json:"ok"` + Reachable bool `json:"reachable"` + Status int `json:"status"` + Message string `json:"message"` + ErrorCode *string `json:"error_code"` + Retryable bool `json:"retryable"` + Protocol *string `json:"protocol"` +} + +type ModelsResult struct { + OK bool `json:"ok"` + Reachable bool `json:"reachable"` + Status int `json:"status"` + Message string `json:"message"` + ErrorCode *string `json:"error_code"` + Retryable bool `json:"retryable"` + Protocol *string `json:"protocol"` + Models []string `json:"models"` +} + +func (c *Client) Probe(ctx context.Context, protocol, providerID, apiKey, model, customBase string) (ProbeResult, error) { + if apiKey == "" { + return ProbeResult{}, oneerrors.New(oneerrors.InvalidRequest, "API key is required") + } + if protocol != ProtocolOpenAI && protocol != ProtocolAnthropic && protocol != ProtocolResponses { + return ProbeResult{}, invalidProtocol(protocol) + } + requestModel := model + if requestModel == "" { + requestModel = FallbackProbeModel(providerID) + } + request, err := protocolRequest(ctx, protocol, providerID, customBase, apiKey, requestModel) + if err != nil { + return ProbeResult{}, err + } + response, err := c.do(request) + if err != nil { + result := transportResult(err) + result.Protocol = stringPointer(protocol) + return result, nil + } + defer response.Body.Close() + if response.StatusCode >= 200 && response.StatusCode < 300 { + // Keep the connection reusable without allowing a successful endpoint to + // stream an unbounded response into the process. + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, c.maxBody)) + return ProbeResult{ + OK: response.StatusCode == http.StatusOK || response.StatusCode == http.StatusNoContent, + Reachable: true, + Status: response.StatusCode, + Message: fmt.Sprintf("%s connection test passed.", ProtocolLabel(protocol)), + Retryable: false, + Protocol: stringPointer(protocol), + }, nil + } + body, _ := c.readBody(response.Body) + return classifyHTTPProbe(response.StatusCode, string(body), protocol, requestModel), nil +} + +func (c *Client) ListModels(ctx context.Context, providerID, apiKey, customBase string) (ModelsResult, error) { + if apiKey == "" { + return ModelsResult{}, oneerrors.New(oneerrors.InvalidRequest, "API key is required") + } + base, err := ProviderBase(providerID, customBase) + if err != nil { + return ModelsResult{}, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, OpenAIBaseURL(base)+"/models", nil) + if err != nil { + return ModelsResult{}, oneerrors.New(oneerrors.InvalidRequest, "Provider endpoint is invalid", oneerrors.WithCause(err)) + } + request.Header.Set("Authorization", "Bearer "+apiKey) + response, err := c.do(request) + if err != nil { + result := transportModelsResult(err) + return result, nil + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + body, _ := c.readBody(response.Body) + return classifyHTTPModels(response.StatusCode, string(body)), nil + } + body, tooLarge := c.readBody(response.Body) + if tooLarge { + return modelsFailure("Model list response is too large; enter model ID manually."), nil + } + var raw any + if err := json.Unmarshal(body, &raw); err != nil { + return ModelsResult{ + Reachable: true, + Status: response.StatusCode, + Message: fmt.Sprintf("Model list response is not valid JSON: %v", err), + ErrorCode: stringPointer(oneerrors.ModelsUnsupported), + Models: []string{}, + }, nil + } + models := modelIDs(raw) + result := ModelsResult{Reachable: true, Status: response.StatusCode, Models: models} + if len(models) > 0 { + result.OK = true + result.Message = fmt.Sprintf("Found %d models.", len(models)) + } else { + result.Message = "No model IDs returned; enter model ID manually." + result.ErrorCode = stringPointer(oneerrors.ModelsUnsupported) + } + return result, nil +} + +// ResolveProbeModel prefers a live chat-capable ID but keeps discovery failure +// non-fatal, exactly as the Python installer does. +func (c *Client) ResolveProbeModel(ctx context.Context, providerID, apiKey, model, customBase string) (string, error) { + if model != "" || apiKey == "" { + if model != "" { + return model, nil + } + return FallbackProbeModel(providerID), nil + } + listing, err := c.ListModels(ctx, providerID, apiKey, customBase) + if err != nil { + return "", err + } + if listing.OK && len(listing.Models) > 0 { + return PickChatModel(listing.Models), nil + } + return FallbackProbeModel(providerID), nil +} + +func protocolRequest(ctx context.Context, protocol, providerID, customBase, apiKey, model string) (*http.Request, error) { + var ( + endpoint string + body any + ) + headers := map[string]string{ + "Authorization": "Bearer " + apiKey, + "Content-Type": "application/json", + } + if protocol == ProtocolAnthropic { + base, err := ProviderConfigBase(providerID, customBase, protocol) + if err != nil { + return nil, err + } + endpoint = AnthropicMessagesURL(base) + headers["X-Api-Key"] = apiKey + headers["Anthropic-Version"] = "2023-06-01" + body = map[string]any{ + "model": model, + "messages": []map[string]string{{"role": "user", "content": "ping"}}, + "max_tokens": 1, + } + } else { + base, err := ProviderBase(providerID, customBase) + if err != nil { + return nil, err + } + v1 := OpenAIBaseURL(base) + if protocol == ProtocolResponses { + endpoint = v1 + "/responses" + body = map[string]any{"model": model, "input": "ping", "max_output_tokens": 16} + } else { + endpoint = v1 + "/chat/completions" + body = map[string]any{ + "model": model, + "messages": []map[string]string{{"role": "user", "content": "ping"}}, + "max_tokens": 1, + } + } + } + payload, err := json.Marshal(body) + if err != nil { + return nil, oneerrors.New(oneerrors.InternalError, "Unable to build Provider request", oneerrors.WithCause(err)) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, oneerrors.New(oneerrors.InvalidRequest, "Provider endpoint is invalid", oneerrors.WithCause(err)) + } + for key, value := range headers { + request.Header.Set(key, value) + } + return request, nil +} + +func (c *Client) do(request *http.Request) (*http.Response, error) { + timeout := c.timeout + if timeout <= 0 { + timeout = defaultTimeout + } + ctx, cancel := context.WithTimeout(request.Context(), timeout) + defer cancel() + return c.doer.Do(request.WithContext(ctx)) +} + +func (c *Client) readBody(reader io.Reader) ([]byte, bool) { + limit := c.maxBody + if limit <= 0 { + limit = defaultMaxBody + } + data, err := io.ReadAll(io.LimitReader(reader, limit+1)) + if err != nil { + return nil, false + } + if int64(len(data)) > limit { + return data[:limit], true + } + return data, false +} + +func classifyHTTPProbe(status int, body, protocol, model string) ProbeResult { + label := ProtocolLabel(protocol) + if unsupportedProtocol(status, body) { + return ProbeResult{ + Reachable: true, + Status: status, + Message: fmt.Sprintf("Model %q does not support %s. Choose a model that serves this protocol.", model, label), + ErrorCode: stringPointer(oneerrors.ProtocolUnsupported), + Protocol: stringPointer(protocol), + } + } + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return ProbeResult{ + Reachable: true, + Status: status, + Message: fmt.Sprintf("API key was rejected (%d).", status), + ErrorCode: stringPointer(oneerrors.APIKeyRejected), + Retryable: true, + Protocol: stringPointer(protocol), + } + } + return ProbeResult{ + Reachable: true, + Status: status, + Message: fmt.Sprintf("Endpoint returned HTTP %d.", status), + ErrorCode: stringPointer(oneerrors.ProviderUnreachable), + Retryable: status >= 500, + Protocol: stringPointer(protocol), + } +} + +func classifyHTTPModels(status int, body string) ModelsResult { + if status == http.StatusUnauthorized || status == http.StatusForbidden { + return ModelsResult{ + Reachable: true, + Status: status, + Message: fmt.Sprintf("API key was rejected (%d). Enter model ID manually.", status), + ErrorCode: stringPointer(oneerrors.APIKeyRejected), + Retryable: true, + Models: []string{}, + } + } + if status == http.StatusNotFound || status == http.StatusMethodNotAllowed { + return ModelsResult{ + Reachable: true, + Status: status, + Message: fmt.Sprintf("This endpoint does not expose /v1/models (%d); enter model ID manually.", status), + ErrorCode: stringPointer(oneerrors.ModelsUnsupported), + Models: []string{}, + } + } + return ModelsResult{ + Reachable: true, + Status: status, + Message: fmt.Sprintf("Endpoint returned HTTP %d.", status), + ErrorCode: stringPointer(oneerrors.ProviderUnreachable), + Retryable: status >= 500, + Models: []string{}, + } +} + +func transportResult(err error) ProbeResult { + code, retryable := transportCode(err) + return ProbeResult{ + Status: unsupportedStatus, + Message: fmt.Sprintf("Cannot reach endpoint: %s", err), + ErrorCode: stringPointer(code), + Retryable: retryable, + } +} + +func transportModelsResult(err error) ModelsResult { + code, retryable := transportCode(err) + return ModelsResult{ + Status: unsupportedStatus, + Message: fmt.Sprintf("Cannot reach endpoint: %s", err), + ErrorCode: stringPointer(code), + Retryable: retryable, + Models: []string{}, + } +} + +func transportCode(err error) (string, bool) { + if errors.Is(err, context.DeadlineExceeded) { + return oneerrors.Timeout, true + } + var networkError net.Error + if errors.As(err, &networkError) && networkError.Timeout() { + return oneerrors.Timeout, true + } + if strings.Contains(strings.ToLower(err.Error()), "timed out") { + return oneerrors.Timeout, true + } + return oneerrors.ProviderUnreachable, true +} + +func modelsFailure(message string) ModelsResult { + return ModelsResult{ + Reachable: true, + Status: http.StatusOK, + Message: message, + ErrorCode: stringPointer(oneerrors.ModelsUnsupported), + Models: []string{}, + } +} + +func modelIDs(raw any) []string { + data := raw + if object, ok := raw.(map[string]any); ok { + data = object["data"] + } + items, ok := data.([]any) + if !ok { + return []string{} + } + models := make([]string, 0, len(items)) + for _, item := range items { + switch value := item.(type) { + case string: + models = append(models, value) + case map[string]any: + if id, ok := value["id"]; ok && id != nil { + models = append(models, fmt.Sprint(id)) + } + } + } + return models +} + +func unsupportedProtocol(status int, body string) bool { + if status == http.StatusNotFound || status == http.StatusMethodNotAllowed || status == http.StatusNotImplemented { + return true + } + if status == http.StatusBadRequest || status == http.StatusUnprocessableEntity || status == http.StatusInternalServerError { + lowered := strings.ToLower(body) + for _, marker := range []string{"does not support endpoint", "not implemented", "unsupported endpoint", "unknown endpoint"} { + if strings.Contains(lowered, marker) { + return true + } + } + } + return false +} + +func stringPointer(value string) *string { + return &value +} diff --git a/internal/provider/client_test.go b/internal/provider/client_test.go new file mode 100644 index 00000000..7076d3dd --- /dev/null +++ b/internal/provider/client_test.go @@ -0,0 +1,209 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "reflect" + "strings" + "testing" + "time" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" +) + +type fakeDoer func(*http.Request) (*http.Response, error) + +func (fake fakeDoer) Do(request *http.Request) (*http.Response, error) { + return fake(request) +} + +func fakeResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +} + +func TestProbeBuildsProtocolSpecificRequests(t *testing.T) { + tests := []struct { + protocol string + path string + bodyKey string + }{ + {ProtocolOpenAI, "/v1/chat/completions", "messages"}, + {ProtocolResponses, "/v1/responses", "input"}, + {ProtocolAnthropic, "/v1/messages", "messages"}, + } + for _, test := range tests { + t.Run(test.protocol, func(t *testing.T) { + var seen *http.Request + client := NewClient(fakeDoer(func(request *http.Request) (*http.Response, error) { + seen = request + return fakeResponse(http.StatusOK, `{}`), nil + })) + result, err := client.Probe(context.Background(), test.protocol, "custom", "key", "model-a", "https://proxy.test/v1") + if err != nil { + t.Fatal(err) + } + if !result.OK || result.Protocol == nil || *result.Protocol != test.protocol { + t.Fatalf("probe result = %#v", result) + } + if seen == nil || seen.URL.Path != test.path { + t.Fatalf("request path = %v, want %s", seen.URL, test.path) + } + var body map[string]any + if err := json.NewDecoder(seen.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if _, ok := body[test.bodyKey]; !ok { + t.Fatalf("request body %#v does not contain %q", body, test.bodyKey) + } + if seen.Header.Get("Authorization") != "Bearer key" { + t.Fatal("Authorization header was not set") + } + if test.protocol == ProtocolAnthropic { + if seen.Header.Get("X-Api-Key") != "key" || seen.Header.Get("Anthropic-Version") != "2023-06-01" { + t.Fatal("Anthropic headers were not set") + } + } else if seen.Header.Get("X-Api-Key") != "" { + t.Fatal("OpenAI request unexpectedly sent X-Api-Key") + } + }) + } +} + +func TestProbeClassifiesUnsupportedAndTransientResponses(t *testing.T) { + unsupported := NewClient(fakeDoer(func(*http.Request) (*http.Response, error) { + return fakeResponse(http.StatusBadRequest, `{"message":"model does not support endpoint"}`), nil + })) + result, err := unsupported.Probe(context.Background(), ProtocolResponses, "custom", "key", "model-a", "https://proxy.test/v1") + if err != nil { + t.Fatal(err) + } + if result.ErrorCode == nil || *result.ErrorCode != oneerrors.ProtocolUnsupported || result.Retryable || result.Reachable != true { + t.Fatalf("unsupported result = %#v", result) + } + + transient := NewClient(fakeDoer(func(*http.Request) (*http.Response, error) { + return fakeResponse(http.StatusServiceUnavailable, `{"error":"busy"}`), nil + })) + result, err = transient.Probe(context.Background(), ProtocolResponses, "custom", "key", "model-a", "https://proxy.test/v1") + if err != nil { + t.Fatal(err) + } + if result.ErrorCode == nil || *result.ErrorCode == oneerrors.ProtocolUnsupported || !result.Retryable || result.Protocol == nil { + t.Fatalf("transient result = %#v", result) + } +} + +func TestProbeTransportAndInputErrors(t *testing.T) { + client := NewClient(fakeDoer(func(*http.Request) (*http.Response, error) { + return nil, errors.New("connection refused") + })) + result, err := client.Probe(context.Background(), ProtocolOpenAI, "ppio", "key", "model", "") + if err != nil || result.ErrorCode == nil || *result.ErrorCode != oneerrors.ProviderUnreachable || result.Reachable || !result.Retryable { + t.Fatalf("transport result = %#v, err=%v", result, err) + } + if _, err := client.Probe(context.Background(), ProtocolOpenAI, "ppio", "", "model", ""); err == nil || oneerrors.As(err).Code != oneerrors.InvalidRequest { + t.Fatalf("missing key error = %v", err) + } + if _, err := client.Probe(context.Background(), "grpc", "ppio", "key", "model", ""); err == nil || oneerrors.As(err).Code != oneerrors.InvalidRequest { + t.Fatalf("unknown protocol error = %v", err) + } +} + +func TestListModelsShapesAndErrors(t *testing.T) { + responses := []string{ + `["model-a", {"id":"model-b"}, {"ignored":true}]`, + `{"data":[{"id":"model-c"}]}`, + } + for _, body := range responses { + client := NewClient(fakeDoer(func(*http.Request) (*http.Response, error) { + return fakeResponse(http.StatusOK, body), nil + })) + result, err := client.ListModels(context.Background(), "ppio", "key", "") + if err != nil || !result.OK || !reflect.DeepEqual(result.Models, func() []string { + if strings.Contains(body, "model-c") { + return []string{"model-c"} + } + return []string{"model-a", "model-b"} + }()) { + t.Fatalf("ListModels(%s) = %#v, err=%v", body, result, err) + } + } + invalid := NewClient(fakeDoer(func(*http.Request) (*http.Response, error) { + return fakeResponse(http.StatusOK, "not-json"), nil + })) + result, err := invalid.ListModels(context.Background(), "ppio", "key", "") + if err != nil || result.ErrorCode == nil || *result.ErrorCode != oneerrors.ModelsUnsupported || len(result.Models) != 0 { + t.Fatalf("invalid JSON result = %#v, err=%v", result, err) + } + missing := NewClient(fakeDoer(func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/openai/v1/models" { + t.Errorf("model URL path = %s", request.URL.Path) + } + return fakeResponse(http.StatusNotFound, ""), nil + })) + result, err = missing.ListModels(context.Background(), "ppio", "key", "") + if err != nil || result.ErrorCode == nil || *result.ErrorCode != oneerrors.ModelsUnsupported || result.Retryable || len(result.Models) != 0 { + t.Fatalf("missing model endpoint result = %#v, err=%v", result, err) + } +} + +func TestListModelsTransportTimeoutAndSizeLimit(t *testing.T) { + timeoutClient := NewClient(fakeDoer(func(request *http.Request) (*http.Response, error) { + return nil, &url.Error{Op: "Get", URL: request.URL.String(), Err: context.DeadlineExceeded} + })) + result, err := timeoutClient.ListModels(context.Background(), "ppio", "key", "") + if err != nil || result.ErrorCode == nil || *result.ErrorCode != oneerrors.Timeout { + t.Fatalf("timeout result = %#v, err=%v", result, err) + } + large := NewClientWithLimits(fakeDoer(func(*http.Request) (*http.Response, error) { + return fakeResponse(http.StatusOK, strings.Repeat("x", 32)), nil + }), time.Second, 8) + result, err = large.ListModels(context.Background(), "ppio", "key", "") + if err != nil || result.ErrorCode == nil || *result.ErrorCode != oneerrors.ModelsUnsupported { + t.Fatalf("large response result = %#v, err=%v", result, err) + } +} + +func TestResolveProbeModelUsesDiscoveryOnlyWhenNeeded(t *testing.T) { + called := false + client := NewClient(fakeDoer(func(*http.Request) (*http.Response, error) { + called = true + return fakeResponse(http.StatusOK, `{"data":[{"id":"text-embedding-3-small"},{"id":"chat-model"} ]}`), nil + })) + model, err := client.ResolveProbeModel(context.Background(), "ppio", "key", "", "") + if err != nil || model != "chat-model" || !called { + t.Fatalf("discovered model = %q, err=%v, called=%v", model, err, called) + } + called = false + model, err = client.ResolveProbeModel(context.Background(), "ppio", "", "", "") + if err != nil || model != FallbackProbeModel("ppio") || called { + t.Fatalf("fallback model = %q, err=%v, called=%v", model, err, called) + } + model, err = client.ResolveProbeModel(context.Background(), "ppio", "key", "chosen", "") + if err != nil || model != "chosen" || called { + t.Fatalf("chosen model = %q, err=%v, called=%v", model, err, called) + } +} + +func TestClientPropagatesContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + client := NewClient(fakeDoer(func(request *http.Request) (*http.Response, error) { + return nil, request.Context().Err() + })) + result, err := client.ListModels(ctx, "ppio", "key", "") + if err != nil || result.ErrorCode == nil || *result.ErrorCode != oneerrors.ProviderUnreachable { + // A caller cancellation is intentionally represented as a transport + // failure here; Wails context validation handles cancellation before a + // request is started. + t.Fatalf("cancelled result = %#v, err=%v", result, err) + } +} From 1b2d3bd2b2c351ffe4f2e910917bf0a1780a8f10 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:42:54 +0800 Subject: [PATCH 07/63] fix: keep provider request context through body reads --- internal/provider/client.go | 26 +++++++++++++++++++++----- internal/provider/client_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/internal/provider/client.go b/internal/provider/client.go index 2727d44d..409b3c83 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -89,12 +89,21 @@ func (c *Client) Probe(ctx context.Context, protocol, providerID, apiKey, model, if err != nil { return ProbeResult{}, err } - response, err := c.do(request) + response, cancelRequest, err := c.do(request) + defer cancelRequest() if err != nil { + if response != nil && response.Body != nil { + _ = response.Body.Close() + } result := transportResult(err) result.Protocol = stringPointer(protocol) return result, nil } + if response == nil { + result := transportResult(errors.New("Provider transport returned no response")) + result.Protocol = stringPointer(protocol) + return result, nil + } defer response.Body.Close() if response.StatusCode >= 200 && response.StatusCode < 300 { // Keep the connection reusable without allowing a successful endpoint to @@ -126,11 +135,18 @@ func (c *Client) ListModels(ctx context.Context, providerID, apiKey, customBase return ModelsResult{}, oneerrors.New(oneerrors.InvalidRequest, "Provider endpoint is invalid", oneerrors.WithCause(err)) } request.Header.Set("Authorization", "Bearer "+apiKey) - response, err := c.do(request) + response, cancelRequest, err := c.do(request) + defer cancelRequest() if err != nil { + if response != nil && response.Body != nil { + _ = response.Body.Close() + } result := transportModelsResult(err) return result, nil } + if response == nil { + return transportModelsResult(errors.New("Provider transport returned no response")), nil + } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { body, _ := c.readBody(response.Body) @@ -235,14 +251,14 @@ func protocolRequest(ctx context.Context, protocol, providerID, customBase, apiK return request, nil } -func (c *Client) do(request *http.Request) (*http.Response, error) { +func (c *Client) do(request *http.Request) (*http.Response, context.CancelFunc, error) { timeout := c.timeout if timeout <= 0 { timeout = defaultTimeout } ctx, cancel := context.WithTimeout(request.Context(), timeout) - defer cancel() - return c.doer.Do(request.WithContext(ctx)) + response, err := c.doer.Do(request.WithContext(ctx)) + return response, cancel, err } func (c *Client) readBody(reader io.Reader) ([]byte, bool) { diff --git a/internal/provider/client_test.go b/internal/provider/client_test.go index 7076d3dd..48b1dfb9 100644 --- a/internal/provider/client_test.go +++ b/internal/provider/client_test.go @@ -29,6 +29,20 @@ func fakeResponse(status int, body string) *http.Response { } } +type contextAwareBody struct { + ctx context.Context + data *strings.Reader +} + +func (body *contextAwareBody) Read(target []byte) (int, error) { + if err := body.ctx.Err(); err != nil { + return 0, err + } + return body.data.Read(target) +} + +func (body *contextAwareBody) Close() error { return nil } + func TestProbeBuildsProtocolSpecificRequests(t *testing.T) { tests := []struct { protocol string @@ -172,6 +186,23 @@ func TestListModelsTransportTimeoutAndSizeLimit(t *testing.T) { } } +func TestClientKeepsRequestContextAliveWhileReadingBody(t *testing.T) { + client := NewClient(fakeDoer(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: &contextAwareBody{ + ctx: request.Context(), + data: strings.NewReader(`{"data":[{"id":"chat-model"}]}`), + }, + Header: make(http.Header), + }, nil + })) + result, err := client.ListModels(context.Background(), "ppio", "key", "") + if err != nil || !result.OK || !reflect.DeepEqual(result.Models, []string{"chat-model"}) { + t.Fatalf("context-aware body result = %#v, err=%v", result, err) + } +} + func TestResolveProbeModelUsesDiscoveryOnlyWhenNeeded(t *testing.T) { called := false client := NewClient(fakeDoer(func(*http.Request) (*http.Response, error) { From bc50f502c57d5c5a97648d40ffe92842e9606855 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:45:28 +0800 Subject: [PATCH 08/63] fix: classify provider body cancellation as timeout --- internal/provider/client.go | 22 ++++++++++++---------- internal/provider/client_test.go | 24 ++++++++++++++++++++---- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/internal/provider/client.go b/internal/provider/client.go index 409b3c83..8c165913 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -118,7 +118,7 @@ func (c *Client) Probe(ctx context.Context, protocol, providerID, apiKey, model, Protocol: stringPointer(protocol), }, nil } - body, _ := c.readBody(response.Body) + body, _, _ := c.readBody(response.Body) return classifyHTTPProbe(response.StatusCode, string(body), protocol, requestModel), nil } @@ -149,10 +149,12 @@ func (c *Client) ListModels(ctx context.Context, providerID, apiKey, customBase } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { - body, _ := c.readBody(response.Body) - return classifyHTTPModels(response.StatusCode, string(body)), nil + return classifyHTTPModels(response.StatusCode), nil + } + body, tooLarge, readErr := c.readBody(response.Body) + if readErr != nil { + return transportModelsResult(readErr), nil } - body, tooLarge := c.readBody(response.Body) if tooLarge { return modelsFailure("Model list response is too large; enter model ID manually."), nil } @@ -261,19 +263,19 @@ func (c *Client) do(request *http.Request) (*http.Response, context.CancelFunc, return response, cancel, err } -func (c *Client) readBody(reader io.Reader) ([]byte, bool) { +func (c *Client) readBody(reader io.Reader) ([]byte, bool, error) { limit := c.maxBody if limit <= 0 { limit = defaultMaxBody } data, err := io.ReadAll(io.LimitReader(reader, limit+1)) if err != nil { - return nil, false + return nil, false, err } if int64(len(data)) > limit { - return data[:limit], true + return data[:limit], true, nil } - return data, false + return data, false, nil } func classifyHTTPProbe(status int, body, protocol, model string) ProbeResult { @@ -307,7 +309,7 @@ func classifyHTTPProbe(status int, body, protocol, model string) ProbeResult { } } -func classifyHTTPModels(status int, body string) ModelsResult { +func classifyHTTPModels(status int) ModelsResult { if status == http.StatusUnauthorized || status == http.StatusForbidden { return ModelsResult{ Reachable: true, @@ -359,7 +361,7 @@ func transportModelsResult(err error) ModelsResult { } func transportCode(err error) (string, bool) { - if errors.Is(err, context.DeadlineExceeded) { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { return oneerrors.Timeout, true } var networkError net.Error diff --git a/internal/provider/client_test.go b/internal/provider/client_test.go index 48b1dfb9..0cd11ce3 100644 --- a/internal/provider/client_test.go +++ b/internal/provider/client_test.go @@ -231,10 +231,26 @@ func TestClientPropagatesContextCancellation(t *testing.T) { return nil, request.Context().Err() })) result, err := client.ListModels(ctx, "ppio", "key", "") - if err != nil || result.ErrorCode == nil || *result.ErrorCode != oneerrors.ProviderUnreachable { - // A caller cancellation is intentionally represented as a transport - // failure here; Wails context validation handles cancellation before a - // request is started. + if err != nil || result.ErrorCode == nil || *result.ErrorCode != oneerrors.Timeout { t.Fatalf("cancelled result = %#v, err=%v", result, err) } } + +type failingBody struct{ err error } + +func (body failingBody) Read([]byte) (int, error) { return 0, body.err } +func (failingBody) Close() error { return nil } + +func TestListModelsMapsBodyReadCancellationToTimeout(t *testing.T) { + client := NewClient(fakeDoer(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: failingBody{err: context.Canceled}, + Header: make(http.Header), + }, nil + })) + result, err := client.ListModels(context.Background(), "ppio", "key", "") + if err != nil || result.ErrorCode == nil || *result.ErrorCode != oneerrors.Timeout || result.Reachable { + t.Fatalf("body cancellation result = %#v, err=%v", result, err) + } +} From ca6f51d15f0b00f003418c201f30167832be614d Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:06:40 +0800 Subject: [PATCH 09/63] feat: wire provider use cases into Wails service --- docs/wails-v3-migration-plan.md | 7 +- .../OneAgent/internal/binding/models.ts | 10 +- internal/app/provider.go | 103 ++++++++++++++++++ internal/app/provider_test.go | 101 +++++++++++++++++ internal/app/status.go | 22 +++- internal/binding/services.go | 79 ++++++++++++-- internal/binding/services_test.go | 96 ++++++++++++++++ 7 files changed, 396 insertions(+), 22 deletions(-) create mode 100644 internal/app/provider.go create mode 100644 internal/app/provider_test.go diff --git a/docs/wails-v3-migration-plan.md b/docs/wails-v3-migration-plan.md index 9cb02217..abeec550 100644 --- a/docs/wails-v3-migration-plan.md +++ b/docs/wails-v3-migration-plan.md @@ -7,9 +7,10 @@ 当前进度:已建立 Go module、嵌入式 Agent catalog、稳定错误/平台类型、纯 Go CLI 和带 `wails` 构建标签的桌面空壳,并已用 Python 当前实现冻结空 HOME、Linux arm64、 -无命令环境的 status/catalog fixture;Go 测试独立读取该 fixture。现有 Python 核心、 -HTTP GUI、包装脚本、测试和发布流程仍是当前生产路径;在阶段 2-6 的行为等价门禁 -通过前不得删除或旁路它们。 +无命令环境的 status/catalog fixture;Go 测试独立读取该 fixture。Provider URL 校验、 +模型发现和 OpenAI/Anthropic/Responses 三协议探测已移植到可注入 HTTP client,并接入 +Wails `ProviderService`。现有 Python 核心、HTTP GUI、包装脚本、测试和发布流程仍是 +当前生产路径;在阶段 2-6 的行为等价门禁通过前不得删除或旁路它们。 ## 1. 结论 diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts index 82db96d0..c3a415da 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts @@ -56,9 +56,10 @@ export interface ModelsResponse { "reachable": boolean; "status": number; "message": string; - "error_code": string; + "error_code": string | null; "retryable": boolean; - "protocol": string; + "protocol"?: string | null; + "protocols"?: { [_ in string]?: ProbeResponse } | null; "models": string[] | null; } @@ -86,9 +87,10 @@ export interface ProbeResponse { "reachable": boolean; "status": number; "message": string; - "error_code": string; + "error_code": string | null; "retryable": boolean; - "protocol": string; + "protocol"?: string | null; + "protocols"?: { [_ in string]?: ProbeResponse } | null; } export interface SaveProfileRequest { diff --git a/internal/app/provider.go b/internal/app/provider.go new file mode 100644 index 00000000..5dc68b01 --- /dev/null +++ b/internal/app/provider.go @@ -0,0 +1,103 @@ +package app + +import ( + "context" + "sort" + + "github.com/MaimoryLab/OneAgent/internal/catalog" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/provider" +) + +type ProviderProbeOptions struct { + Provider string + APIBaseURL string + APIKey string + Model string + AgentIDs []string +} + +type ProviderProbeResult struct { + Primary provider.ProbeResult + Protocols map[string]provider.ProbeResult +} + +func (u *UseCases) ProbeProvider(ctx context.Context, options ProviderProbeOptions) (ProviderProbeResult, error) { + if err := ctx.Err(); err != nil { + return ProviderProbeResult{}, oneerrors.New(oneerrors.Timeout, "Request was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + if u == nil || u.provider == nil { + return ProviderProbeResult{}, oneerrors.New(oneerrors.InternalError, "Provider probing is not configured", oneerrors.WithStatus(501)) + } + model, err := u.provider.ResolveProbeModel(ctx, options.Provider, options.APIKey, options.Model, options.APIBaseURL) + if err != nil { + return ProviderProbeResult{}, err + } + protocols, err := protocolsForAgents(options.AgentIDs) + if err != nil { + return ProviderProbeResult{}, err + } + results := make(map[string]provider.ProbeResult, len(protocols)) + for _, protocolID := range protocols { + result, probeErr := u.provider.Probe(ctx, protocolID, options.Provider, options.APIKey, model, options.APIBaseURL) + if probeErr != nil { + return ProviderProbeResult{}, probeErr + } + results[protocolID] = result + } + primary := results[protocols[0]] + allOK := true + for _, protocolID := range protocols { + result := results[protocolID] + if !result.OK { + allOK = false + primary = result + break + } + } + primary.OK = allOK + return ProviderProbeResult{Primary: primary, Protocols: results}, nil +} + +func (u *UseCases) ListProviderModels(ctx context.Context, providerID, apiKey, customBase string) (provider.ModelsResult, error) { + if err := ctx.Err(); err != nil { + return provider.ModelsResult{}, oneerrors.New(oneerrors.Timeout, "Request was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + if u == nil || u.provider == nil { + return provider.ModelsResult{}, oneerrors.New(oneerrors.InternalError, "Model discovery is not configured", oneerrors.WithStatus(501)) + } + return u.provider.ListModels(ctx, providerID, apiKey, customBase) +} + +func protocolsForAgents(agentIDs []string) ([]string, error) { + protocols := map[string]bool{} + if len(agentIDs) == 0 { + protocols[provider.ProtocolOpenAI] = true + } else { + manifest, err := catalog.LoadEmbedded() + if err != nil { + return nil, err + } + for _, agentID := range agentIDs { + if agentID == "" { + return nil, oneerrors.New(oneerrors.InvalidRequest, "agents must be a non-empty array of Agent IDs") + } + agent, ok := manifest.Agents[agentID] + if !ok { + return nil, oneerrors.New(oneerrors.InvalidRequest, "Unknown Agent: "+agentID) + } + if agent.ConfigMode == "auto" { + protocols[provider.ProtocolForAdapter(agent.ConfigAdapter)] = true + } + } + if len(protocols) == 0 { + protocols[provider.ProtocolOpenAI] = true + } + } + result := make([]string, 0, len(protocols)) + for protocolID := range protocols { + result = append(result, protocolID) + } + sort.Strings(result) + return result, nil +} diff --git a/internal/app/provider_test.go b/internal/app/provider_test.go new file mode 100644 index 00000000..a2ac112b --- /dev/null +++ b/internal/app/provider_test.go @@ -0,0 +1,101 @@ +package app + +import ( + "context" + "io" + "net/http" + "reflect" + "sort" + "strings" + "testing" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/provider" +) + +type appProviderDoer func(*http.Request) (*http.Response, error) + +func (doer appProviderDoer) Do(request *http.Request) (*http.Response, error) { + return doer(request) +} + +func appProviderResponse(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)} +} + +func providerUseCases(t *testing.T, doer provider.HTTPDoer) *UseCases { + t.Helper() + return NewUseCasesWithProviderClient(StatusOptions{ + Home: t.TempDir(), + Platform: platform.For("linux", "amd64"), + Lookup: func(string) (string, bool) { return "", false }, + }, provider.NewClient(doer)) +} + +func TestProbeProviderAggregatesAgentProtocols(t *testing.T) { + seen := make([]string, 0) + core := providerUseCases(t, appProviderDoer(func(request *http.Request) (*http.Response, error) { + seen = append(seen, request.URL.Path) + return appProviderResponse(http.StatusNoContent, ""), nil + })) + result, err := core.ProbeProvider(context.Background(), ProviderProbeOptions{ + Provider: "custom", + APIBaseURL: "https://proxy.test/v1", + APIKey: "key", + Model: "model", + AgentIDs: []string{"codex", "claude-code", "opencode"}, + }) + if err != nil { + t.Fatal(err) + } + if !result.Primary.OK || len(result.Protocols) != 3 || result.Primary.Protocol == nil || *result.Primary.Protocol != provider.ProtocolAnthropic { + t.Fatalf("aggregated probe = %#v", result) + } + sort.Strings(seen) + if !reflect.DeepEqual(seen, []string{"/v1/chat/completions", "/v1/messages", "/v1/responses"}) { + t.Fatalf("probe paths = %v", seen) + } +} + +func TestProbeProviderSelectsFirstFailure(t *testing.T) { + core := providerUseCases(t, appProviderDoer(func(request *http.Request) (*http.Response, error) { + if request.URL.Path == "/v1/responses" { + return appProviderResponse(http.StatusBadRequest, `{"message":"does not support endpoint"}`), nil + } + return appProviderResponse(http.StatusNoContent, ""), nil + })) + result, err := core.ProbeProvider(context.Background(), ProviderProbeOptions{ + Provider: "custom", APIBaseURL: "https://proxy.test/v1", APIKey: "key", Model: "model", AgentIDs: []string{"codex", "opencode"}, + }) + if err != nil || result.Primary.OK || result.Primary.ErrorCode == nil || *result.Primary.ErrorCode != oneerrors.ProtocolUnsupported { + t.Fatalf("failure result = %#v, err=%v", result, err) + } +} + +func TestProtocolsForAgentsRejectsUnknownAndIgnoresGuideOnly(t *testing.T) { + protocols, err := protocolsForAgents([]string{"openclaw"}) + if err != nil || !reflect.DeepEqual(protocols, []string{provider.ProtocolOpenAI}) { + t.Fatalf("guide protocols = %v, err=%v", protocols, err) + } + if _, err := protocolsForAgents([]string{"missing-agent"}); err == nil || oneerrors.As(err).Code != oneerrors.InvalidRequest { + t.Fatalf("unknown Agent error = %v", err) + } + if _, err := protocolsForAgents([]string{""}); err == nil || oneerrors.As(err).Code != oneerrors.InvalidRequest { + t.Fatalf("empty Agent error = %v", err) + } +} + +func TestProviderUseCasesHonorCancellationBeforeNetwork(t *testing.T) { + called := false + core := providerUseCases(t, appProviderDoer(func(*http.Request) (*http.Response, error) { + called = true + return appProviderResponse(http.StatusOK, `{}`), nil + })) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := core.ListProviderModels(ctx, "ppio", "key", "") + if err == nil || oneerrors.As(err).Code != oneerrors.Timeout || called { + t.Fatalf("cancelled call = %v, called=%v", err, called) + } +} diff --git a/internal/app/status.go b/internal/app/status.go index 6d658894..3e23a21e 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -1,6 +1,6 @@ -// Package app contains transport-independent use cases. Only the small status -// slice is moved in this first migration increment; installation and config -// writes remain in Python until their Go equivalents pass their own gates. +// Package app contains transport-independent use cases. Status and Provider +// reads are the first migrated slices; installation and config writes remain +// in Python until their Go equivalents pass their own gates. package app import ( @@ -13,6 +13,7 @@ import ( "github.com/MaimoryLab/OneAgent/internal/catalog" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/provider" ) type CommandLookup func(string) (string, bool) @@ -24,10 +25,18 @@ type StatusOptions struct { } type UseCases struct { - status StatusOptions + status StatusOptions + provider *provider.Client } func NewUseCases(options StatusOptions) *UseCases { + return NewUseCasesWithProviderClient(options, nil) +} + +// NewUseCasesWithProviderClient keeps network access injectable while the +// Python production path remains active and Go behavior is verified with fake +// transports. +func NewUseCasesWithProviderClient(options StatusOptions, client *provider.Client) *UseCases { if options.Platform.OS == "" { options.Platform = platform.Current() } @@ -37,7 +46,10 @@ func NewUseCases(options StatusOptions) *UseCases { if options.Lookup == nil { options.Lookup = defaultLookup } - return &UseCases{status: options} + if client == nil { + client = provider.NewClient(nil) + } + return &UseCases{status: options, provider: client} } func NewUseCasesFromEnvironment() *UseCases { diff --git a/internal/binding/services.go b/internal/binding/services.go index 88fcda01..fdc027ba 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -10,6 +10,7 @@ import ( "github.com/MaimoryLab/OneAgent/internal/app" "github.com/MaimoryLab/OneAgent/internal/catalog" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/provider" ) type Services struct { @@ -22,7 +23,7 @@ type Services struct { func NewServices(core *app.UseCases, opener BrowserOpener) *Services { return &Services{ Status: &StatusService{core: core}, - Provider: &ProviderService{opener: opener}, + Provider: NewProviderService(core, opener), Agent: &AgentService{}, Profile: &ProfileService{}, } @@ -43,6 +44,11 @@ type BrowserOpener func(string) error type ProviderService struct { opener BrowserOpener + core *app.UseCases +} + +func NewProviderService(core *app.UseCases, opener BrowserOpener) *ProviderService { + return &ProviderService{opener: opener, core: core} } func (s *ProviderService) ListProviders(ctx context.Context) (map[string]catalog.Provider, error) { @@ -56,14 +62,39 @@ func (s *ProviderService) Probe(ctx context.Context, request ProbeRequest) (Prob if err := contextError(ctx); err != nil { return ProbeResponse{}, err } - return ProbeResponse{}, notReady("Provider probing is not available in the migration foundation") + if s == nil || s.core == nil { + return ProbeResponse{}, notReady("Provider probing is not configured") + } + result, err := s.core.ProbeProvider(ctx, app.ProviderProbeOptions{ + Provider: request.Provider, + APIBaseURL: request.APIBaseURL, + APIKey: request.APIKey, + Model: request.Model, + AgentIDs: request.Agents, + }) + if err != nil { + return ProbeResponse{}, err + } + response := probeResponse(result.Primary) + response.Protocols = make(map[string]ProbeResponse, len(result.Protocols)) + for protocolID, protocolResult := range result.Protocols { + response.Protocols[protocolID] = probeResponse(protocolResult) + } + return response, nil } func (s *ProviderService) ListModels(ctx context.Context, request ModelsRequest) (ModelsResponse, error) { if err := contextError(ctx); err != nil { return ModelsResponse{}, err } - return ModelsResponse{}, notReady("Model discovery is not available in the migration foundation") + if s == nil || s.core == nil { + return ModelsResponse{}, notReady("Model discovery is not configured") + } + result, err := s.core.ListProviderModels(ctx, request.Provider, request.APIKey, request.APIBaseURL) + if err != nil { + return ModelsResponse{}, err + } + return modelsResponse(result), nil } func (s *ProviderService) OpenRegistration(ctx context.Context, request OpenRegistrationRequest) (OpenRegistrationResponse, error) { @@ -145,13 +176,14 @@ type OpenRegistrationResponse struct { } type ProbeResponse struct { - OK bool `json:"ok"` - Reachable bool `json:"reachable"` - Status int `json:"status"` - Message string `json:"message"` - ErrorCode string `json:"error_code"` - Retryable bool `json:"retryable"` - Protocol string `json:"protocol"` + OK bool `json:"ok"` + Reachable bool `json:"reachable"` + Status int `json:"status"` + Message string `json:"message"` + ErrorCode *string `json:"error_code"` + Retryable bool `json:"retryable"` + Protocol *string `json:"protocol,omitempty"` + Protocols map[string]ProbeResponse `json:"protocols,omitempty"` } type ModelsResponse struct { @@ -224,3 +256,30 @@ func contextError(ctx context.Context) error { func notReady(message string) error { return oneerrors.New(oneerrors.InternalError, strings.TrimSpace(message), oneerrors.WithStatus(501)) } + +func probeResponse(result provider.ProbeResult) ProbeResponse { + return ProbeResponse{ + OK: result.OK, + Reachable: result.Reachable, + Status: result.Status, + Message: result.Message, + ErrorCode: result.ErrorCode, + Retryable: result.Retryable, + Protocol: result.Protocol, + } +} + +func modelsResponse(result provider.ModelsResult) ModelsResponse { + return ModelsResponse{ + ProbeResponse: ProbeResponse{ + OK: result.OK, + Reachable: result.Reachable, + Status: result.Status, + Message: result.Message, + ErrorCode: result.ErrorCode, + Retryable: result.Retryable, + Protocol: result.Protocol, + }, + Models: result.Models, + } +} diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index 36fd4cb1..7cd2551c 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -2,13 +2,39 @@ package binding import ( "context" + "encoding/json" + "io" + "net/http" "reflect" "sort" + "strings" "testing" + "github.com/MaimoryLab/OneAgent/internal/app" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/provider" ) +type providerFakeDoer func(*http.Request) (*http.Response, error) + +func (fake providerFakeDoer) Do(request *http.Request) (*http.Response, error) { + return fake(request) +} + +func providerResponse(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)} +} + +func providerCore(t *testing.T, client *provider.Client) *app.UseCases { + t.Helper() + return app.NewUseCasesWithProviderClient(app.StatusOptions{ + Home: t.TempDir(), + Platform: platform.For("linux", "amd64"), + Lookup: func(string) (string, bool) { return "", false }, + }, client) +} + func TestServiceMethodAllowlist(t *testing.T) { tests := []struct { service any @@ -61,3 +87,73 @@ func TestServiceCancellationUsesStableTimeoutCode(t *testing.T) { t.Fatalf("cancellation error = %v", err) } } + +func TestProviderServiceAggregatesSelectedAgentProtocols(t *testing.T) { + seen := make([]string, 0) + client := provider.NewClient(providerFakeDoer(func(request *http.Request) (*http.Response, error) { + seen = append(seen, request.URL.Path) + return providerResponse(http.StatusNoContent, ""), nil + })) + service := NewProviderService(providerCore(t, client), nil) + result, err := service.Probe(context.Background(), ProbeRequest{ + Provider: "custom", + APIBaseURL: "https://proxy.test/v1", + APIKey: "key", + Model: "model", + Agents: []string{"codex", "claude-code", "opencode"}, + }) + if err != nil { + t.Fatal(err) + } + if !result.OK || len(result.Protocols) != 3 || result.ErrorCode != nil { + t.Fatalf("aggregated probe = %#v", result) + } + sort.Strings(seen) + if !reflect.DeepEqual(seen, []string{"/v1/chat/completions", "/v1/messages", "/v1/responses"}) { + t.Fatalf("probe paths = %v", seen) + } + if result.Protocol == nil || *result.Protocol != "anthropic" { + t.Fatalf("primary protocol = %v", result.Protocol) + } + wire, err := json.Marshal(result) + if err != nil || strings.Contains(string(wire), "key") { + t.Fatalf("probe response leaked input: %s (%v)", wire, err) + } + if strings.Count(string(wire), `"protocols"`) != 1 { + t.Fatalf("nested probe results must omit empty protocol maps: %s", wire) + } +} + +func TestProviderServiceUsesStableFailureAsPrimary(t *testing.T) { + client := provider.NewClient(providerFakeDoer(func(request *http.Request) (*http.Response, error) { + if request.URL.Path == "/v1/responses" { + return providerResponse(http.StatusBadRequest, `{"message":"does not support endpoint"}`), nil + } + return providerResponse(http.StatusNoContent, ""), nil + })) + service := NewProviderService(providerCore(t, client), nil) + result, err := service.Probe(context.Background(), ProbeRequest{ + Provider: "custom", APIBaseURL: "https://proxy.test/v1", APIKey: "key", Model: "model", Agents: []string{"codex", "opencode"}, + }) + if err != nil || result.OK || result.ErrorCode == nil || *result.ErrorCode != oneerrors.ProtocolUnsupported { + t.Fatalf("failure probe = %#v, err=%v", result, err) + } + if len(result.Protocols) != 2 || result.Protocols["responses"].OK { + t.Fatalf("protocol details = %#v", result.Protocols) + } +} + +func TestProviderServiceListsModels(t *testing.T) { + client := provider.NewClient(providerFakeDoer(func(*http.Request) (*http.Response, error) { + return providerResponse(http.StatusOK, `{"data":[{"id":"chat-model"}]}`), nil + })) + service := NewProviderService(providerCore(t, client), nil) + result, err := service.ListModels(context.Background(), ModelsRequest{Provider: "ppio", APIKey: "key"}) + if err != nil || !result.OK || !reflect.DeepEqual(result.Models, []string{"chat-model"}) { + t.Fatalf("models = %#v, err=%v", result, err) + } + wire, err := json.Marshal(result) + if err != nil || strings.Contains(string(wire), `"protocol"`) || !strings.Contains(string(wire), `"error_code":null`) { + t.Fatalf("model response null/omitted fields diverged: %s (%v)", wire, err) + } +} From b0e8a0d29e741f0d8aafff227ef96f09b6ce1fae Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:24:45 +0800 Subject: [PATCH 10/63] feat: project profiles through Go status services --- internal/app/status.go | 92 ++++++-- internal/app/status_test.go | 97 +++++++++ internal/binding/services.go | 15 +- internal/binding/services_test.go | 34 +++ internal/profile/store.go | 336 ++++++++++++++++++++++++++++++ internal/profile/store_test.go | 158 ++++++++++++++ 6 files changed, 716 insertions(+), 16 deletions(-) create mode 100644 internal/profile/store.go create mode 100644 internal/profile/store_test.go diff --git a/internal/app/status.go b/internal/app/status.go index 3e23a21e..5727cd0f 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -13,6 +13,7 @@ import ( "github.com/MaimoryLab/OneAgent/internal/catalog" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" "github.com/MaimoryLab/OneAgent/internal/platform" + profileStore "github.com/MaimoryLab/OneAgent/internal/profile" "github.com/MaimoryLab/OneAgent/internal/provider" ) @@ -27,6 +28,7 @@ type StatusOptions struct { type UseCases struct { status StatusOptions provider *provider.Client + profiles profileStore.Store } func NewUseCases(options StatusOptions) *UseCases { @@ -49,7 +51,11 @@ func NewUseCasesWithProviderClient(options StatusOptions, client *provider.Clien if client == nil { client = provider.NewClient(nil) } - return &UseCases{status: options, provider: client} + return &UseCases{ + status: options, + provider: client, + profiles: profileStore.NewStore(options.Home, options.Platform.OS), + } } func NewUseCasesFromEnvironment() *UseCases { @@ -132,6 +138,9 @@ type ProfileSummary struct { } func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { + if u == nil { + return StatusResponse{}, oneerrors.New(oneerrors.InternalError, "Status service is not configured", oneerrors.WithStatus(501)) + } if err := ctx.Err(); err != nil { return StatusResponse{}, oneerrors.New(oneerrors.Timeout, "Status request was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) } @@ -192,22 +201,79 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { CanInstall: canInstall, } } + profiles, activeProfile, environment, environmentError := u.profileStatus() return StatusResponse{ - APIVersion: 1, - Platform: options.Platform, - Capabilities: capabilities, - Agents: statuses, - Catalog: catalog.PublicCatalog(manifest, options.Platform.OS), - Groups: catalog.Groups(), - Providers: catalog.PublicProviders(), - Mirrors: catalog.Mirrors(), - Paths: paths, - Backups: backupState(options.Home, options.Platform.OS, manifest), - Profiles: []ProfileSummary{}, - Environment: nil, + APIVersion: 1, + Platform: options.Platform, + Capabilities: capabilities, + Agents: statuses, + Catalog: catalog.PublicCatalog(manifest, options.Platform.OS), + Groups: catalog.Groups(), + Providers: catalog.PublicProviders(), + Mirrors: catalog.Mirrors(), + Paths: paths, + Backups: backupState(options.Home, options.Platform.OS, manifest), + Profiles: profiles, + ActiveProfile: activeProfile, + Environment: environment, + EnvironmentError: environmentError, }, nil } +// ListProfiles returns only the public profile projection. Secret files are +// checked for existence by the profile store, but their contents never enter +// this use case or its transport DTOs. +func (u *UseCases) ListProfiles(ctx context.Context) ([]ProfileSummary, error) { + if u == nil { + return nil, oneerrors.New(oneerrors.InternalError, "Profile service is not configured", oneerrors.WithStatus(501)) + } + if err := ctx.Err(); err != nil { + return nil, oneerrors.New(oneerrors.Timeout, "Profile request was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + return u.profileSummaries(), nil +} + +func (u *UseCases) profileStatus() ([]ProfileSummary, *string, any, *string) { + active := u.profiles.LoadActive() + // Load the active profile first so the status projection follows the same + // read ordering as the legacy implementation when a v1 pointer is present. + profiles := u.profileSummaries() + var activeID *string + if active.ID != "" { + id := active.ID + activeID = &id + } + var environment any + if active.Environment != nil { + environment = active.Environment + } + var environmentError *string + if active.Error != "" { + errorText := active.Error + environmentError = &errorText + } + return profiles, activeID, environment, environmentError +} + +func (u *UseCases) profileSummaries() []ProfileSummary { + stored := u.profiles.List() + result := make([]ProfileSummary, 0, len(stored)) + for _, item := range stored { + summary := item.Summary() + result = append(result, ProfileSummary{ + ID: summary.ID, + Label: summary.Label, + Provider: summary.Provider, + BaseURL: summary.BaseURL, + Model: summary.Model, + AgentIDs: summary.AgentIDs, + ActivatedAt: summary.ActivatedAt, + HasKey: summary.HasKey, + }) + } + return result +} + func envFilename(osID string) string { if osID == "windows" { return "env.ps1" diff --git a/internal/app/status_test.go b/internal/app/status_test.go index dce173ac..35c98cab 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -66,6 +66,103 @@ func TestStatusReportsExistingConfigWithoutWriting(t *testing.T) { } } +func TestStatusProjectsProfilesAndActiveEnvironmentWithoutSecrets(t *testing.T) { + home := t.TempDir() + oneagentDir := filepath.Join(home, ".oneagent") + profilesDir := filepath.Join(oneagentDir, "profiles") + if err := os.MkdirAll(profilesDir, 0o700); err != nil { + t.Fatal(err) + } + profileJSON := `{"schema_version":2,"id":"team","label":"Team","provider":"ppio","base_url":"https://api.ppio.com/openai","model":"model-a","config_mode":"provider","agent_ids":["codex","opencode"],"created_at":"created","activated_at":"active","api_key":"must-not-escape"}` + if err := os.WriteFile(filepath.Join(profilesDir, "team.json"), []byte(profileJSON), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(oneagentDir, "secrets"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oneagentDir, "secrets", "team.env"), []byte("export ONEAGENT_API_KEY=sk-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oneagentDir, "profile.json"), []byte(`{"schema_version":2,"active":"team"}`), 0o600); err != nil { + t.Fatal(err) + } + + core := NewUseCases(StatusOptions{ + Home: home, + Platform: platform.For("linux", "amd64"), + Lookup: func(string) (string, bool) { return "", false }, + }) + status, err := core.GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(status.Profiles) != 1 || status.Profiles[0].ID != "team" || !status.Profiles[0].HasKey { + t.Fatalf("profile summaries = %#v", status.Profiles) + } + if status.ActiveProfile == nil || *status.ActiveProfile != "team" { + t.Fatalf("active profile = %#v", status.ActiveProfile) + } + environment, ok := status.Environment.(map[string]any) + if !ok || environment["provider"] != "ppio" || environment["model"] != "model-a" { + t.Fatalf("environment projection = %#v", status.Environment) + } + if _, leaked := environment["api_key"]; leaked { + t.Fatalf("environment exposed a secret: %#v", environment) + } + wire, err := json.Marshal(status) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(wire), "sk-secret") || strings.Contains(string(wire), "must-not-escape") { + t.Fatalf("status contains secret material: %s", wire) + } +} + +func TestStatusKeepsLegacyProfileInMemoryAndReportsFailures(t *testing.T) { + home := t.TempDir() + oneagentDir := filepath.Join(home, ".oneagent") + if err := os.MkdirAll(oneagentDir, 0o700); err != nil { + t.Fatal(err) + } + legacy := `{"schema_version":1,"provider":"ppio","base_url":"https://api.ppio.com/openai","model":"legacy-model","config_mode":"provider","agent_ids":["codex"],"activated_at":"legacy-time"}` + if err := os.WriteFile(filepath.Join(oneagentDir, "profile.json"), []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + core := NewUseCases(StatusOptions{Home: home, Platform: platform.For("linux", "amd64"), Lookup: func(string) (string, bool) { return "", false }}) + status, err := core.GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status.ActiveProfile == nil || *status.ActiveProfile != "default" || status.Environment == nil || status.EnvironmentError != nil { + t.Fatalf("legacy status = %#v", status) + } + + if err := os.WriteFile(filepath.Join(oneagentDir, "profile.json"), []byte(`{"schema_version":2,"active":"ghost"}`), 0o600); err != nil { + t.Fatal(err) + } + status, err = core.GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status.ActiveProfile == nil || *status.ActiveProfile != "ghost" || status.Environment != nil || status.EnvironmentError == nil || !strings.Contains(*status.EnvironmentError, "ghost") { + t.Fatalf("missing profile status = %#v", status) + } + + if err := os.MkdirAll(filepath.Join(oneagentDir, "profiles"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oneagentDir, "profiles", "ghost.json"), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + status, err = core.GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if status.EnvironmentError == nil || status.Environment != nil { + t.Fatalf("corrupt profile status = %#v", status) + } +} + func TestStatusMatchesPythonEmptyLinuxARM64Fixture(t *testing.T) { home := t.TempDir() core := NewUseCases(StatusOptions{ diff --git a/internal/binding/services.go b/internal/binding/services.go index fdc027ba..45a77271 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -25,7 +25,7 @@ func NewServices(core *app.UseCases, opener BrowserOpener) *Services { Status: &StatusService{core: core}, Provider: NewProviderService(core, opener), Agent: &AgentService{}, - Profile: &ProfileService{}, + Profile: NewProfileService(core), } } @@ -134,13 +134,22 @@ func (s *AgentService) Activate(ctx context.Context, request ActivateRequest) (A return ActivateResponse{}, notReady("Agent activation is not available in the migration foundation") } -type ProfileService struct{} +type ProfileService struct { + core *app.UseCases +} + +func NewProfileService(core *app.UseCases) *ProfileService { + return &ProfileService{core: core} +} func (s *ProfileService) ListProfiles(ctx context.Context) ([]app.ProfileSummary, error) { if err := contextError(ctx); err != nil { return nil, err } - return []app.ProfileSummary{}, nil + if s == nil || s.core == nil { + return nil, notReady("Profile listing is not configured") + } + return s.core.ListProfiles(ctx) } func (s *ProfileService) SaveProfile(ctx context.Context, request SaveProfileRequest) (app.ProfileSummary, error) { diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index 7cd2551c..e8d45fab 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "io" "net/http" + "os" + "path/filepath" "reflect" "sort" "strings" @@ -88,6 +90,38 @@ func TestServiceCancellationUsesStableTimeoutCode(t *testing.T) { } } +func TestProfileServiceListsPublicSummaries(t *testing.T) { + home := t.TempDir() + profilesDir := filepath.Join(home, ".oneagent", "profiles") + if err := os.MkdirAll(profilesDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(profilesDir, "team.json"), []byte(`{"schema_version":2,"id":"team","label":"Team","provider":"ppio","base_url":null,"model":"model","config_mode":"provider","agent_ids":["codex"]}`), 0o600); err != nil { + t.Fatal(err) + } + secretDir := filepath.Join(home, ".oneagent", "secrets") + if err := os.MkdirAll(secretDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(secretDir, "team.env"), []byte("export ONEAGENT_API_KEY=sk-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + core := app.NewUseCases(app.StatusOptions{ + Home: home, + Platform: platform.For("linux", "amd64"), + Lookup: func(string) (string, bool) { return "", false }, + }) + service := NewProfileService(core) + profiles, err := service.ListProfiles(context.Background()) + if err != nil || len(profiles) != 1 || profiles[0].ID != "team" || !profiles[0].HasKey { + t.Fatalf("profiles = %#v, err=%v", profiles, err) + } + wire, err := json.Marshal(profiles) + if err != nil || strings.Contains(string(wire), "sk-secret") || strings.Contains(string(wire), "api_key") { + t.Fatalf("profile listing leaked secret data: %s (%v)", wire, err) + } +} + func TestProviderServiceAggregatesSelectedAgentProtocols(t *testing.T) { seen := make([]string, 0) client := provider.NewClient(providerFakeDoer(func(request *http.Request) (*http.Response, error) { diff --git a/internal/profile/store.go b/internal/profile/store.go new file mode 100644 index 00000000..b123bbb4 --- /dev/null +++ b/internal/profile/store.go @@ -0,0 +1,336 @@ +// Package profile reads the on-disk profile store without exposing secret +// contents. Write and migration primitives are added separately once securefs +// has its platform-specific tests. +package profile + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" +) + +var profileIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) + +type Store struct { + Home string + OS string +} + +type Profile struct { + SchemaVersion int + ID string + Label string + Provider string + BaseURL *string + Model *string + ConfigMode string + AgentIDs []string + CreatedAt string + ActivatedAt *string + HasKey bool +} + +type Summary struct { + ID string + Label string + Provider string + BaseURL *string + Model *string + AgentIDs []string + ActivatedAt *string + HasKey bool +} + +type ActiveResult struct { + Profile *Profile + Environment map[string]any + ID string + Error string +} + +type storedProfile struct { + SchemaVersion int `json:"schema_version"` + ID string `json:"id"` + Label string `json:"label"` + Provider string `json:"provider"` + BaseURL *string `json:"base_url"` + Model *string `json:"model"` + ConfigMode string `json:"config_mode"` + AgentIDs []string `json:"agent_ids"` + CreatedAt string `json:"created_at"` + ActivatedAt *string `json:"activated_at"` +} + +func NewStore(home, osID string) Store { + return Store{Home: home, OS: osID} +} + +func ValidateID(id string) error { + if !profileIDPattern.MatchString(id) { + return oneerrors.New(oneerrors.InvalidRequest, "Profile ID must start with a lowercase letter or digit and use only lowercase letters, digits, '-' or '_'") + } + return nil +} + +func (s Store) Root() string { + return filepath.Join(s.Home, ".oneagent") +} + +func (s Store) PointerPath() string { + return filepath.Join(s.Root(), "profile.json") +} + +func (s Store) ProfilesPath() string { + return filepath.Join(s.Root(), "profiles") +} + +func (s Store) ProfilePath(id string) (string, error) { + if err := ValidateID(id); err != nil { + return "", err + } + return filepath.Join(s.ProfilesPath(), id+".json"), nil +} + +func (s Store) SecretPath(id string) (string, error) { + if err := ValidateID(id); err != nil { + return "", err + } + suffix := "env" + if s.OS == "windows" { + suffix = "env.ps1" + } + return filepath.Join(s.Root(), "secrets", id+"."+suffix), nil +} + +func (s Store) List() []Profile { + entries, err := os.ReadDir(s.ProfilesPath()) + if err != nil { + return []Profile{} + } + profiles := make([]Profile, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + path := filepath.Join(s.ProfilesPath(), entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + profile, err := decodeStored(data) + if err != nil || ValidateID(profile.ID) != nil { + continue + } + secret, err := s.SecretPath(profile.ID) + if err == nil { + _, statErr := os.Stat(secret) + profile.HasKey = statErr == nil + } + profiles = append(profiles, profile) + } + return profiles +} + +func (s Store) LoadActive() ActiveResult { + data, err := os.ReadFile(s.PointerPath()) + if os.IsNotExist(err) { + return ActiveResult{} + } + if err != nil { + return ActiveResult{Error: err.Error()} + } + var pointer map[string]any + if err := json.Unmarshal(data, &pointer); err != nil { + return ActiveResult{Error: err.Error()} + } + schema, ok := integerField(pointer["schema_version"]) + if !ok { + return ActiveResult{Error: "Unsupported environment profile schema"} + } + if schema == 1 { + profile := migrateLegacy(pointer) + return ActiveResult{Profile: &profile, Environment: profile.environment(), ID: profile.ID} + } + if schema != 2 { + return ActiveResult{Error: "Unsupported environment profile schema"} + } + active, ok := pointer["active"].(string) + if !ok { + return ActiveResult{Error: "Unsupported environment profile schema"} + } + if err := ValidateID(active); err != nil { + return ActiveResult{Error: err.Error()} + } + result := ActiveResult{ID: active} + path, err := s.ProfilePath(active) + if err != nil { + result.Error = err.Error() + return result + } + data, err = os.ReadFile(path) + if os.IsNotExist(err) { + result.Error = fmt.Sprintf("Profile %s is missing", active) + return result + } + if err != nil { + result.Error = err.Error() + return result + } + profile, err := decodeStored(data) + if err != nil { + result.Error = fmt.Sprintf("Profile %s is corrupt", active) + return result + } + if profile.ID == "" || profile.ID != active { + result.Error = fmt.Sprintf("Profile %s is corrupt", active) + return result + } + if err := ValidateID(profile.ID); err != nil { + result.Error = err.Error() + return result + } + result.Profile = &profile + result.Environment = profile.environment() + return result +} + +func (p Profile) Summary() Summary { + return Summary{ + ID: p.ID, + Label: valueOr(p.Label, p.ID), + Provider: p.Provider, + BaseURL: p.BaseURL, + Model: p.Model, + AgentIDs: cloneStrings(p.AgentIDs), + ActivatedAt: p.ActivatedAt, + HasKey: p.HasKey, + } +} + +func (p Profile) environment() map[string]any { + return map[string]any{ + "schema_version": p.SchemaVersion, + "id": p.ID, + "label": p.Label, + "provider": p.Provider, + "base_url": optionalStringValue(p.BaseURL), + "model": optionalStringValue(p.Model), + "config_mode": p.ConfigMode, + "agent_ids": cloneStrings(p.AgentIDs), + "created_at": p.CreatedAt, + "activated_at": optionalStringValue(p.ActivatedAt), + } +} + +func decodeStored(data []byte) (Profile, error) { + var stored storedProfile + if err := json.Unmarshal(data, &stored); err != nil { + return Profile{}, err + } + if stored.ID == "" { + return Profile{}, fmt.Errorf("profile has no id") + } + if stored.SchemaVersion != 2 { + return Profile{}, fmt.Errorf("Unsupported profile schema") + } + return Profile{ + SchemaVersion: stored.SchemaVersion, + ID: stored.ID, + Label: stored.Label, + Provider: stored.Provider, + BaseURL: stored.BaseURL, + Model: stored.Model, + ConfigMode: stored.ConfigMode, + AgentIDs: cloneStrings(stored.AgentIDs), + CreatedAt: stored.CreatedAt, + ActivatedAt: stored.ActivatedAt, + }, nil +} + +func migrateLegacy(pointer map[string]any) Profile { + activated, _ := pointer["activated_at"].(string) + configMode, _ := pointer["config_mode"].(string) + if configMode == "" { + configMode = "provider" + } + return Profile{ + SchemaVersion: 2, + ID: "default", + Label: "default", + Provider: stringField(pointer["provider"]), + BaseURL: pointerString(pointer["base_url"]), + Model: pointerString(pointer["model"]), + ConfigMode: configMode, + AgentIDs: stringSlice(pointer["agent_ids"]), + CreatedAt: activated, + ActivatedAt: stringPointer(activated), + } +} + +func integerField(value any) (int, bool) { + number, ok := value.(float64) + return int(number), ok && number == float64(int(number)) +} + +func stringField(value any) string { + valueString, _ := value.(string) + return valueString +} + +func pointerString(value any) *string { + if value == nil { + return nil + } + valueString, ok := value.(string) + if !ok { + return nil + } + return &valueString +} + +func stringPointer(value string) *string { + if value == "" { + return nil + } + return &value +} + +func stringSlice(value any) []string { + items, ok := value.([]any) + if !ok { + return []string{} + } + result := make([]string, 0, len(items)) + for _, item := range items { + if valueString, ok := item.(string); ok { + result = append(result, valueString) + } + } + return result +} + +func cloneStrings(values []string) []string { + if len(values) == 0 { + return []string{} + } + return append([]string{}, values...) +} + +func optionalStringValue(value *string) any { + if value == nil { + return nil + } + return *value +} + +func valueOr(value, fallback string) string { + if value == "" { + return fallback + } + return value +} diff --git a/internal/profile/store_test.go b/internal/profile/store_test.go new file mode 100644 index 00000000..30bb557c --- /dev/null +++ b/internal/profile/store_test.go @@ -0,0 +1,158 @@ +package profile + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +func writeProfileFixture(t *testing.T, store Store, id, content string) { + t.Helper() + path, err := store.ProfilePath(id) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestValidateIDAndPathsRejectTraversal(t *testing.T) { + store := NewStore(t.TempDir(), "linux") + for _, id := range []string{"", "Upper", "-lead", "a" + string(make([]byte, 65)), "../escape", "a/b"} { + if err := ValidateID(id); err == nil { + t.Errorf("ValidateID(%q) unexpectedly succeeded", id) + } + if _, err := store.ProfilePath(id); err == nil { + t.Errorf("ProfilePath(%q) unexpectedly succeeded", id) + } + } + if got, err := store.SecretPath("team_one"); err != nil || filepath.Base(got) != "team_one.env" { + t.Fatalf("SecretPath() = %q, %v", got, err) + } + if got, err := NewStore(t.TempDir(), "windows").SecretPath("team_one"); err != nil || filepath.Base(got) != "team_one.env.ps1" { + t.Fatalf("Windows SecretPath() = %q, %v", got, err) + } +} + +func TestEmptyStoreAndStableListProjection(t *testing.T) { + store := NewStore(t.TempDir(), "linux") + if got := store.List(); len(got) != 0 { + t.Fatalf("empty List() = %#v", got) + } + if result := store.LoadActive(); result.Profile != nil || result.ID != "" || result.Error != "" { + t.Fatalf("empty LoadActive() = %#v", result) + } + writeProfileFixture(t, store, "b-profile", `{"schema_version":2,"id":"b-profile","label":"B","provider":"novita","base_url":null,"model":"m2","config_mode":"provider","agent_ids":["aider"],"created_at":"t2","activated_at":null}`) + writeProfileFixture(t, store, "a-profile", `{"schema_version":2,"id":"a-profile","label":"A","provider":"ppio","base_url":"https://api.ppio.com/openai","model":"m1","config_mode":"provider","agent_ids":["codex"],"created_at":"t1","activated_at":"t1"}`) + secret, err := store.SecretPath("a-profile") + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(secret), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(secret, []byte("export ONEAGENT_API_KEY=hidden\n"), 0o600); err != nil { + t.Fatal(err) + } + profiles := store.List() + if len(profiles) != 2 || profiles[0].ID != "a-profile" || profiles[1].ID != "b-profile" || !profiles[0].HasKey || profiles[1].HasKey { + t.Fatalf("stable profile list = %#v", profiles) + } + summary := profiles[0].Summary() + if summary.HasKey != true || summary.BaseURL == nil || *summary.BaseURL != "https://api.ppio.com/openai" { + t.Fatalf("summary = %#v", summary) + } +} + +func TestLoadActiveV2PreservesPointerAndStripsSecrets(t *testing.T) { + store := NewStore(t.TempDir(), "linux") + writeProfileFixture(t, store, "team", `{"schema_version":2,"id":"team","label":"Team","provider":"ppio","base_url":"https://api.ppio.com/openai","model":"m","config_mode":"provider","agent_ids":["codex"],"created_at":"created","activated_at":"active","api_key":"must-not-escape"}`) + if err := os.MkdirAll(store.Root(), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store.PointerPath(), []byte(`{"schema_version":2,"active":"team"}`), 0o600); err != nil { + t.Fatal(err) + } + result := store.LoadActive() + if result.Error != "" || result.ID != "team" || result.Profile == nil { + t.Fatalf("active result = %#v", result) + } + if result.Environment["api_key"] != nil { + t.Fatalf("environment exposed a secret: %#v", result.Environment) + } + if result.Environment["provider"] != "ppio" || result.Environment["model"] != "m" { + t.Fatalf("environment projection = %#v", result.Environment) + } +} + +func TestLoadActiveLegacyAndFailures(t *testing.T) { + store := NewStore(t.TempDir(), "linux") + if err := os.MkdirAll(store.Root(), 0o700); err != nil { + t.Fatal(err) + } + legacy := map[string]any{ + "schema_version": 1, + "provider": "ppio", + "base_url": "https://api.ppio.com/openai", + "model": "m", + "config_mode": "provider", + "agent_ids": []string{"codex"}, + "activated_at": "2026-07-22T00:00:00Z", + } + data, _ := json.Marshal(legacy) + if err := os.WriteFile(store.PointerPath(), data, 0o600); err != nil { + t.Fatal(err) + } + result := store.LoadActive() + if result.Error != "" || result.ID != "default" || result.Profile == nil || result.Profile.Provider != "ppio" { + t.Fatalf("legacy result = %#v", result) + } + if result.Profile.CreatedAt != "2026-07-22T00:00:00Z" { + t.Fatalf("legacy created_at = %q", result.Profile.CreatedAt) + } + if err := os.WriteFile(store.PointerPath(), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + if result := store.LoadActive(); result.Error == "" || result.Profile != nil { + t.Fatalf("invalid JSON result = %#v", result) + } + if err := os.WriteFile(store.PointerPath(), []byte(`{"schema_version":2,"active":"ghost"}`), 0o600); err != nil { + t.Fatal(err) + } + result = store.LoadActive() + if result.ID != "ghost" || result.Profile != nil || result.Error != "Profile ghost is missing" { + t.Fatalf("missing profile result = %#v", result) + } + writeProfileFixture(t, store, "ghost", `{"schema_version":2,"id":"other"}`) + result = store.LoadActive() + if result.Profile != nil || result.Error != "Profile ghost is corrupt" { + t.Fatalf("mismatched profile result = %#v", result) + } +} + +func TestListSkipsCorruptAndSecretContentsNeverEnterSummary(t *testing.T) { + store := NewStore(t.TempDir(), "linux") + if err := os.MkdirAll(store.ProfilesPath(), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(store.ProfilesPath(), "bad.json"), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(store.ProfilesPath(), "noid.json"), []byte(`{"schema_version":2,"provider":"ppio"}`), 0o600); err != nil { + t.Fatal(err) + } + writeProfileFixture(t, store, "real", `{"schema_version":2,"id":"real","provider":"ppio","model":"m"}`) + profiles := store.List() + if len(profiles) != 1 || profiles[0].ID != "real" { + t.Fatalf("filtered profiles = %#v", profiles) + } + if got := profiles[0].Summary().AgentIDs; !reflect.DeepEqual(got, []string{}) { + t.Fatalf("summary AgentIDs = %#v, want an empty array", got) + } +} From ed79a42f73379e31aa585168d54fc82451f898a8 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:31:21 +0800 Subject: [PATCH 11/63] feat: add secure atomic filesystem primitives --- internal/securefs/securefs.go | 329 +++++++++++++++++++++++++++++ internal/securefs/securefs_test.go | 175 +++++++++++++++ 2 files changed, 504 insertions(+) create mode 100644 internal/securefs/securefs.go create mode 100644 internal/securefs/securefs_test.go diff --git a/internal/securefs/securefs.go b/internal/securefs/securefs.go new file mode 100644 index 00000000..0d996fef --- /dev/null +++ b/internal/securefs/securefs.go @@ -0,0 +1,329 @@ +// Package securefs contains the small filesystem primitives used by profile +// and configuration writers. It deliberately does not know about any file +// format or credential name. +package securefs + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "os/user" + "path/filepath" + "runtime" + "strings" + "time" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" +) + +// CommandRunner is injectable so Windows ACL behavior can be tested without +// invoking a host command. argv[0] is the executable name. +type CommandRunner func(context.Context, []string) error + +// SecurePathFunc is an optional security hook used by fault-injection tests. +// The default implementation applies Unix modes or Windows ACLs. +type SecurePathFunc func(path string, directory bool) error + +type Options struct { + OS string + Username string + Now func() time.Time + Run CommandRunner + Secure SecurePathFunc +} + +type Store struct { + os string + username string + now func() time.Time + commandRunner CommandRunner + secure SecurePathFunc +} + +func New(options Options) Store { + platformID := options.OS + if platformID == "" { + platformID = runtime.GOOS + } + if options.Now == nil { + options.Now = time.Now + } + if options.Run == nil { + options.Run = runCommand + } + return Store{ + os: platformID, + username: options.Username, + now: options.Now, + commandRunner: options.Run, + secure: options.Secure, + } +} + +func (s Store) EnsurePrivateDir(ctx context.Context, path string) error { + if err := checkContext(ctx); err != nil { + return err + } + if path == "" { + return writeError("Cannot secure an empty directory path") + } + if err := os.MkdirAll(path, 0o700); err != nil { + return writeError("Cannot create private directory %s: %v", path, err) + } + info, err := os.Stat(path) + if err != nil { + return writeError("Cannot inspect private directory %s: %v", path, err) + } + if !info.IsDir() { + return writeError("Private path %s is not a directory", path) + } + if err := s.securePath(ctx, path, true); err != nil { + return err + } + return nil +} + +func (s Store) SecureFile(ctx context.Context, path string) error { + if err := checkContext(ctx); err != nil { + return err + } + if path == "" { + return writeError("Cannot secure an empty file path") + } + if err := s.securePath(ctx, path, false); err != nil { + return err + } + return nil +} + +// Backup copies an existing file beside itself. A missing source is not an +// error and returns an empty path. Callers decide whether the backup is a +// secret and therefore needs an additional permission check. +func (s Store) Backup(ctx context.Context, path string) (string, error) { + if err := checkContext(ctx); err != nil { + return "", err + } + info, err := os.Stat(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", writeError("Cannot inspect %s before backup: %v", path, err) + } + if info.IsDir() { + return "", writeError("Cannot back up directory %s", path) + } + candidate := s.backupName(path) + if err := copyFile(path, candidate, info.Mode().Perm()); err != nil { + return "", writeError("Cannot back up %s: %v", path, err) + } + return candidate, nil +} + +// AtomicWrite publishes content only after the temporary file has been +// written, synced, and secured. The temporary inode always lives beside the +// target so a successful rename cannot cross filesystems. +func (s Store) AtomicWrite(ctx context.Context, path string, content []byte, secret bool) (backup string, err error) { + if err = checkContext(ctx); err != nil { + return "", err + } + parent := filepath.Dir(path) + if err = s.EnsurePrivateDir(ctx, parent); err != nil { + return "", err + } + backup, err = s.Backup(ctx, path) + if err != nil { + return "", err + } + if backup != "" && secret { + if err = s.SecureFile(ctx, backup); err != nil { + if removeErr := os.Remove(backup); removeErr != nil { + return "", writeError("Cannot remove insecure secret backup %s: %v", backup, removeErr) + } + return "", err + } + } + + temporary, err := os.CreateTemp(parent, ".oneagent-tmp-") + if err != nil { + return backup, writeError("Cannot create temporary file for %s: %v", path, err) + } + temporaryPath := temporary.Name() + cleanup := func() error { + _ = temporary.Close() + if removeErr := os.Remove(temporaryPath); removeErr != nil && !os.IsNotExist(removeErr) { + return writeError("Cannot remove temporary %sfile %s: %v", secretWord(secret), temporaryPath, removeErr) + } + return nil + } + defer func() { + if cleanupErr := cleanup(); err == nil && cleanupErr != nil { + err = cleanupErr + } + }() + + if _, err = temporary.Write(content); err != nil { + return backup, writeError("Cannot write temporary file for %s: %v", path, err) + } + if err = temporary.Sync(); err != nil { + return backup, writeError("Cannot flush temporary file for %s: %v", path, err) + } + if err = temporary.Close(); err != nil { + return backup, writeError("Cannot close temporary file for %s: %v", path, err) + } + if err = s.SecureFile(ctx, temporaryPath); err != nil { + return backup, err + } + if err = checkContext(ctx); err != nil { + return backup, err + } + if err = os.Rename(temporaryPath, path); err != nil { + return backup, writeError("Cannot replace %s: %v", path, err) + } + return backup, nil +} + +func (s Store) securePath(ctx context.Context, path string, directory bool) error { + if err := checkContext(ctx); err != nil { + return err + } + if s.secure != nil { + if err := s.secure(path, directory); err != nil { + return err + } + return nil + } + if s.os != "windows" { + mode := os.FileMode(0o600) + if directory { + mode = 0o700 + } + if err := os.Chmod(path, mode); err != nil { + return writeError("Cannot secure %s: %v", path, err) + } + return nil + } + username := s.username + if username == "" { + username = os.Getenv("USERNAME") + } + if username == "" { + if current, err := user.Current(); err == nil { + username = current.Username + } + } + if username == "" { + return writeError("Cannot secure %s: Windows username is unavailable", path) + } + grants := username + ":F" + system := "*S-1-5-18:F" + if directory { + grants = username + ":(OI)(CI)F" + system = "*S-1-5-18:(OI)(CI)F" + } + for _, argv := range [][]string{ + {"icacls", path, "/reset"}, + {"icacls", path, "/inheritance:r", "/grant:r", grants, system}, + } { + if err := s.execute(ctx, argv); err != nil { + if ctx.Err() != nil { + return checkContext(ctx) + } + return writeError("Failed to secure Windows ACL for %s", path) + } + } + return nil +} + +func (s Store) backupName(path string) string { + now := s.now + if now == nil { + now = time.Now + } + stamp := now().UTC().Format("20060102150405") + base := path + ".backup-" + stamp + candidate := base + for counter := 1; ; counter++ { + if _, err := os.Lstat(candidate); os.IsNotExist(err) { + return candidate + } + candidate = fmt.Sprintf("%s-%d", base, counter) + } +} + +func (s Store) execute(ctx context.Context, argv []string) error { + if err := checkContext(ctx); err != nil { + return err + } + if s.commandRunner == nil { + return runCommand(ctx, argv) + } + return s.commandRunner(ctx, argv) +} + +func runCommand(ctx context.Context, argv []string) error { + if len(argv) == 0 || strings.TrimSpace(argv[0]) == "" { + return fmt.Errorf("empty command") + } + command := exec.CommandContext(ctx, argv[0], argv[1:]...) + command.Stdout = io.Discard + command.Stderr = io.Discard + return command.Run() +} + +func copyFile(source, destination string, mode os.FileMode) error { + input, err := os.Open(source) + if err != nil { + return err + } + defer input.Close() + output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + closeOutput := func() error { + if closeErr := output.Close(); closeErr != nil { + return closeErr + } + return nil + } + if _, err = io.Copy(output, input); err != nil { + _ = output.Close() + _ = os.Remove(destination) + return err + } + if err = output.Sync(); err != nil { + _ = output.Close() + _ = os.Remove(destination) + return err + } + if err = os.Chmod(destination, mode); err != nil { + _ = output.Close() + _ = os.Remove(destination) + return err + } + return closeOutput() +} + +func checkContext(ctx context.Context) error { + if ctx == nil { + return nil + } + if err := ctx.Err(); err != nil { + return oneerrors.New(oneerrors.Timeout, "Filesystem request was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + return nil +} + +func writeError(format string, values ...any) error { + return oneerrors.New(oneerrors.ConfigWriteFailed, fmt.Sprintf(format, values...)) +} + +func secretWord(secret bool) string { + if secret { + return "secret " + } + return "" +} diff --git a/internal/securefs/securefs_test.go b/internal/securefs/securefs_test.go new file mode 100644 index 00000000..00212328 --- /dev/null +++ b/internal/securefs/securefs_test.go @@ -0,0 +1,175 @@ +package securefs + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" +) + +func fixedClock() time.Time { + return time.Date(2026, time.July, 30, 12, 34, 56, 0, time.UTC) +} + +func TestAtomicWriteCreatesPrivateFileAndCollisionSafeBackup(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, ".oneagent", "profile.json") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + store := New(Options{OS: "linux", Now: fixedClock}) + backupPath, err := store.AtomicWrite(context.Background(), target, []byte("new"), true) + if err != nil { + t.Fatal(err) + } + if backupPath != target+".backup-20260730123456" { + t.Fatalf("backup path = %q", backupPath) + } + assertMode(t, filepath.Dir(target), 0o700) + assertMode(t, target, 0o600) + assertMode(t, backupPath, 0o600) + if got, _ := os.ReadFile(target); string(got) != "new" { + t.Fatalf("target content = %q", got) + } + if got, _ := os.ReadFile(backupPath); string(got) != "old" { + t.Fatalf("backup content = %q", got) + } + if err := os.WriteFile(target+".backup-20260730123456", []byte("collision"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := store.AtomicWrite(context.Background(), target, []byte("newer"), false); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(target + ".backup-20260730123456-1"); err != nil { + t.Fatalf("collision backup missing: %v", err) + } +} + +func TestSecretBackupSecurityFailureRemovesBackup(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "secret") + if err := os.WriteFile(target, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + store := New(Options{ + OS: "linux", + Now: fixedClock, + Secure: func(path string, directory bool) error { + if !directory && strings.Contains(path, ".backup-") { + return oneerrors.New(oneerrors.ConfigWriteFailed, "backup ACL failed") + } + return nil + }, + }) + _, err := store.AtomicWrite(context.Background(), target, []byte("new"), true) + if err == nil || !strings.Contains(err.Error(), "backup ACL failed") { + t.Fatalf("security failure = %v", err) + } + matches, _ := filepath.Glob(target + ".backup-*") + if len(matches) != 0 { + t.Fatalf("insecure backups remain: %v", matches) + } + if got, _ := os.ReadFile(target); string(got) != "old" { + t.Fatalf("original changed after security failure: %q", got) + } +} + +func TestTemporarySecurityFailureLeavesNoPublishedReplacement(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, ".oneagent", "new") + callCount := 0 + store := New(Options{ + OS: "linux", + Now: fixedClock, + Secure: func(path string, directory bool) error { + if !directory { + callCount++ + if callCount == 1 { + return oneerrors.New(oneerrors.ConfigWriteFailed, "temporary ACL failed") + } + } + return nil + }, + }) + _, err := store.AtomicWrite(context.Background(), target, []byte("replacement"), false) + if err == nil || !strings.Contains(err.Error(), "temporary ACL failed") { + t.Fatalf("temporary failure = %v", err) + } + if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { + t.Fatalf("replacement was published: %v", statErr) + } + entries, readErr := os.ReadDir(filepath.Dir(target)) + if readErr != nil { + t.Fatal(readErr) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".oneagent-tmp-") { + t.Fatalf("temporary file remains: %s", entry.Name()) + } + } +} + +func TestWindowsACLCommandsAreRestrictedAndContextAware(t *testing.T) { + var calls [][]string + store := New(Options{ + OS: "windows", + Username: "tester", + Run: func(_ context.Context, argv []string) error { + calls = append(calls, append([]string(nil), argv...)) + return nil + }, + }) + root := t.TempDir() + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + if err := store.SecureFile(context.Background(), filepath.Join(root, "secret.ps1")); err != nil { + t.Fatal(err) + } + if err := store.EnsurePrivateDir(context.Background(), filepath.Join(root, "nested")); err != nil { + t.Fatal(err) + } + if len(calls) != 4 { + t.Fatalf("ACL calls = %v", calls) + } + if !reflect.DeepEqual(calls[0], []string{"icacls", filepath.Join(root, "secret.ps1"), "/reset"}) || !reflect.DeepEqual(calls[1], []string{"icacls", filepath.Join(root, "secret.ps1"), "/inheritance:r", "/grant:r", "tester:F", "*S-1-5-18:F"}) { + t.Fatalf("file ACL calls = %v", calls[:2]) + } + if !strings.Contains(strings.Join(calls[3], " "), "tester:(OI)(CI)F") { + t.Fatalf("directory ACL grant = %v", calls[3]) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := store.SecureFile(ctx, filepath.Join(root, "cancelled")); err == nil || oneerrors.As(err).Code != oneerrors.Timeout { + t.Fatalf("cancelled ACL = %v", err) + } +} + +func TestAtomicWriteMapsFilesystemFailuresAndDoesNotLeakContent(t *testing.T) { + store := New(Options{OS: "linux"}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := store.AtomicWrite(ctx, filepath.Join(t.TempDir(), "missing", "target"), []byte("secret-value"), false) + if err == nil || oneerrors.As(err).Code != oneerrors.Timeout || strings.Contains(err.Error(), "secret-value") { + t.Fatalf("cancelled filesystem request = %v", err) + } +} + +func assertMode(t *testing.T, path string, want os.FileMode) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("%s mode = %o, want %o", path, got, want) + } +} From f6ebc28b18a1e2ab63a6305c364fff54bc147503 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:36:03 +0800 Subject: [PATCH 12/63] feat: add secure profile writes and secret storage --- internal/profile/store.go | 14 +- internal/profile/write.go | 394 +++++++++++++++++++++++++++++++++ internal/profile/write_test.go | 195 ++++++++++++++++ 3 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 internal/profile/write.go create mode 100644 internal/profile/write_test.go diff --git a/internal/profile/store.go b/internal/profile/store.go index b123bbb4..35dd6112 100644 --- a/internal/profile/store.go +++ b/internal/profile/store.go @@ -10,8 +10,10 @@ import ( "path/filepath" "regexp" "strings" + "time" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/securefs" ) var profileIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) @@ -19,6 +21,8 @@ var profileIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) type Store struct { Home string OS string + FS *securefs.Store + Now func() time.Time } type Profile struct { @@ -67,7 +71,15 @@ type storedProfile struct { } func NewStore(home, osID string) Store { - return Store{Home: home, OS: osID} + filesystem := securefs.New(securefs.Options{OS: osID}) + return Store{Home: home, OS: osID, FS: &filesystem, Now: time.Now} +} + +func NewStoreWithDependencies(home, osID string, filesystem securefs.Store, now func() time.Time) Store { + if now == nil { + now = time.Now + } + return Store{Home: home, OS: osID, FS: &filesystem, Now: now} } func ValidateID(id string) error { diff --git a/internal/profile/write.go b/internal/profile/write.go new file mode 100644 index 00000000..7409e562 --- /dev/null +++ b/internal/profile/write.go @@ -0,0 +1,394 @@ +package profile + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "time" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/provider" + "github.com/MaimoryLab/OneAgent/internal/securefs" +) + +type SaveRequest struct { + ID string + Label string + Provider string + BaseURL string + APIKey string + Model string + ConfigMode string + AgentIDs []string +} + +type ActiveRequest struct { + Agents []string + Configure bool + Provider string + BaseURL string + Model string + APIKey string +} + +// Save stores a reusable profile template without changing the active +// pointer. Credentials are written to the sibling secret store only. +func (s Store) Save(ctx context.Context, request SaveRequest) (Profile, error) { + if err := requestContext(ctx); err != nil { + return Profile{}, err + } + if err := ValidateID(request.ID); err != nil { + return Profile{}, err + } + model := strings.TrimSpace(request.Model) + if model == "" { + return Profile{}, oneerrors.New(oneerrors.InvalidRequest, "model is required") + } + mode, err := configMode(request.ConfigMode) + if err != nil { + return Profile{}, err + } + base, err := provider.ProviderBase(request.Provider, request.BaseURL) + if err != nil { + return Profile{}, err + } + agents, err := normalizeAgents(request.AgentIDs) + if err != nil { + return Profile{}, err + } + existing := s.existing(request.ID) + now := s.clock().UTC().Format(time.RFC3339) + label := strings.TrimSpace(request.Label) + if label == "" { + label = valueOr(existing.Label, request.ID) + } + created := existing.CreatedAt + if created == "" { + created = now + } + stored := storedProfile{ + SchemaVersion: 2, + ID: request.ID, + Label: label, + Provider: request.Provider, + BaseURL: optionalPointer(request.BaseURL, base), + Model: stringPointer(model), + ConfigMode: mode, + AgentIDs: agents, + CreatedAt: created, + ActivatedAt: existing.ActivatedAt, + } + if err := s.writeStored(ctx, stored); err != nil { + return Profile{}, err + } + if request.APIKey != "" && mode == "provider" { + if err := s.writeSecret(ctx, request.ID, request.APIKey, base); err != nil { + return Profile{}, err + } + } + profile := profileFromStored(stored) + profile.HasKey = s.secretExists(request.ID) + return profile, nil +} + +// WriteActive updates the v2 profile record and active pointer used by the +// installation workflow. It keeps the legacy v1 behavior in memory while the +// durable migration is implemented separately. +func (s Store) WriteActive(ctx context.Context, request ActiveRequest) (string, error) { + if err := requestContext(ctx); err != nil { + return "", err + } + agents, err := normalizeAgents(request.Agents) + if err != nil { + return "", err + } + currentResult := s.LoadActive() + current := Profile{} + if currentResult.Profile != nil { + current = *currentResult.Profile + } + profileID := "default" + if current.ID != "" { + profileID = current.ID + } + var baseURL *string + var model *string + resolvedBase := "" + providerID := "existing-account" + mode := "existing-account" + if request.Configure { + base, providerErr := provider.ProviderBase(request.Provider, request.BaseURL) + if providerErr != nil { + return "", providerErr + } + resolvedBase = base + providerID = request.Provider + mode = "provider" + baseURL = stringPointer(base) + modelValue := strings.TrimSpace(request.Model) + if modelValue == "" { + return "", oneerrors.New(oneerrors.InvalidRequest, "model is required") + } + model = stringPointer(modelValue) + } + if sameProfileTarget(current, providerID, mode, model) { + agents = mergeAgents(current.AgentIDs, agents) + } + now := s.clock().UTC().Format(time.RFC3339) + created := current.CreatedAt + if created == "" { + created = now + } + activated := stringPointer(now) + stored := storedProfile{ + SchemaVersion: 2, + ID: profileID, + Label: valueOr(current.Label, profileID), + Provider: providerID, + BaseURL: baseURL, + Model: model, + ConfigMode: mode, + AgentIDs: agents, + CreatedAt: created, + ActivatedAt: activated, + } + if err := s.writeStored(ctx, stored); err != nil { + return "", err + } + if request.Configure && request.APIKey != "" { + if err := s.writeSecret(ctx, profileID, request.APIKey, resolvedBase); err != nil { + return "", err + } + } + pointer := map[string]any{"schema_version": 2, "active": profileID} + data, err := json.MarshalIndent(pointer, "", " ") + if err != nil { + return "", writeError("Cannot encode active profile: %v", err) + } + data = append(data, '\n') + if _, err := s.filesystem().AtomicWrite(ctx, s.PointerPath(), data, false); err != nil { + return "", err + } + return s.PointerPath(), nil +} + +func (s Store) ReadSecret(ctx context.Context, id string) (string, error) { + if err := requestContext(ctx); err != nil { + return "", err + } + path, err := s.SecretPath(id) + if err != nil { + return "", err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", writeError("Cannot read stored key for profile %s: %v", id, err) + } + return parseSecret(string(data), s.OS), nil +} + +func (s Store) writeStored(ctx context.Context, stored storedProfile) error { + path, err := s.ProfilePath(stored.ID) + if err != nil { + return err + } + data, err := json.MarshalIndent(stored, "", " ") + if err != nil { + return writeError("Cannot encode profile %s: %v", stored.ID, err) + } + data = append(data, '\n') + _, err = s.filesystem().AtomicWrite(ctx, path, data, false) + return err +} + +func (s Store) writeSecret(ctx context.Context, id, apiKey, base string) error { + path, err := s.SecretPath(id) + if err != nil { + return err + } + content := secretContent(s.OS, apiKey, base) + _, err = s.filesystem().AtomicWrite(ctx, path, []byte(content), true) + return err +} + +func (s Store) existing(id string) Profile { + path, err := s.ProfilePath(id) + if err != nil { + return Profile{} + } + data, err := os.ReadFile(path) + if err != nil { + return Profile{} + } + profile, err := decodeStored(data) + if err != nil { + return Profile{} + } + return profile +} + +func (s Store) secretExists(id string) bool { + path, err := s.SecretPath(id) + if err != nil { + return false + } + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func (s Store) filesystem() securefs.Store { + if s.FS != nil { + return *s.FS + } + return securefs.New(securefs.Options{OS: s.OS}) +} + +func (s Store) clock() time.Time { + if s.Now == nil { + return time.Now() + } + return s.Now() +} + +func profileFromStored(stored storedProfile) Profile { + return Profile{ + SchemaVersion: stored.SchemaVersion, + ID: stored.ID, + Label: stored.Label, + Provider: stored.Provider, + BaseURL: stored.BaseURL, + Model: stored.Model, + ConfigMode: stored.ConfigMode, + AgentIDs: cloneStrings(stored.AgentIDs), + CreatedAt: stored.CreatedAt, + ActivatedAt: stored.ActivatedAt, + } +} + +func configMode(value string) (string, error) { + if value == "" { + return "provider", nil + } + if value != "provider" && value != "existing-account" { + return "", oneerrors.New(oneerrors.InvalidRequest, "config_mode must be provider or existing-account") + } + return value, nil +} + +func normalizeAgents(values []string) ([]string, error) { + if len(values) == 0 { + return nil, oneerrors.New(oneerrors.InvalidRequest, "agents must be a non-empty array of Agent IDs") + } + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + return nil, oneerrors.New(oneerrors.InvalidRequest, "agents must contain non-empty Agent IDs") + } + seen[value] = struct{}{} + } + result := make([]string, 0, len(seen)) + for value := range seen { + result = append(result, value) + } + sort.Strings(result) + return result, nil +} + +func mergeAgents(first, second []string) []string { + values := append(append([]string{}, first...), second...) + result, _ := normalizeAgents(values) + return result +} + +func sameProfileTarget(current Profile, providerID, mode string, model *string) bool { + if current.ID == "" || current.Provider != providerID || current.ConfigMode != mode { + return false + } + return optionalEqual(current.Model, model) +} + +func optionalEqual(first, second *string) bool { + if first == nil || second == nil { + return first == nil && second == nil + } + return *first == *second +} + +func optionalPointer(input, resolved string) *string { + if input == "" { + return nil + } + return stringPointer(resolved) +} + +func secretContent(osID, apiKey, base string) string { + if osID == "windows" { + return "$env:ONEAGENT_API_KEY = '" + powershellQuote(apiKey) + "'\n" + + "$env:ONEAGENT_API_BASE_URL = '" + powershellQuote(base) + "'\n" + } + return "export ONEAGENT_API_KEY=" + shellQuote(apiKey) + "\n" + + "export ONEAGENT_API_BASE_URL=" + shellQuote(base) + "\n" +} + +func shellQuote(value string) string { + if value != "" { + safe := true + for _, character := range value { + if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-", character) { + safe = false + break + } + } + if safe { + return value + } + } + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func powershellQuote(value string) string { + return strings.ReplaceAll(value, "'", "''") +} + +func parseSecret(content, osID string) string { + prefix := "export ONEAGENT_API_KEY=" + if osID == "windows" { + prefix = "$env:ONEAGENT_API_KEY = " + } + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, prefix) { + continue + } + value := strings.TrimSpace(strings.TrimPrefix(line, prefix)) + if osID == "windows" && len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' { + return strings.ReplaceAll(value[1:len(value)-1], "''", "'") + } + if strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'") { + return strings.ReplaceAll(value[1:len(value)-1], "'\\''", "'") + } + return value + } + return "" +} + +func requestContext(ctx context.Context) error { + if ctx == nil { + return nil + } + if err := ctx.Err(); err != nil { + return oneerrors.New(oneerrors.Timeout, "Profile request was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + return nil +} + +func writeError(format string, values ...any) error { + return oneerrors.New(oneerrors.ConfigWriteFailed, fmt.Sprintf(format, values...)) +} diff --git a/internal/profile/write_test.go b/internal/profile/write_test.go new file mode 100644 index 00000000..8601daf9 --- /dev/null +++ b/internal/profile/write_test.go @@ -0,0 +1,195 @@ +package profile + +import ( + "context" + "encoding/json" + "os" + "reflect" + "strings" + "testing" + "time" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/securefs" +) + +func testStore(t *testing.T, home, osID string) Store { + t.Helper() + filesystem := securefs.New(securefs.Options{OS: osID, Now: fixedProfileClock}) + return NewStoreWithDependencies(home, osID, filesystem, fixedProfileClock) +} + +func fixedProfileClock() time.Time { + return time.Date(2026, time.July, 30, 13, 14, 15, 0, time.UTC) +} + +func TestSaveProfileIsolatesSecretAndPreservesHistory(t *testing.T) { + home := t.TempDir() + store := testStore(t, home, "linux") + secret := "sk-quoted'value" + profile, err := store.Save(context.Background(), SaveRequest{ + ID: "ppio-deepseek", + Label: "Team PPIO", + Provider: "ppio", + Model: "deepseek-v3", + AgentIDs: []string{"opencode", "codex", "codex"}, + APIKey: secret, + }) + if err != nil { + t.Fatal(err) + } + if profile.ID != "ppio-deepseek" || !profile.HasKey || !reflect.DeepEqual(profile.AgentIDs, []string{"codex", "opencode"}) { + t.Fatalf("saved profile = %#v", profile) + } + profilePath, _ := store.ProfilePath("ppio-deepseek") + profileData, err := os.ReadFile(profilePath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(profileData), secret) || strings.Contains(string(profileData), "api_key") { + t.Fatalf("profile file leaked secret material: %s", profileData) + } + secretPath, _ := store.SecretPath("ppio-deepseek") + secretData, err := os.ReadFile(secretPath) + if err != nil || !strings.Contains(string(secretData), "ONEAGENT_API_KEY") { + t.Fatalf("secret file = %q, err=%v", secretData, err) + } + if got, err := store.ReadSecret(context.Background(), "ppio-deepseek"); err != nil || got != secret { + t.Fatalf("ReadSecret() = %q, %v", got, err) + } + if got := store.List(); len(got) != 1 || !got[0].HasKey { + t.Fatalf("List() = %#v", got) + } + created := profile.CreatedAt + updated, err := store.Save(context.Background(), SaveRequest{ + ID: "ppio-deepseek", + Provider: "ppio", + Model: "model-b", + AgentIDs: []string{"codex"}, + }) + if err != nil { + t.Fatal(err) + } + if updated.Label != "Team PPIO" || updated.CreatedAt != created || updated.Model == nil || *updated.Model != "model-b" || !updated.HasKey { + t.Fatalf("updated profile = %#v", updated) + } + assertProfileMode(t, profilePath, 0o600) + assertProfileMode(t, secretPath, 0o600) +} + +func TestSaveProfileValidatesInputAndCustomBase(t *testing.T) { + store := testStore(t, t.TempDir(), "linux") + for _, request := range []SaveRequest{ + {ID: "../bad", Provider: "ppio", Model: "m", AgentIDs: []string{"codex"}}, + {ID: "ok", Provider: "nope", Model: "m", AgentIDs: []string{"codex"}}, + {ID: "ok", Provider: "ppio", Model: "", AgentIDs: []string{"codex"}}, + {ID: "ok", Provider: "ppio", Model: "m", AgentIDs: nil}, + } { + if _, err := store.Save(context.Background(), request); err == nil || oneerrors.As(err).Code != oneerrors.InvalidRequest { + t.Errorf("invalid request %#v returned %v", request, err) + } + } + profile, err := store.Save(context.Background(), SaveRequest{ + ID: "custom-local", + Provider: "custom", + BaseURL: "http://127.0.0.1:9000/", + Model: "m", + AgentIDs: []string{"codex"}, + }) + if err != nil || profile.BaseURL == nil || *profile.BaseURL != "http://127.0.0.1:9000" { + t.Fatalf("custom profile = %#v, err=%v", profile, err) + } +} + +func TestWriteActiveMergesAgentsAndSupportsExistingAccount(t *testing.T) { + store := testStore(t, t.TempDir(), "linux") + if _, err := store.WriteActive(context.Background(), ActiveRequest{ + Agents: []string{"codex"}, Configure: true, Provider: "ppio", Model: "model-a", APIKey: "sk-a", + }); err != nil { + t.Fatal(err) + } + if _, err := store.WriteActive(context.Background(), ActiveRequest{ + Agents: []string{"opencode"}, Configure: true, Provider: "ppio", Model: "model-a", + }); err != nil { + t.Fatal(err) + } + active := store.LoadActive() + if active.Error != "" || active.Profile == nil || !reflect.DeepEqual(active.Profile.AgentIDs, []string{"codex", "opencode"}) { + t.Fatalf("merged active profile = %#v", active) + } + if active.Profile.BaseURL == nil || *active.Profile.BaseURL != "https://api.ppio.com/openai" { + t.Fatalf("active base URL = %#v", active.Profile.BaseURL) + } + if got, err := store.ReadSecret(context.Background(), "default"); err != nil || got != "sk-a" { + t.Fatalf("preserved active secret = %q, %v", got, err) + } + if _, err := store.WriteActive(context.Background(), ActiveRequest{ + Agents: []string{"aider"}, Configure: true, Provider: "novita", Model: "model-b", + }); err != nil { + t.Fatal(err) + } + active = store.LoadActive() + if active.Profile.Provider != "novita" || !reflect.DeepEqual(active.Profile.AgentIDs, []string{"aider"}) { + t.Fatalf("replaced active profile = %#v", active.Profile) + } + if _, err := store.WriteActive(context.Background(), ActiveRequest{Agents: []string{"codex"}, Configure: false}); err != nil { + t.Fatal(err) + } + active = store.LoadActive() + if active.Profile.ConfigMode != "existing-account" || active.Profile.Provider != "existing-account" || active.Profile.Model != nil || active.Profile.BaseURL != nil { + t.Fatalf("existing account profile = %#v", active.Profile) + } + var pointer map[string]any + data, _ := os.ReadFile(store.PointerPath()) + if err := json.Unmarshal(data, &pointer); err != nil || pointer["active"] != "default" || pointer["schema_version"] != float64(2) { + t.Fatalf("active pointer = %s, %v", data, err) + } +} + +func TestProfileWritesHonorCancellation(t *testing.T) { + store := testStore(t, t.TempDir(), "linux") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := store.Save(ctx, SaveRequest{ID: "team", Provider: "ppio", Model: "m", AgentIDs: []string{"codex"}}) + if err == nil || oneerrors.As(err).Code != oneerrors.Timeout { + t.Fatalf("cancelled Save() = %v", err) + } + if _, err := store.ReadSecret(ctx, "team"); err == nil || oneerrors.As(err).Code != oneerrors.Timeout { + t.Fatalf("cancelled ReadSecret() = %v", err) + } +} + +func TestWindowsSecretUsesPowerShellQuoting(t *testing.T) { + store := testStore(t, t.TempDir(), "windows") + // Replace the default securefs command runner with a no-op runner while + // preserving the Windows ACL argument construction. + filesystem := securefs.New(securefs.Options{ + OS: "windows", + Username: "tester", + Run: func(context.Context, []string) error { return nil }, + Now: fixedProfileClock, + }) + store = NewStoreWithDependencies(store.Home, "windows", filesystem, fixedProfileClock) + if _, err := store.Save(context.Background(), SaveRequest{ID: "win", Provider: "ppio", Model: "m", AgentIDs: []string{"codex"}, APIKey: "key'value"}); err != nil { + t.Fatal(err) + } + path, _ := store.SecretPath("win") + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), "$env:ONEAGENT_API_KEY = 'key''value'") { + t.Fatalf("PowerShell secret = %q", data) + } + if got, err := store.ReadSecret(context.Background(), "win"); err != nil || got != "key'value" { + t.Fatalf("Windows ReadSecret() = %q, %v", got, err) + } +} + +func assertProfileMode(t *testing.T, path string, want os.FileMode) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("%s mode = %o, want %o", path, got, want) + } +} From be23675c0ed8b0a4e49e8b3dcf53a3a736da2c41 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:38:25 +0800 Subject: [PATCH 13/63] feat: expose secure profile save through Wails service --- internal/app/status.go | 77 ++++++++++++++++++++++++++----- internal/app/status_test.go | 32 +++++++++++++ internal/binding/services.go | 14 +++++- internal/binding/services_test.go | 25 ++++++++++ 4 files changed, 135 insertions(+), 13 deletions(-) diff --git a/internal/app/status.go b/internal/app/status.go index 5727cd0f..3c9029b3 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -9,6 +9,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "github.com/MaimoryLab/OneAgent/internal/catalog" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" @@ -29,6 +30,7 @@ type UseCases struct { status StatusOptions provider *provider.Client profiles profileStore.Store + writeMu sync.Mutex } func NewUseCases(options StatusOptions) *UseCases { @@ -39,6 +41,14 @@ func NewUseCases(options StatusOptions) *UseCases { // Python production path remains active and Go behavior is verified with fake // transports. func NewUseCasesWithProviderClient(options StatusOptions, client *provider.Client) *UseCases { + return newUseCases(options, client, profileStore.Store{}) +} + +func NewUseCasesWithDependencies(options StatusOptions, client *provider.Client, profiles profileStore.Store) *UseCases { + return newUseCases(options, client, profiles) +} + +func newUseCases(options StatusOptions, client *provider.Client, profiles profileStore.Store) *UseCases { if options.Platform.OS == "" { options.Platform = platform.Current() } @@ -51,10 +61,13 @@ func NewUseCasesWithProviderClient(options StatusOptions, client *provider.Clien if client == nil { client = provider.NewClient(nil) } + if profiles.Home == "" { + profiles = profileStore.NewStore(options.Home, options.Platform.OS) + } return &UseCases{ status: options, provider: client, - profiles: profileStore.NewStore(options.Home, options.Platform.OS), + profiles: profiles, } } @@ -233,6 +246,42 @@ func (u *UseCases) ListProfiles(ctx context.Context) ([]ProfileSummary, error) { return u.profileSummaries(), nil } +type SaveProfileOptions struct { + ID string + Label string + Provider string + APIBaseURL string + APIKey string + Model string + ConfigMode string + AgentIDs []string +} + +func (u *UseCases) SaveProfile(ctx context.Context, options SaveProfileOptions) (ProfileSummary, error) { + if u == nil { + return ProfileSummary{}, oneerrors.New(oneerrors.InternalError, "Profile service is not configured", oneerrors.WithStatus(501)) + } + if err := ctx.Err(); err != nil { + return ProfileSummary{}, oneerrors.New(oneerrors.Timeout, "Profile request was cancelled", oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + u.writeMu.Lock() + defer u.writeMu.Unlock() + stored, err := u.profiles.Save(ctx, profileStore.SaveRequest{ + ID: options.ID, + Label: options.Label, + Provider: options.Provider, + BaseURL: options.APIBaseURL, + APIKey: options.APIKey, + Model: options.Model, + ConfigMode: options.ConfigMode, + AgentIDs: append([]string(nil), options.AgentIDs...), + }) + if err != nil { + return ProfileSummary{}, err + } + return profileSummary(stored), nil +} + func (u *UseCases) profileStatus() ([]ProfileSummary, *string, any, *string) { active := u.profiles.LoadActive() // Load the active profile first so the status projection follows the same @@ -259,21 +308,25 @@ func (u *UseCases) profileSummaries() []ProfileSummary { stored := u.profiles.List() result := make([]ProfileSummary, 0, len(stored)) for _, item := range stored { - summary := item.Summary() - result = append(result, ProfileSummary{ - ID: summary.ID, - Label: summary.Label, - Provider: summary.Provider, - BaseURL: summary.BaseURL, - Model: summary.Model, - AgentIDs: summary.AgentIDs, - ActivatedAt: summary.ActivatedAt, - HasKey: summary.HasKey, - }) + result = append(result, profileSummary(item)) } return result } +func profileSummary(item profileStore.Profile) ProfileSummary { + summary := item.Summary() + return ProfileSummary{ + ID: summary.ID, + Label: summary.Label, + Provider: summary.Provider, + BaseURL: summary.BaseURL, + Model: summary.Model, + AgentIDs: summary.AgentIDs, + ActivatedAt: summary.ActivatedAt, + HasKey: summary.HasKey, + } +} + func envFilename(osID string) string { if osID == "windows" { return "env.ps1" diff --git a/internal/app/status_test.go b/internal/app/status_test.go index 35c98cab..7b302aaa 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -163,6 +163,38 @@ func TestStatusKeepsLegacyProfileInMemoryAndReportsFailures(t *testing.T) { } } +func TestSaveProfileUseCaseWritesOnlyPublicSummary(t *testing.T) { + home := t.TempDir() + core := NewUseCases(StatusOptions{ + Home: home, + Platform: platform.For("linux", "amd64"), + Lookup: func(string) (string, bool) { return "", false }, + }) + summary, err := core.SaveProfile(context.Background(), SaveProfileOptions{ + ID: "team", + Label: "Team", + Provider: "ppio", + Model: "model-a", + APIKey: "sk-secret", + ConfigMode: "provider", + AgentIDs: []string{"opencode", "codex"}, + }) + if err != nil { + t.Fatal(err) + } + if summary.ID != "team" || !summary.HasKey || !reflect.DeepEqual(summary.AgentIDs, []string{"codex", "opencode"}) { + t.Fatalf("saved summary = %#v", summary) + } + status, err := core.GetStatus(context.Background()) + if err != nil || len(status.Profiles) != 1 || status.Profiles[0].ID != "team" { + t.Fatalf("status after save = %#v, err=%v", status, err) + } + wire, err := json.Marshal(summary) + if err != nil || strings.Contains(string(wire), "sk-secret") || strings.Contains(string(wire), "api_key") { + t.Fatalf("summary leaked secret data: %s (%v)", wire, err) + } +} + func TestStatusMatchesPythonEmptyLinuxARM64Fixture(t *testing.T) { home := t.TempDir() core := NewUseCases(StatusOptions{ diff --git a/internal/binding/services.go b/internal/binding/services.go index 45a77271..ebeab02a 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -156,7 +156,19 @@ func (s *ProfileService) SaveProfile(ctx context.Context, request SaveProfileReq if err := contextError(ctx); err != nil { return app.ProfileSummary{}, err } - return app.ProfileSummary{}, notReady("Profile writes are not available in the migration foundation") + if s == nil || s.core == nil { + return app.ProfileSummary{}, notReady("Profile service is not configured") + } + return s.core.SaveProfile(ctx, app.SaveProfileOptions{ + ID: request.ID, + Label: request.Label, + Provider: request.Provider, + APIBaseURL: request.APIBaseURL, + APIKey: request.APIKey, + Model: request.Model, + ConfigMode: request.ConfigMode, + AgentIDs: append([]string(nil), request.AgentIDs...), + }) } type ProbeRequest struct { diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index e8d45fab..7eb7a287 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -122,6 +122,31 @@ func TestProfileServiceListsPublicSummaries(t *testing.T) { } } +func TestProfileServiceSavesWithoutReturningSecret(t *testing.T) { + core := app.NewUseCases(app.StatusOptions{ + Home: t.TempDir(), + Platform: platform.For("linux", "amd64"), + Lookup: func(string) (string, bool) { return "", false }, + }) + service := NewProfileService(core) + summary, err := service.SaveProfile(context.Background(), SaveProfileRequest{ + ID: "team", + Label: "Team", + Provider: "ppio", + Model: "model-a", + APIKey: "sk-secret", + ConfigMode: "provider", + AgentIDs: []string{"codex"}, + }) + if err != nil || summary.ID != "team" || !summary.HasKey { + t.Fatalf("saved profile = %#v, err=%v", summary, err) + } + wire, err := json.Marshal(summary) + if err != nil || strings.Contains(string(wire), "sk-secret") || strings.Contains(string(wire), "api_key") { + t.Fatalf("binding response leaked secret data: %s (%v)", wire, err) + } +} + func TestProviderServiceAggregatesSelectedAgentProtocols(t *testing.T) { seen := make([]string, 0) client := provider.NewClient(providerFakeDoer(func(request *http.Request) (*http.Response, error) { From c76783425c510a3a2c2fdd0c85b91b2665973cd0 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:42:32 +0800 Subject: [PATCH 14/63] feat: persist legacy profiles as v2 --- internal/app/status.go | 17 +++--- internal/app/status_test.go | 2 +- internal/profile/migration_test.go | 86 ++++++++++++++++++++++++++++++ internal/profile/store.go | 64 ++++++++++++++++++++-- 4 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 internal/profile/migration_test.go diff --git a/internal/app/status.go b/internal/app/status.go index 3c9029b3..35dacf5a 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -1,6 +1,6 @@ -// Package app contains transport-independent use cases. Status and Provider -// reads are the first migrated slices; installation and config writes remain -// in Python until their Go equivalents pass their own gates. +// Package app contains transport-independent use cases. Status, Provider and +// profile reads/writes are migrated slices; installation and Agent config +// writes remain in Python until their Go equivalents pass their own gates. package app import ( @@ -214,7 +214,7 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { CanInstall: canInstall, } } - profiles, activeProfile, environment, environmentError := u.profileStatus() + profiles, activeProfile, environment, environmentError := u.profileStatus(ctx) return StatusResponse{ APIVersion: 1, Platform: options.Platform, @@ -282,8 +282,13 @@ func (u *UseCases) SaveProfile(ctx context.Context, options SaveProfileOptions) return profileSummary(stored), nil } -func (u *UseCases) profileStatus() ([]ProfileSummary, *string, any, *string) { - active := u.profiles.LoadActive() +func (u *UseCases) profileStatus(ctx context.Context) ([]ProfileSummary, *string, any, *string) { + // A v1 read can perform the one-time migration. Serialize that path with + // profile writes so the old pointer is backed up before another operation + // can publish a new profile state. + u.writeMu.Lock() + defer u.writeMu.Unlock() + active := u.profiles.LoadActiveContext(ctx) // Load the active profile first so the status projection follows the same // read ordering as the legacy implementation when a v1 pointer is present. profiles := u.profileSummaries() diff --git a/internal/app/status_test.go b/internal/app/status_test.go index 7b302aaa..e7d440e7 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -133,7 +133,7 @@ func TestStatusKeepsLegacyProfileInMemoryAndReportsFailures(t *testing.T) { if err != nil { t.Fatal(err) } - if status.ActiveProfile == nil || *status.ActiveProfile != "default" || status.Environment == nil || status.EnvironmentError != nil { + if status.ActiveProfile == nil || *status.ActiveProfile != "default" || status.Environment == nil || status.EnvironmentError != nil || len(status.Profiles) != 1 || status.Profiles[0].ID != "default" { t.Fatalf("legacy status = %#v", status) } diff --git a/internal/profile/migration_test.go b/internal/profile/migration_test.go new file mode 100644 index 00000000..f9672d15 --- /dev/null +++ b/internal/profile/migration_test.go @@ -0,0 +1,86 @@ +package profile + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/securefs" +) + +func TestLoadActiveMigratesV1WithBackupAndPublicProjection(t *testing.T) { + home := t.TempDir() + store := testStore(t, home, "linux") + if err := os.MkdirAll(store.Root(), 0o700); err != nil { + t.Fatal(err) + } + legacy := `{"schema_version":1,"provider":"ppio","base_url":"https://api.ppio.com/openai","model":"legacy-model","config_mode":"provider","agent_ids":["opencode","codex"],"activated_at":"2026-07-22T00:00:00Z","api_key":"must-not-copy"}` + if err := os.WriteFile(store.PointerPath(), []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + result := store.LoadActiveContext(context.Background()) + if result.Error != "" || result.Profile == nil || result.ID != "default" { + t.Fatalf("migration result = %#v", result) + } + if result.Profile.CreatedAt != "2026-07-22T00:00:00Z" || result.Environment["model"] != "legacy-model" { + t.Fatalf("migrated projection = %#v", result) + } + var pointer map[string]any + data, err := os.ReadFile(store.PointerPath()) + if err != nil || json.Unmarshal(data, &pointer) != nil || pointer["active"] != "default" || pointer["schema_version"] != float64(2) { + t.Fatalf("migrated pointer = %s, %v", data, err) + } + profilePath, _ := store.ProfilePath("default") + profileData, err := os.ReadFile(profilePath) + if err != nil || strings.Contains(string(profileData), "must-not-copy") { + t.Fatalf("migrated profile = %s, %v", profileData, err) + } + backups, _ := filepath.Glob(store.PointerPath() + ".backup-*") + if len(backups) != 1 { + t.Fatalf("legacy backups = %v", backups) + } + if listed := store.List(); len(listed) != 1 || listed[0].ID != "default" { + t.Fatalf("migrated list = %#v", listed) + } + // A second read follows the v2 pointer and does not create another backup. + if result := store.LoadActive(); result.Error != "" || result.ID != "default" { + t.Fatalf("second migrated read = %#v", result) + } + backups, _ = filepath.Glob(store.PointerPath() + ".backup-*") + if len(backups) != 1 { + t.Fatalf("second read changed backups = %v", backups) + } +} + +func TestLegacyMigrationFailureLeavesOriginalPointer(t *testing.T) { + home := t.TempDir() + filesystem := securefs.New(securefs.Options{ + OS: "linux", + Secure: func(path string, directory bool) error { + if directory && strings.HasSuffix(path, "profiles") { + return oneerrors.New(oneerrors.ConfigWriteFailed, "profiles are read-only") + } + return nil + }, + }) + store := NewStoreWithDependencies(home, "linux", filesystem, fixedProfileClock) + if err := os.MkdirAll(store.Root(), 0o700); err != nil { + t.Fatal(err) + } + original := []byte(`{"schema_version":1,"provider":"ppio","model":"m"}`) + if err := os.WriteFile(store.PointerPath(), original, 0o600); err != nil { + t.Fatal(err) + } + result := store.LoadActive() + if result.Profile != nil || !strings.Contains(result.Error, "Cannot migrate legacy profile") { + t.Fatalf("migration failure = %#v", result) + } + data, err := os.ReadFile(store.PointerPath()) + if err != nil || string(data) != string(original) { + t.Fatalf("original pointer changed: %s, %v", data, err) + } +} diff --git a/internal/profile/store.go b/internal/profile/store.go index 35dd6112..95f61607 100644 --- a/internal/profile/store.go +++ b/internal/profile/store.go @@ -1,15 +1,16 @@ -// Package profile reads the on-disk profile store without exposing secret -// contents. Write and migration primitives are added separately once securefs -// has its platform-specific tests. +// Package profile reads and writes the on-disk profile store without exposing +// secret contents. Agent configuration and installation remain separate. package profile import ( + "context" "encoding/json" "fmt" "os" "path/filepath" "regexp" "strings" + "sync" "time" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" @@ -23,6 +24,7 @@ type Store struct { OS string FS *securefs.Store Now func() time.Time + mu *sync.Mutex } type Profile struct { @@ -72,14 +74,14 @@ type storedProfile struct { func NewStore(home, osID string) Store { filesystem := securefs.New(securefs.Options{OS: osID}) - return Store{Home: home, OS: osID, FS: &filesystem, Now: time.Now} + return Store{Home: home, OS: osID, FS: &filesystem, Now: time.Now, mu: &sync.Mutex{}} } func NewStoreWithDependencies(home, osID string, filesystem securefs.Store, now func() time.Time) Store { if now == nil { now = time.Now } - return Store{Home: home, OS: osID, FS: &filesystem, Now: now} + return Store{Home: home, OS: osID, FS: &filesystem, Now: now, mu: &sync.Mutex{}} } func ValidateID(id string) error { @@ -149,6 +151,13 @@ func (s Store) List() []Profile { } func (s Store) LoadActive() ActiveResult { + return s.LoadActiveContext(context.Background()) +} + +func (s Store) LoadActiveContext(ctx context.Context) ActiveResult { + if err := requestContext(ctx); err != nil { + return ActiveResult{Error: err.Error()} + } data, err := os.ReadFile(s.PointerPath()) if os.IsNotExist(err) { return ActiveResult{} @@ -166,6 +175,10 @@ func (s Store) LoadActive() ActiveResult { } if schema == 1 { profile := migrateLegacy(pointer) + profile, err = s.persistLegacy(ctx, profile) + if err != nil { + return ActiveResult{Error: fmt.Sprintf("Cannot migrate legacy profile: %v", err)} + } return ActiveResult{Profile: &profile, Environment: profile.environment(), ID: profile.ID} } if schema != 2 { @@ -211,6 +224,47 @@ func (s Store) LoadActive() ActiveResult { return result } +func (s Store) persistLegacy(ctx context.Context, profile Profile) (Profile, error) { + if s.mu != nil { + s.mu.Lock() + defer s.mu.Unlock() + } + stored := storedProfile{ + SchemaVersion: 2, + ID: profile.ID, + Label: profile.Label, + Provider: profile.Provider, + BaseURL: profile.BaseURL, + Model: profile.Model, + ConfigMode: profile.ConfigMode, + AgentIDs: cloneStrings(profile.AgentIDs), + CreatedAt: profile.CreatedAt, + ActivatedAt: profile.ActivatedAt, + } + if stored.CreatedAt == "" { + stored.CreatedAt = s.clock().UTC().Format(time.RFC3339) + profile.CreatedAt = stored.CreatedAt + } + if stored.ActivatedAt == nil { + stored.ActivatedAt = stringPointer(stored.CreatedAt) + profile.ActivatedAt = stored.ActivatedAt + } + if err := s.writeStored(ctx, stored); err != nil { + return Profile{}, err + } + pointer := map[string]any{"schema_version": 2, "active": stored.ID} + data, err := json.MarshalIndent(pointer, "", " ") + if err != nil { + return Profile{}, writeError("Cannot encode migrated profile pointer: %v", err) + } + data = append(data, '\n') + _, err = s.filesystem().AtomicWrite(ctx, s.PointerPath(), data, false) + if err != nil { + return Profile{}, err + } + return profile, nil +} + func (p Profile) Summary() Summary { return Summary{ ID: p.ID, From 3a72d2a2c07c9619d320c2a84c9b9492251cd63d Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:45:05 +0800 Subject: [PATCH 15/63] feat: project per-agent bindings in Go status --- internal/app/status.go | 19 ++++ internal/app/status_test.go | 25 +++++ internal/profile/binding.go | 168 +++++++++++++++++++++++++++++++ internal/profile/binding_test.go | 92 +++++++++++++++++ 4 files changed, 304 insertions(+) create mode 100644 internal/profile/binding.go create mode 100644 internal/profile/binding_test.go diff --git a/internal/app/status.go b/internal/app/status.go index 35dacf5a..7c885b53 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -171,6 +171,7 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { SupportedAgentIDs: make([]string, 0, len(manifest.Agents)), } statuses := make(map[string]AgentStatus, len(manifest.Agents)) + bindings := u.profiles.ListAgentBindings() for _, id := range catalog.AgentIDs(manifest) { agent := manifest.Agents[id] configPath := configPath(options.Home, options.Platform.OS, agent) @@ -205,6 +206,13 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { version := agent.Package.Version lockedVersion = &version } + var boundProvider, boundModel, boundBaseURL, boundUpdatedAt *string + if binding, ok := bindings[id]; ok { + boundProvider = nonEmptyPointer(binding.Provider) + boundModel = nonEmptyPointer(binding.Model) + boundBaseURL = nonEmptyPointer(binding.BaseURL) + boundUpdatedAt = nonEmptyPointer(binding.UpdatedAt) + } statuses[id] = AgentStatus{ Installed: installed, Configured: fileExists(configPath), @@ -212,6 +220,10 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { Config: configPath, LockedVersion: lockedVersion, CanInstall: canInstall, + Provider: boundProvider, + Model: boundModel, + BaseURL: boundBaseURL, + UpdatedAt: boundUpdatedAt, } } profiles, activeProfile, environment, environmentError := u.profileStatus(ctx) @@ -233,6 +245,13 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { }, nil } +func nonEmptyPointer(value string) *string { + if value == "" { + return nil + } + return &value +} + // ListProfiles returns only the public profile projection. Secret files are // checked for existence by the profile store, but their contents never enter // this use case or its transport DTOs. diff --git a/internal/app/status_test.go b/internal/app/status_test.go index e7d440e7..f1aeadf3 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -195,6 +195,31 @@ func TestSaveProfileUseCaseWritesOnlyPublicSummary(t *testing.T) { } } +func TestStatusProjectsAgentBindingsWithoutUnknownFields(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, ".oneagent", "agents", "codex.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + content := `{"schema_version":1,"agent_id":"codex","provider":"ppio","base_url":"https://api.ppio.com/responses","model":"model-a","profile_ref":"team","created_at":"created","updated_at":"updated","api_key":"must-not-escape"}` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + core := NewUseCases(StatusOptions{Home: home, Platform: platform.For("linux", "amd64"), Lookup: func(string) (string, bool) { return "", false }}) + status, err := core.GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + agent := status.Agents["codex"] + if agent.Provider == nil || *agent.Provider != "ppio" || agent.Model == nil || *agent.Model != "model-a" || agent.BaseURL == nil || *agent.BaseURL != "https://api.ppio.com/responses" || agent.UpdatedAt == nil || *agent.UpdatedAt != "updated" { + t.Fatalf("agent binding projection = %#v", agent) + } + wire, err := json.Marshal(status) + if err != nil || strings.Contains(string(wire), "must-not-escape") || strings.Contains(string(wire), "api_key") { + t.Fatalf("status binding leaked unknown fields: %s (%v)", wire, err) + } +} + func TestStatusMatchesPythonEmptyLinuxARM64Fixture(t *testing.T) { home := t.TempDir() core := NewUseCases(StatusOptions{ diff --git a/internal/profile/binding.go b/internal/profile/binding.go new file mode 100644 index 00000000..df166918 --- /dev/null +++ b/internal/profile/binding.go @@ -0,0 +1,168 @@ +package profile + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" +) + +type AgentBinding struct { + SchemaVersion int + AgentID string + Provider string + BaseURL string + Model string + ProfileRef string + CreatedAt string + UpdatedAt string +} + +type BindingWriteRequest struct { + Provider string + BaseURL string + Model string + ProfileRef string +} + +type storedBinding struct { + SchemaVersion int `json:"schema_version"` + AgentID string `json:"agent_id"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + ProfileRef string `json:"profile_ref"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (s Store) AgentsPath() string { + return filepath.Join(s.Root(), "agents") +} + +func (s Store) AgentBindingPath(agentID string) (string, error) { + if err := ValidateID(agentID); err != nil { + return "", oneerrors.New(oneerrors.InvalidRequest, fmt.Sprintf("Invalid Agent ID: %s", agentID)) + } + return filepath.Join(s.AgentsPath(), agentID+".json"), nil +} + +func (s Store) ReadAgentBinding(agentID string) (*AgentBinding, error) { + path, err := s.AgentBindingPath(agentID) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("cannot read Agent binding for %s: %w", agentID, err) + } + var stored storedBinding + if err := json.Unmarshal(data, &stored); err != nil { + return nil, fmt.Errorf("Agent binding for %s is corrupt", agentID) + } + if stored.SchemaVersion != 1 { + return nil, fmt.Errorf("Unsupported Agent binding schema for %s", agentID) + } + if stored.AgentID != agentID { + return nil, fmt.Errorf("Agent binding for %s is corrupt", agentID) + } + return &AgentBinding{ + SchemaVersion: stored.SchemaVersion, + AgentID: stored.AgentID, + Provider: stored.Provider, + BaseURL: stored.BaseURL, + Model: stored.Model, + ProfileRef: stored.ProfileRef, + CreatedAt: stored.CreatedAt, + UpdatedAt: stored.UpdatedAt, + }, nil +} + +func (s Store) ListAgentBindings() map[string]AgentBinding { + entries, err := os.ReadDir(s.AgentsPath()) + if err != nil { + return map[string]AgentBinding{} + } + result := make(map[string]AgentBinding) + ids := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + id := strings.TrimSuffix(entry.Name(), ".json") + if ValidateID(id) != nil { + continue + } + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + binding, err := s.ReadAgentBinding(id) + if err == nil && binding != nil { + result[id] = *binding + } + } + return result +} + +func (s Store) WriteAgentBinding(ctx context.Context, agentID string, request BindingWriteRequest) (AgentBinding, error) { + if err := requestContext(ctx); err != nil { + return AgentBinding{}, err + } + path, err := s.AgentBindingPath(agentID) + if err != nil { + return AgentBinding{}, err + } + if strings.TrimSpace(request.Provider) == "" || strings.TrimSpace(request.BaseURL) == "" || strings.TrimSpace(request.Model) == "" { + return AgentBinding{}, oneerrors.New(oneerrors.InvalidRequest, "Agent binding requires provider, base URL, and model") + } + existing, _ := s.ReadAgentBinding(agentID) + now := s.clock().UTC().Format(time.RFC3339) + profileRef := request.ProfileRef + created := now + if existing != nil { + if profileRef == "" { + profileRef = existing.ProfileRef + } + if existing.CreatedAt != "" { + created = existing.CreatedAt + } + } + stored := storedBinding{ + SchemaVersion: 1, + AgentID: agentID, + Provider: request.Provider, + BaseURL: request.BaseURL, + Model: request.Model, + ProfileRef: profileRef, + CreatedAt: created, + UpdatedAt: now, + } + data, err := json.MarshalIndent(stored, "", " ") + if err != nil { + return AgentBinding{}, oneerrors.New(oneerrors.ConfigWriteFailed, "Cannot encode Agent binding") + } + data = append(data, '\n') + if _, err := s.filesystem().AtomicWrite(ctx, path, data, false); err != nil { + return AgentBinding{}, err + } + return AgentBinding{ + SchemaVersion: stored.SchemaVersion, + AgentID: stored.AgentID, + Provider: stored.Provider, + BaseURL: stored.BaseURL, + Model: stored.Model, + ProfileRef: stored.ProfileRef, + CreatedAt: stored.CreatedAt, + UpdatedAt: stored.UpdatedAt, + }, nil +} diff --git a/internal/profile/binding_test.go b/internal/profile/binding_test.go new file mode 100644 index 00000000..c2333e2e --- /dev/null +++ b/internal/profile/binding_test.go @@ -0,0 +1,92 @@ +package profile + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestAgentBindingWriteReadAndPreserveProfileReference(t *testing.T) { + store := testStore(t, t.TempDir(), "linux") + binding, err := store.WriteAgentBinding(context.Background(), "codex", BindingWriteRequest{ + Provider: "ppio", + BaseURL: "https://api.ppio.com/responses", + Model: "model-a", + ProfileRef: "team", + }) + if err != nil { + t.Fatal(err) + } + if binding.AgentID != "codex" || binding.ProfileRef != "team" { + t.Fatalf("binding = %#v", binding) + } + updated, err := store.WriteAgentBinding(context.Background(), "codex", BindingWriteRequest{ + Provider: "novita", + BaseURL: "https://api.novita.ai/openai", + Model: "model-b", + }) + if err != nil { + t.Fatal(err) + } + if updated.ProfileRef != "team" || updated.CreatedAt != binding.CreatedAt || updated.UpdatedAt == "" { + t.Fatalf("updated binding = %#v", updated) + } + read, err := store.ReadAgentBinding("codex") + if err != nil || read == nil || read.Provider != "novita" { + t.Fatalf("ReadAgentBinding() = %#v, %v", read, err) + } + wire, err := json.Marshal(read) + if err != nil || strings.Contains(string(wire), "api_key") || strings.Contains(string(wire), "secret") { + t.Fatalf("binding projection leaked unknown fields: %s (%v)", wire, err) + } + assertProfileMode(t, func() string { path, _ := store.AgentBindingPath("codex"); return path }(), 0o600) +} + +func TestAgentBindingListSkipsCorruptAndInvalidFiles(t *testing.T) { + store := testStore(t, t.TempDir(), "linux") + if err := os.MkdirAll(store.AgentsPath(), 0o700); err != nil { + t.Fatal(err) + } + fixtures := map[string]string{ + "codex.json": "{", + "claude.json": `{"schema_version":9,"agent_id":"claude"}`, + "Bad-Name.json": `{"schema_version":1,"agent_id":"Bad-Name"}`, + "openai.json": `{"schema_version":1,"agent_id":"other","provider":"ppio"}`, + } + for name, content := range fixtures { + if err := os.WriteFile(filepath.Join(store.AgentsPath(), name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + if _, err := store.WriteAgentBinding(context.Background(), "opencode", BindingWriteRequest{Provider: "ppio", BaseURL: "https://api.ppio.com/openai", Model: "m"}); err != nil { + t.Fatal(err) + } + bindings := store.ListAgentBindings() + if len(bindings) != 1 || bindings["opencode"].Model != "m" { + t.Fatalf("bindings = %#v", bindings) + } + if _, err := store.ReadAgentBinding("../escape"); err == nil { + t.Fatal("traversal Agent ID unexpectedly accepted") + } +} + +func TestAgentBindingRejectsInvalidInputAndHonorsCancellation(t *testing.T) { + store := testStore(t, t.TempDir(), "linux") + for _, request := range []BindingWriteRequest{{Provider: "", BaseURL: "x", Model: "m"}, {Provider: "p", BaseURL: "", Model: "m"}, {Provider: "p", BaseURL: "x", Model: ""}} { + if _, err := store.WriteAgentBinding(context.Background(), "codex", request); err == nil { + t.Errorf("invalid binding %#v succeeded", request) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := store.WriteAgentBinding(ctx, "codex", BindingWriteRequest{Provider: "p", BaseURL: "x", Model: "m"}); err == nil { + t.Fatal("cancelled binding write succeeded") + } + if got := store.ListAgentBindings(); !reflect.DeepEqual(got, map[string]AgentBinding{}) { + t.Fatalf("cancelled write changed bindings = %#v", got) + } +} From 78c2a3e551cfa151d45265f1581610445cef242f Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:51:02 +0800 Subject: [PATCH 16/63] feat: detect external Agent configurations in Go status --- go.mod | 5 +- go.sum | 16 +++ internal/app/status.go | 18 +++ internal/app/status_test.go | 43 +++++++ internal/config/discovery.go | 201 ++++++++++++++++++++++++++++++ internal/config/discovery_test.go | 109 ++++++++++++++++ 6 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 internal/config/discovery.go create mode 100644 internal/config/discovery_test.go diff --git a/go.mod b/go.mod index 5d5e0826..5a44dada 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/MaimoryLab/OneAgent go 1.25.0 -require github.com/wailsapp/wails/v3 v3.0.0-alpha2.119 +require ( + github.com/pelletier/go-toml/v2 v2.2.3 + github.com/wailsapp/wails/v3 v3.0.0-alpha2.119 +) require ( github.com/adrg/xdg v0.5.3 // indirect diff --git a/go.sum b/go.sum index a9e1e58b..beb450ca 100644 --- a/go.sum +++ b/go.sum @@ -2,16 +2,30 @@ github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/wailsapp/wails/v3 v3.0.0-alpha2.119 h1:+y9H2QxqxSiaq3i4h3e77k1/nFW0uGhcWWl1bz/DFFg= github.com/wailsapp/wails/v3 v3.0.0-alpha2.119/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -19,3 +33,5 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/status.go b/internal/app/status.go index 7c885b53..800bfa56 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -12,6 +12,7 @@ import ( "sync" "github.com/MaimoryLab/OneAgent/internal/catalog" + configReader "github.com/MaimoryLab/OneAgent/internal/config" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" "github.com/MaimoryLab/OneAgent/internal/platform" profileStore "github.com/MaimoryLab/OneAgent/internal/profile" @@ -213,6 +214,10 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { boundBaseURL = nonEmptyPointer(binding.BaseURL) boundUpdatedAt = nonEmptyPointer(binding.UpdatedAt) } + var detected *DetectedConfig + if agent.ConfigMode == "auto" && configPath != "" { + detected = detectedConfig(configReader.DetectFile(configPath, agent.ConfigAdapter, agent.EnvVars)) + } statuses[id] = AgentStatus{ Installed: installed, Configured: fileExists(configPath), @@ -224,6 +229,7 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { Model: boundModel, BaseURL: boundBaseURL, UpdatedAt: boundUpdatedAt, + Detected: detected, } } profiles, activeProfile, environment, environmentError := u.profileStatus(ctx) @@ -245,6 +251,18 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { }, nil } +func detectedConfig(value *configReader.Detected) *DetectedConfig { + if value == nil { + return nil + } + return &DetectedConfig{ + BaseURL: value.BaseURL, + Model: value.Model, + ManagedByOneAgent: value.ManagedByOneAgent, + Unreadable: value.Unreadable, + } +} + func nonEmptyPointer(value string) *string { if value == "" { return nil diff --git a/internal/app/status_test.go b/internal/app/status_test.go index f1aeadf3..efe685b9 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -220,6 +220,49 @@ func TestStatusProjectsAgentBindingsWithoutUnknownFields(t *testing.T) { } } +func TestStatusDetectsExternalConfigWithoutSecretsOrGlobalFailure(t *testing.T) { + home := t.TempDir() + codexPath := filepath.Join(home, ".codex", "config.toml") + claudePath := filepath.Join(home, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(codexPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(claudePath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(codexPath, []byte(`model_provider = "vendor" +model = "gpt-5-mini" +[model_providers.vendor] +base_url = "https://api.other-vendor.com/v1" +api_key = "sk-detected-secret" +`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(claudePath, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + core := NewUseCases(StatusOptions{Home: home, Platform: platform.For("linux", "amd64"), Lookup: func(string) (string, bool) { return "", false }}) + status, err := core.GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + codex := status.Agents["codex"] + if codex.Detected == nil || codex.Detected.BaseURL != "https://api.other-vendor.com/v1" || codex.Detected.Model != "gpt-5-mini" || codex.Detected.ManagedByOneAgent { + t.Fatalf("codex detected = %#v", codex.Detected) + } + claude := status.Agents["claude-code"] + if claude.Detected == nil || claude.Detected.Unreadable == nil { + t.Fatalf("claude malformed detection = %#v", claude.Detected) + } + wire, err := json.Marshal(status) + if err != nil || strings.Contains(string(wire), "sk-detected-secret") || strings.Contains(string(wire), "api_key") { + t.Fatalf("detected status leaked secret data: %s (%v)", wire, err) + } + if status.Agents["openclaw"].Detected != nil { + t.Fatalf("guide-only Agent unexpectedly detected config: %#v", status.Agents["openclaw"].Detected) + } +} + func TestStatusMatchesPythonEmptyLinuxARM64Fixture(t *testing.T) { home := t.TempDir() core := NewUseCases(StatusOptions{ diff --git a/internal/config/discovery.go b/internal/config/discovery.go new file mode 100644 index 00000000..fac010a7 --- /dev/null +++ b/internal/config/discovery.go @@ -0,0 +1,201 @@ +// Package config contains read-only projections of Agent configuration files. +// Readers intentionally extract only endpoint/model metadata; they never +// return credential values or execute user-controlled scripts. +package config + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "strings" + + "github.com/pelletier/go-toml/v2" +) + +type Detected struct { + BaseURL string `json:"baseUrl"` + Model string `json:"model"` + ManagedByOneAgent bool `json:"managedByOneAgent"` + Unreadable *string `json:"unreadable"` +} + +func ReadCodexConfig(text string) Detected { + var parsed map[string]any + if err := toml.Unmarshal([]byte(text), &parsed); err != nil { + return unreadable(fmt.Sprintf("TOML 无法解析:%v", err)) + } + providers, _ := parsed["model_providers"].(map[string]any) + selected, _ := parsed["model_provider"].(string) + baseURL := "" + if table, ok := providers[selected].(map[string]any); ok { + if value, ok := table["base_url"].(string); ok { + baseURL = value + } + } + model, _ := parsed["model"].(string) + _, managed := providers["oneagent"] + return Detected{BaseURL: baseURL, Model: model, ManagedByOneAgent: managed} +} + +func ReadClaudeConfig(text string, envVars map[string]string) Detected { + var parsed map[string]any + if err := json.Unmarshal([]byte(text), &parsed); err != nil { + return unreadable(fmt.Sprintf("JSON 无法解析:%v", err)) + } + if parsed == nil { + return unreadable("配置的顶层不是对象") + } + env, _ := parsed["env"].(map[string]any) + baseKey := envVars["base_url"] + if baseKey == "" { + baseKey = "ANTHROPIC_BASE_URL" + } + modelKey := envVars["model"] + if modelKey == "" { + modelKey = "ANTHROPIC_MODEL" + } + baseURL, _ := env[baseKey].(string) + model, _ := env[modelKey].(string) + managed := len(envVars) > 0 + for _, key := range envVars { + value, ok := env[key].(string) + if !ok || value == "" { + managed = false + break + } + } + return Detected{BaseURL: baseURL, Model: model, ManagedByOneAgent: managed} +} + +func ReadOpenAICompatibleConfig(text string) Detected { + var parsed map[string]any + if err := json.Unmarshal([]byte(text), &parsed); err != nil { + if jsoncCommentPattern.MatchString(text) { + return unreadable("包含 JSONC 注释,OneAgent 不解析") + } + return unreadable(fmt.Sprintf("JSON 无法解析:%v", err)) + } + if parsed == nil { + return unreadable("配置的顶层不是对象") + } + providers, _ := parsed["provider"].(map[string]any) + modelValue, _ := parsed["model"].(string) + selected, bareModel := splitModel(modelValue) + baseURL := "" + if table, ok := providers[selected].(map[string]any); ok { + if options, ok := table["options"].(map[string]any); ok { + if value, ok := options["baseURL"].(string); ok { + baseURL = value + } + } + } + _, managed := providers["oneagent"] + return Detected{BaseURL: baseURL, Model: bareModel, ManagedByOneAgent: managed} +} + +func ReadAiderConfig(text string) Detected { + baseURL := "" + for _, line := range strings.Split(text, "\n") { + trimmed := strings.TrimSpace(line) + for _, prefix := range []string{"export OPENAI_API_BASE=", "$env:OPENAI_API_BASE ="} { + if !strings.HasPrefix(trimmed, prefix) { + continue + } + value := strings.TrimSpace(strings.TrimPrefix(trimmed, prefix)) + baseURL = unquoteShellValue(value) + } + } + return Detected{BaseURL: baseURL} +} + +// DetectFile returns nil only when the file is absent. Any present but empty, +// unreadable, or unknown-format file gets a local diagnostic so one bad Agent +// cannot fail the entire status request. +func DetectFile(path, adapter string, envVars map[string]string) *Detected { + reader := readerFor(adapter, envVars) + if reader == nil { + result := unreadable("没有可用的配置解析器") + return &result + } + info, err := os.Stat(path) + if os.IsNotExist(err) || err == nil && !info.Mode().IsRegular() { + if os.IsNotExist(err) { + return nil + } + result := unreadable("配置文件不是普通文件") + return &result + } + if err != nil { + result := unreadable(fmt.Sprintf("无法读取:%v", err)) + return &result + } + data, err := os.ReadFile(path) + if err != nil { + result := unreadable(fmt.Sprintf("无法读取:%v", err)) + return &result + } + if strings.TrimSpace(string(data)) == "" { + result := unreadable("配置文件为空") + return &result + } + result := safeRead(reader, string(data)) + return &result +} + +type reader func(string) Detected + +func readerFor(adapter string, envVars map[string]string) reader { + switch adapter { + case "codex": + return ReadCodexConfig + case "claude-code": + return func(text string) Detected { return ReadClaudeConfig(text, envVars) } + case "opencode", "kilo-cli": + return ReadOpenAICompatibleConfig + case "aider": + return ReadAiderConfig + default: + return nil + } +} + +func safeRead(read reader, text string) (result Detected) { + defer func() { + if recover() != nil { + result = unreadable("配置解析失败") + } + }() + return read(text) +} + +func unreadable(message string) Detected { + return Detected{Unreadable: stringPointer(message)} +} + +func stringPointer(value string) *string { + if value == "" { + return nil + } + return &value +} + +func splitModel(value string) (string, string) { + selected, bare, found := strings.Cut(value, "/") + if !found { + return "", value + } + return selected, bare +} + +func unquoteShellValue(value string) string { + if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' { + return strings.ReplaceAll(value[1:len(value)-1], "''", "'") + } + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + return value[1 : len(value)-1] + } + return value +} + +var jsoncCommentPattern = regexp.MustCompile(`(?m)(^|\s)(//|/\*)`) diff --git a/internal/config/discovery_test.go b/internal/config/discovery_test.go new file mode 100644 index 00000000..383ccd20 --- /dev/null +++ b/internal/config/discovery_test.go @@ -0,0 +1,109 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadCodexFollowsSelectedProviderAndMarker(t *testing.T) { + detected := ReadCodexConfig("model_provider = \"vendor\"\nmodel = \"gpt-5-mini\"\n[model_providers.vendor]\nbase_url = \"https://vendor.example/v1\"\n[model_providers.oneagent]\nbase_url = \"https://ours.example/v1\"\napi_key = \"sk-hidden\"\n") + if detected.BaseURL != "https://vendor.example/v1" || detected.Model != "gpt-5-mini" || !detected.ManagedByOneAgent || detected.Unreadable != nil { + t.Fatalf("codex detection = %#v", detected) + } + if strings.Contains(mustJSON(t, detected), "sk-hidden") || strings.Contains(mustJSON(t, detected), "api_key") { + t.Fatalf("codex detection leaked secret fields: %#v", detected) + } +} + +func TestReadClaudeRequiresAllDeclaredVariablesWithoutReturningKey(t *testing.T) { + declared := map[string]string{ + "api_key": "ANTHROPIC_AUTH_TOKEN", + "base_url": "ANTHROPIC_BASE_URL", + "model": "ANTHROPIC_MODEL", + "small_fast_model": "ANTHROPIC_SMALL_FAST_MODEL", + } + partial := ReadClaudeConfig(`{"env":{"ANTHROPIC_BASE_URL":"https://x.example"}}`, declared) + if partial.BaseURL != "https://x.example" || partial.ManagedByOneAgent { + t.Fatalf("partial Claude detection = %#v", partial) + } + full := ReadClaudeConfig(`{"env":{"ANTHROPIC_AUTH_TOKEN":"sk-secret","ANTHROPIC_BASE_URL":"https://x.example","ANTHROPIC_MODEL":"m","ANTHROPIC_SMALL_FAST_MODEL":"fast"}}`, declared) + if !full.ManagedByOneAgent || full.Model != "m" || strings.Contains(mustJSON(t, full), "sk-secret") { + t.Fatalf("full Claude detection = %#v", full) + } +} + +func TestReadOpenAICompatibleAndJSONC(t *testing.T) { + detected := ReadOpenAICompatibleConfig(`{"provider":{"mine":{"options":{"baseURL":"https://mine.example/v1","apiKey":"sk-secret"}}},"model":"mine/local-llm"}`) + if detected.BaseURL != "https://mine.example/v1" || detected.Model != "local-llm" { + t.Fatalf("OpenAI-compatible detection = %#v", detected) + } + if detected := ReadOpenAICompatibleConfig("{\n // comment\n \"model\":\"x/y\"\n}"); detected.Unreadable == nil || !strings.Contains(*detected.Unreadable, "JSONC") { + t.Fatalf("JSONC detection = %#v", detected) + } + if detected := ReadOpenAICompatibleConfig(`{"model":"bare-model"}`); detected.Model != "bare-model" { + t.Fatalf("bare model detection = %#v", detected) + } +} + +func TestReadAiderNeverExecutesOrReturnsKey(t *testing.T) { + detected := ReadAiderConfig("export OPENAI_API_BASE='https://hand.example/v1'\nexport OPENAI_API_KEY='sk-never-return'\n") + if detected.BaseURL != "https://hand.example/v1" || detected.ManagedByOneAgent || strings.Contains(mustJSON(t, detected), "sk-never-return") { + t.Fatalf("Aider detection = %#v", detected) + } + if got := ReadAiderConfig("$env:OPENAI_API_BASE = \"https://win.example/v1\"\n"); got.BaseURL != "https://win.example/v1" { + t.Fatalf("PowerShell detection = %#v", got) + } +} + +func TestReadersReturnLocalDiagnosticsForMalformedOrWrongTypes(t *testing.T) { + for _, detected := range []Detected{ + ReadCodexConfig("model_provider = \nbroken ["), + ReadClaudeConfig("[]", nil), + ReadOpenAICompatibleConfig("[]"), + ReadOpenAICompatibleConfig("{ not json"), + } { + if detected.Unreadable == nil || detected.BaseURL != "" || detected.Model != "" { + t.Fatalf("malformed detection = %#v", detected) + } + } + codex := ReadCodexConfig("model_provider = 1\nmodel = 2\n[model_providers.p]\nbase_url = 3\n") + if codex.BaseURL != "" || codex.Model != "" { + t.Fatalf("wrongly typed TOML values = %#v", codex) + } +} + +func TestDetectFileDistinguishesAbsentEmptyUnknownAndSecrets(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, "config.toml") + if got := DetectFile(path, "codex", nil); got != nil { + t.Fatalf("absent detection = %#v", got) + } + if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + if got := DetectFile(path, "codex", nil); got == nil || got.Unreadable == nil || !strings.Contains(*got.Unreadable, "空") { + t.Fatalf("empty detection = %#v", got) + } + if err := os.WriteFile(path, []byte("model_provider = \"p\"\nmodel = \"m\"\n[model_providers.p]\nbase_url = \"https://example\"\napi_key = \"sk-file-secret\"\n"), 0o600); err != nil { + t.Fatal(err) + } + got := DetectFile(path, "codex", nil) + if got == nil || got.BaseURL != "https://example" || strings.Contains(mustJSON(t, got), "sk-file-secret") { + t.Fatalf("file detection = %#v", got) + } + if got := DetectFile(path, "future-adapter", nil); got == nil || got.Unreadable == nil { + t.Fatalf("unknown adapter detection = %#v", got) + } +} + +func mustJSON(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(data) +} From 12b1320381bf638bda5bc5e34fac2583affa1199 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:55:10 +0800 Subject: [PATCH 17/63] feat: port Agent configuration writers --- internal/config/write.go | 268 ++++++++++++++++++++++++++++++++++ internal/config/write_test.go | 143 ++++++++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 internal/config/write.go create mode 100644 internal/config/write_test.go diff --git a/internal/config/write.go b/internal/config/write.go new file mode 100644 index 00000000..c3646be2 --- /dev/null +++ b/internal/config/write.go @@ -0,0 +1,268 @@ +package config + +import ( + "context" + "encoding/json" + "fmt" + "os" + "regexp" + "strconv" + "strings" + + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/provider" + "github.com/MaimoryLab/OneAgent/internal/securefs" + "github.com/pelletier/go-toml/v2" +) + +type Writer struct { + Home string + OS string + FS securefs.Store +} + +func NewWriter(home, osID string, filesystem securefs.Store) Writer { + return Writer{Home: home, OS: osID, FS: filesystem} +} + +func (w Writer) WriteCodex(ctx context.Context, path, providerName, baseURL, model string) error { + managed := strings.Join([]string{ + "model_provider = \"oneagent\"", + "model = " + quoteTOML(model), + "", + "[model_providers.oneagent]", + "name = " + quoteTOML(providerName), + "base_url = " + quoteTOML(baseURL), + "env_key = " + quoteTOML(agentEnvVar("codex")), + "wire_api = \"responses\"", + "", + }, "\n") + existing, err := readText(path) + if err != nil { + return configError("Cannot read existing TOML configuration %s: %v", path, err) + } + content, err := mergeCodexTOML(existing, managed, path) + if err != nil { + return err + } + return w.write(ctx, path, []byte(content), false) +} + +func (w Writer) WriteClaude(ctx context.Context, path, baseURL, apiKey, model, smallFastModel string) error { + data, err := loadJSON(path) + if err != nil { + return err + } + env, ok := data["env"] + if env == nil { + env = map[string]any{} + data["env"] = env + } + envMap, ok := env.(map[string]any) + if !ok { + return configError("Existing Claude Code env configuration must contain an object: %s", path) + } + envMap["ANTHROPIC_BASE_URL"] = baseURL + envMap["ANTHROPIC_AUTH_TOKEN"] = apiKey + envMap["ANTHROPIC_MODEL"] = model + if smallFastModel == "" { + smallFastModel = model + } + envMap["ANTHROPIC_SMALL_FAST_MODEL"] = smallFastModel + return w.writeJSON(ctx, path, data, true) +} + +func (w Writer) WriteOpenAICompatible(ctx context.Context, path, schemaURL, providerName, baseURL, model, agentID string) error { + data, err := loadJSON(path) + if err != nil { + return err + } + data["$schema"] = schemaURL + providersValue := data["provider"] + if providersValue == nil { + providersValue = map[string]any{} + data["provider"] = providersValue + } + providers, ok := providersValue.(map[string]any) + if !ok { + return configError("Existing provider configuration must contain an object: %s", path) + } + providers["oneagent"] = map[string]any{ + "npm": "@ai-sdk/openai-compatible", + "name": providerName, + "options": map[string]any{ + "baseURL": provider.OpenAIBaseURL(baseURL), + "apiKey": "{env:" + agentEnvVar(agentID) + "}", + }, + "models": map[string]any{model: map[string]any{"name": model}}, + } + data["model"] = "oneagent/" + model + return w.writeJSON(ctx, path, data, false) +} + +func (w Writer) WriteAider(ctx context.Context, path, baseURL, apiKey string) error { + base := provider.OpenAIBaseURL(baseURL) + var content string + if w.OS == "windows" { + content = "$env:OPENAI_API_BASE = '" + powershellQuote(base) + "'\n" + + "$env:OPENAI_API_KEY = '" + powershellQuote(apiKey) + "'\n" + } else { + content = "export OPENAI_API_BASE=" + shellQuote(base) + "\n" + + "export OPENAI_API_KEY=" + shellQuote(apiKey) + "\n" + } + return w.write(ctx, path, []byte(content), true) +} + +func (w Writer) write(ctx context.Context, path string, data []byte, secret bool) error { + if _, err := w.FS.AtomicWrite(ctx, path, data, secret); err != nil { + return err + } + return nil +} + +func (w Writer) writeJSON(ctx context.Context, path string, value map[string]any, secret bool) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return configError("Cannot encode JSON configuration %s: %v", path, err) + } + data = append(data, '\n') + return w.write(ctx, path, data, secret) +} + +func readText(path string) (string, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + return string(data), nil +} + +func loadJSON(path string) (map[string]any, error) { + text, err := readText(path) + if err != nil { + return nil, configError("Cannot read existing JSON configuration %s: %v", path, err) + } + if strings.TrimSpace(text) == "" { + return map[string]any{}, nil + } + var value map[string]any + if err := json.Unmarshal([]byte(text), &value); err != nil { + if strings.HasSuffix(path, ".jsonc") && jsoncCommentPattern.MatchString(text) { + return nil, configError("%s contains JSONC comments, which OneAgent cannot preserve when it rewrites the file", path) + } + return nil, configError("Existing JSON configuration is invalid: %s: %v", path, err) + } + if value == nil { + return nil, configError("Existing JSON configuration must contain an object: %s", path) + } + return value, nil +} + +func mergeCodexTOML(existing, managed, path string) (string, error) { + if strings.TrimSpace(existing) == "" { + return managed, nil + } + var parsed map[string]any + if err := toml.Unmarshal([]byte(existing), &parsed); err != nil { + return "", configError("Existing TOML configuration is invalid: %s: %v", path, err) + } + var topLevel, tables []string + removed := map[string]bool{} + managedFound := false + inTable := false + skipManaged := false + for _, line := range strings.Split(existing, "\n") { + stripped := strings.TrimSpace(line) + if strings.HasPrefix(stripped, "[") { + header := strings.ReplaceAll(strings.TrimSpace(strings.SplitN(stripped, "#", 2)[0]), " ", "") + inTable = true + skipManaged = managedSectionPattern.MatchString(header) + if skipManaged { + managedFound = true + continue + } + } + if skipManaged { + continue + } + if !inTable { + if match := topLevelKeyPattern.FindStringSubmatch(line); match != nil { + removed[match[1]] = true + continue + } + topLevel = append(topLevel, line) + } else { + tables = append(tables, line) + } + } + if providers, ok := parsed["model_providers"].(map[string]any); ok { + if _, exists := providers["oneagent"]; exists && !managedFound { + return "", configError("Unsupported OneAgent TOML table syntax in %s", path) + } + } + for _, key := range []string{"model_provider", "model"} { + if _, exists := parsed[key]; exists && !removed[key] { + return "", configError("Unsupported TOML key syntax for %s in %s", key, path) + } + } + sections := make([]string, 0, 3) + for _, section := range []string{strings.TrimSpace(strings.Join(topLevel, "\n")), strings.TrimSpace(managed), strings.TrimSpace(strings.Join(tables, "\n"))} { + if section != "" { + sections = append(sections, section) + } + } + merged := strings.Join(sections, "\n\n") + "\n" + var validation map[string]any + if err := toml.Unmarshal([]byte(merged), &validation); err != nil { + return "", configError("Cannot merge TOML configuration %s: %v", path, err) + } + return merged, nil +} + +func agentEnvVar(agentID string) string { + var builder strings.Builder + for _, character := range strings.ToUpper(agentID) { + if character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' { + builder.WriteRune(character) + } else { + builder.WriteRune('_') + } + } + return "ONEAGENT_API_KEY_" + strings.Trim(builder.String(), "_") +} + +func quoteTOML(value string) string { + return strconv.Quote(value) +} + +func shellQuote(value string) string { + if value != "" { + safe := true + for _, character := range value { + if !strings.ContainsRune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-", character) { + safe = false + break + } + } + if safe { + return value + } + } + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func powershellQuote(value string) string { + return strings.ReplaceAll(value, "'", "''") +} + +func configError(format string, values ...any) error { + return oneerrors.New(oneerrors.ConfigWriteFailed, fmt.Sprintf(format, values...)) +} + +var ( + topLevelKeyPattern = regexp.MustCompile(`^\s*(model_provider|model)\s*=`) + managedSectionPattern = regexp.MustCompile(`^\[model_providers\.oneagent(?:\..+)?\]$`) +) diff --git a/internal/config/write_test.go b/internal/config/write_test.go new file mode 100644 index 00000000..e1d6d715 --- /dev/null +++ b/internal/config/write_test.go @@ -0,0 +1,143 @@ +package config + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MaimoryLab/OneAgent/internal/securefs" +) + +func testWriter(t *testing.T, home, osID string) Writer { + t.Helper() + filesystem := securefs.New(securefs.Options{OS: osID}) + return NewWriter(home, osID, filesystem) +} + +func TestWriteCodexPreservesUnmanagedTablesAndRoundTrips(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, ".codex", "config.toml") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("approval_policy = \"on-request\"\nmodel_provider = \"old\"\nmodel = \"old-model\"\n\n[model_providers.old]\nname = \"Keep Me\"\n\n[model_providers.oneagent]\nname = \"Old\"\nbase_url = \"https://old.example\"\n"), 0o600); err != nil { + t.Fatal(err) + } + writer := testWriter(t, home, "linux") + if err := writer.WriteCodex(context.Background(), path, "PPIO", "https://api.ppio.com/openai", "model-a"); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(data) + if !strings.Contains(text, "approval_policy = \"on-request\"") || !strings.Contains(text, "name = \"Keep Me\"") || !strings.Contains(text, "model_provider = \"oneagent\"") { + t.Fatalf("merged Codex config = %s", text) + } + detected := ReadCodexConfig(text) + if detected.BaseURL != "https://api.ppio.com/openai" || detected.Model != "model-a" || !detected.ManagedByOneAgent || detected.Unreadable != nil { + t.Fatalf("round-trip Codex detection = %#v", detected) + } + for _, invalid := range []string{"model = \"unterminated\n", "[\"model_providers\".\"oneagent\"]\nname = \"quoted\"\n", "\"model\" = \"quoted\"\n"} { + if err := os.WriteFile(path, []byte(invalid), 0o600); err != nil { + t.Fatal(err) + } + if err := writer.WriteCodex(context.Background(), path, "PPIO", "https://example.com", "m"); err == nil { + t.Fatalf("invalid Codex config unexpectedly succeeded: %q", invalid) + } + got, _ := os.ReadFile(path) + if string(got) != invalid { + t.Fatalf("invalid Codex config was modified: %q", got) + } + } +} + +func TestWriteJSONAdaptersPreserveFieldsAndRejectJSONC(t *testing.T) { + home := t.TempDir() + writer := testWriter(t, home, "linux") + claudePath := filepath.Join(home, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(claudePath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(claudePath, []byte(`{"keep":true,"env":{ "CUSTOM":"value" }}`), 0o600); err != nil { + t.Fatal(err) + } + if err := writer.WriteClaude(context.Background(), claudePath, "https://anthropic.example", "sk-claude-secret", "model-a", ""); err != nil { + t.Fatal(err) + } + var claude map[string]any + data, _ := os.ReadFile(claudePath) + if err := json.Unmarshal(data, &claude); err != nil || claude["keep"] != true { + t.Fatalf("Claude config = %s, %v", data, err) + } + env := claude["env"].(map[string]any) + if env["ANTHROPIC_SMALL_FAST_MODEL"] != "model-a" || env["CUSTOM"] != "value" { + t.Fatalf("Claude env = %#v", env) + } + if !strings.Contains(string(data), "sk-claude-secret") { + t.Fatal("Claude config did not contain its required native credential") + } + + openPath := filepath.Join(home, ".config", "opencode", "opencode.jsonc") + if err := os.MkdirAll(filepath.Dir(openPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(openPath, []byte(`{"keep":true,"provider":{"other":{"x":1}}}`), 0o600); err != nil { + t.Fatal(err) + } + if err := writer.WriteOpenAICompatible(context.Background(), openPath, "https://opencode.ai/config.json", "PPIO", "https://api.ppio.com/openai", "model-a", "opencode"); err != nil { + t.Fatal(err) + } + var open map[string]any + data, _ = os.ReadFile(openPath) + if err := json.Unmarshal(data, &open); err != nil || open["keep"] != true || open["model"] != "oneagent/model-a" { + t.Fatalf("OpenCode config = %s, %v", data, err) + } + providers := open["provider"].(map[string]any) + if _, ok := providers["other"]; !ok { + t.Fatalf("unmanaged provider removed: %#v", providers) + } + detected := ReadOpenAICompatibleConfig(string(data)) + if detected.BaseURL != "https://api.ppio.com/openai/v1" || detected.Model != "model-a" || !detected.ManagedByOneAgent { + t.Fatalf("OpenCode round-trip = %#v", detected) + } + + if err := os.WriteFile(openPath, []byte("{\n // keep\n \"theme\": \"dark\"\n}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := writer.WriteOpenAICompatible(context.Background(), openPath, "schema", "PPIO", "https://api.ppio.com/openai", "m", "opencode"); err == nil || !strings.Contains(err.Error(), "JSONC comments") { + t.Fatalf("JSONC write error = %v", err) + } +} + +func TestWriteAiderQuotesSecretsOnUnixAndWindows(t *testing.T) { + linuxHome := t.TempDir() + linux := testWriter(t, linuxHome, "linux") + linuxPath := filepath.Join(linuxHome, ".oneagent", "aider.env") + if err := linux.WriteAider(context.Background(), linuxPath, "https://api.example/openai", "key'quoted"); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(linuxPath) + if !strings.Contains(string(data), "OPENAI_API_BASE=https://api.example/openai/v1") || !strings.Contains(string(data), "'key'\\''quoted'") { + t.Fatalf("Unix Aider config = %q", data) + } + if detected := ReadAiderConfig(string(data)); detected.BaseURL != "https://api.example/openai/v1" { + t.Fatalf("Unix Aider round-trip = %#v", detected) + } + + windowsHome := t.TempDir() + filesystem := securefs.New(securefs.Options{OS: "windows", Username: "tester", Run: func(context.Context, []string) error { return nil }}) + windows := NewWriter(windowsHome, "windows", filesystem) + windowsPath := filepath.Join(windowsHome, ".oneagent", "aider.ps1") + if err := windows.WriteAider(context.Background(), windowsPath, "https://api.example/openai", "key'quoted"); err != nil { + t.Fatal(err) + } + data, _ = os.ReadFile(windowsPath) + if !strings.Contains(string(data), "key''quoted") { + t.Fatalf("Windows Aider config = %q", data) + } +} From 8e37bc8ba3261577058998d187d3d21b441603f2 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:03:39 +0800 Subject: [PATCH 18/63] refactor: share Agent environment variable rendering --- internal/config/env.go | 92 +++++++++++++++++++++++++++++++++++++ internal/config/env_test.go | 54 ++++++++++++++++++++++ internal/config/write.go | 12 ----- 3 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 internal/config/env.go create mode 100644 internal/config/env_test.go diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 00000000..b9b18d27 --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,92 @@ +package config + +import ( + "context" + "strings" + + "github.com/MaimoryLab/OneAgent/internal/securefs" +) + +func WriteAgentEnv(ctx context.Context, filesystem securefs.Store, path, osID, agentID, apiKey, baseURL, model, smallFastModel string, native map[string]string) error { + values := []envValue{ + {Name: agentEnvVar(agentID), Value: apiKey}, + {Name: agentEnvVar(agentID, "API_BASE_URL"), Value: baseURL}, + {Name: "ONEAGENT_API_KEY", Value: apiKey}, + {Name: "ONEAGENT_API_BASE_URL", Value: baseURL}, + } + fastModel := smallFastModel + if fastModel == "" { + fastModel = model + } + for _, item := range []struct{ field, value string }{ + {field: "api_key", value: apiKey}, + {field: "base_url", value: baseURL}, + {field: "model", value: model}, + {field: "small_fast_model", value: fastModel}, + } { + field, value := item.field, item.value + if name := native[field]; name != "" && value != "" { + values = append(values, envValue{Name: name, Value: value}) + } + } + content := renderEnv(osID, values) + if _, err := filesystem.AtomicWrite(ctx, path, []byte(content), true); err != nil { + return err + } + return nil +} + +func WriteSharedEnv(ctx context.Context, filesystem securefs.Store, path, osID, apiKey, baseURL string) error { + content := renderEnv(osID, []envValue{ + {Name: "ONEAGENT_API_KEY", Value: apiKey}, + {Name: "ONEAGENT_API_BASE_URL", Value: baseURL}, + }) + if _, err := filesystem.AtomicWrite(ctx, path, []byte(content), true); err != nil { + return err + } + return nil +} + +type envValue struct { + Name string + Value string +} + +func renderEnv(osID string, values []envValue) string { + var builder strings.Builder + for _, value := range values { + if osID == "windows" { + builder.WriteString("$env:") + builder.WriteString(value.Name) + builder.WriteString(" = '") + builder.WriteString(powershellQuote(value.Value)) + builder.WriteString("'\n") + continue + } + builder.WriteString("export ") + builder.WriteString(value.Name) + builder.WriteByte('=') + builder.WriteString(shellQuote(value.Value)) + builder.WriteByte('\n') + } + return builder.String() +} + +func agentEnvVar(agentID string, suffix ...string) string { + stem := strings.Trim(strings.Map(func(character rune) rune { + if character >= 'a' && character <= 'z' { + return character - ('a' - 'A') + } + if character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' { + return character + } + return '_' + }, agentID), "_") + if len(suffix) == 0 { + return "ONEAGENT_API_KEY_" + stem + } + if suffix[0] == "API_BASE_URL" { + return "ONEAGENT_API_BASE_URL_" + stem + } + return "ONEAGENT_" + suffix[0] + "_" + stem +} diff --git a/internal/config/env_test.go b/internal/config/env_test.go new file mode 100644 index 00000000..479d26bd --- /dev/null +++ b/internal/config/env_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MaimoryLab/OneAgent/internal/securefs" +) + +func TestWriteAgentEnvUsesDeclaredAndCompatibilityVariables(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, ".oneagent", "agents", "claude-code.env") + filesystem := securefs.New(securefs.Options{OS: "linux"}) + if err := WriteAgentEnv(context.Background(), filesystem, path, "linux", "claude-code", "key'quoted", "https://api.example", "model-a", "model-fast", map[string]string{ + "api_key": "ANTHROPIC_AUTH_TOKEN", "base_url": "ANTHROPIC_BASE_URL", "model": "ANTHROPIC_MODEL", "small_fast_model": "ANTHROPIC_SMALL_FAST_MODEL", + }); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + text := string(data) + for _, expected := range []string{ + "export ONEAGENT_API_KEY_CLAUDE_CODE='key'\\''quoted'", + "export ONEAGENT_API_BASE_URL_CLAUDE_CODE=https://api.example", + "export ONEAGENT_API_KEY='key'\\''quoted'", + "export ANTHROPIC_MODEL=model-a", + "export ANTHROPIC_SMALL_FAST_MODEL=model-fast", + } { + if !strings.Contains(text, expected) { + t.Fatalf("env missing %q: %s", expected, text) + } + } + if strings.Contains(text, "api_key") { + t.Fatal("env variable names unexpectedly contain internal field names") + } +} + +func TestWriteAgentEnvWindowsAndSharedCompatibility(t *testing.T) { + home := t.TempDir() + filesystem := securefs.New(securefs.Options{OS: "windows", Username: "tester", Run: func(context.Context, []string) error { return nil }}) + path := filepath.Join(home, ".oneagent", "env.ps1") + if err := WriteSharedEnv(context.Background(), filesystem, path, "windows", "key'quoted", "https://api.example"); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), "$env:ONEAGENT_API_KEY = 'key''quoted'") || !strings.Contains(string(data), "$env:ONEAGENT_API_BASE_URL = 'https://api.example'") { + t.Fatalf("PowerShell env = %q", data) + } +} diff --git a/internal/config/write.go b/internal/config/write.go index c3646be2..8ff0730f 100644 --- a/internal/config/write.go +++ b/internal/config/write.go @@ -222,18 +222,6 @@ func mergeCodexTOML(existing, managed, path string) (string, error) { return merged, nil } -func agentEnvVar(agentID string) string { - var builder strings.Builder - for _, character := range strings.ToUpper(agentID) { - if character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' { - builder.WriteRune(character) - } else { - builder.WriteRune('_') - } - } - return "ONEAGENT_API_KEY_" + strings.Trim(builder.String(), "_") -} - func quoteTOML(value string) string { return strconv.Quote(value) } From 809fff7a4293af8bbd6ff86c6f686a613e2a4deb Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:21:14 +0800 Subject: [PATCH 19/63] feat: activate Agents through Go use cases --- internal/app/agent.go | 247 ++++++++++++++++++++++++++++++ internal/app/agent_test.go | 207 +++++++++++++++++++++++++ internal/app/status.go | 36 +++-- internal/binding/services.go | 35 ++++- internal/binding/services_test.go | 29 ++++ 5 files changed, 541 insertions(+), 13 deletions(-) create mode 100644 internal/app/agent.go create mode 100644 internal/app/agent_test.go diff --git a/internal/app/agent.go b/internal/app/agent.go new file mode 100644 index 00000000..c1c102f7 --- /dev/null +++ b/internal/app/agent.go @@ -0,0 +1,247 @@ +package app + +import ( + "context" + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/MaimoryLab/OneAgent/internal/catalog" + configWriter "github.com/MaimoryLab/OneAgent/internal/config" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + profileStore "github.com/MaimoryLab/OneAgent/internal/profile" + "github.com/MaimoryLab/OneAgent/internal/provider" +) + +// ActivateAgentOptions is the transport-independent input for pointing one +// managed Agent at a Provider. APIKey is deliberately kept inside the use +// case and never appears in ActivateAgentResult. +type ActivateAgentOptions struct { + AgentID string + Provider string + APIBaseURL string + APIKey string + Model string + ProfileID string + SmallFastModel string +} + +// ActivateAgentResult contains only the public outcome needed by the UI and +// CLI. The binding itself is persisted separately and is not repeated here. +type ActivateAgentResult struct { + AgentID string + Config string + Provider string + Model string + Restart string + Next string + Binding profileStore.AgentBinding +} + +var managedAgentIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// ActivateAgent points one Agent at a Provider while leaving all other Agent +// files untouched. The write lock covers model resolution and every write so +// concurrent Wails calls cannot interleave backups or publish stale bindings. +func (u *UseCases) ActivateAgent(ctx context.Context, options ActivateAgentOptions) (ActivateAgentResult, error) { + if u == nil { + return ActivateAgentResult{}, oneerrors.New(oneerrors.InternalError, "Agent service is not configured", oneerrors.WithStatus(501)) + } + if err := contextError(ctx, "Agent activation request was cancelled"); err != nil { + return ActivateAgentResult{}, err + } + + u.writeMu.Lock() + defer u.writeMu.Unlock() + + manifest, err := catalog.LoadEmbedded() + if err != nil { + return ActivateAgentResult{}, err + } + agentID := options.AgentID + if !managedAgentIDPattern.MatchString(agentID) { + return ActivateAgentResult{}, oneerrors.New(oneerrors.InvalidRequest, fmt.Sprintf("Invalid Agent ID: %s", agentID)) + } + agent, ok := manifest.Agents[agentID] + if !ok { + return ActivateAgentResult{}, oneerrors.New(oneerrors.InvalidRequest, "Unknown Agent: "+agentID) + } + if !contains(agent.Platforms, u.status.Platform.OS) { + return ActivateAgentResult{}, oneerrors.New( + oneerrors.PrerequisiteMissing, + fmt.Sprintf("%s is not supported on %s", agent.Name, u.status.Platform.OS), + ) + } + if agent.ConfigMode != "auto" { + return ActivateAgentResult{}, oneerrors.New(oneerrors.InvalidRequest, fmt.Sprintf("%s is guide-only and has no managed configuration", agentID)) + } + + providerID := strings.TrimSpace(options.Provider) + if providerID == "" { + providerID = "ppio" + } + apiKey := options.APIKey + profileID := strings.TrimSpace(options.ProfileID) + if apiKey == "" && profileID != "" { + apiKey, err = u.profiles.ReadSecret(ctx, profileID) + if err != nil { + return ActivateAgentResult{}, err + } + } + if apiKey == "" { + return ActivateAgentResult{}, oneerrors.New(oneerrors.InvalidRequest, "API key is required") + } + + model := strings.TrimSpace(options.Model) + if model == "" { + if u.provider == nil { + return ActivateAgentResult{}, oneerrors.New(oneerrors.InternalError, "Model discovery is not configured", oneerrors.WithStatus(501)) + } + model, err = u.provider.ResolveProbeModel(ctx, providerID, apiKey, "", options.APIBaseURL) + if err != nil { + return ActivateAgentResult{}, err + } + } + if model == "" { + return ActivateAgentResult{}, oneerrors.New(oneerrors.InvalidRequest, "model is required") + } + + baseURL, err := provider.ProviderBase(providerID, options.APIBaseURL) + if err != nil { + return ActivateAgentResult{}, err + } + protocol := provider.ProtocolForAdapter(agent.ConfigAdapter) + configBaseURL, err := provider.ProviderConfigBase(providerID, options.APIBaseURL, protocol) + if err != nil { + return ActivateAgentResult{}, err + } + providerName := "Custom" + if definition, found := catalog.ProviderByID(providerID); found { + providerName = definition.Name + } + configPath := configPath(u.status.Home, u.status.Platform.OS, agent) + if configPath == "" { + return ActivateAgentResult{}, oneerrors.New(oneerrors.InvalidRequest, "Managed Agent has no configuration path") + } + + filesystem := u.filesystem + if needsAgentEnv(agent) { + envPath := agentEnvPath(u.status.Home, u.status.Platform.OS, agentID) + if err := configWriter.WriteAgentEnv( + ctx, + filesystem, + envPath, + u.status.Platform.OS, + agentID, + apiKey, + baseURL, + model, + options.SmallFastModel, + agent.EnvVars, + ); err != nil { + return ActivateAgentResult{}, err + } + } + + writer := configWriter.NewWriter(u.status.Home, u.status.Platform.OS, filesystem) + if err := writeManagedAgentConfig(ctx, writer, agentID, agent, configPath, providerName, configBaseURL, apiKey, model, options.SmallFastModel); err != nil { + return ActivateAgentResult{}, err + } + binding, err := u.profiles.WriteAgentBinding(ctx, agentID, profileStore.BindingWriteRequest{ + Provider: providerID, + BaseURL: configBaseURL, + Model: model, + ProfileRef: profileID, + }) + if err != nil { + return ActivateAgentResult{}, err + } + + return ActivateAgentResult{ + AgentID: agentID, + Config: configPath, + Provider: providerID, + Model: model, + Restart: restartHint(agentID, agent), + Next: nextStep(u.status.Platform.OS, agentID, agent, model), + Binding: binding, + }, nil +} + +func contextError(ctx context.Context, message string) error { + if ctx == nil { + return nil + } + if err := ctx.Err(); err != nil { + return oneerrors.New(oneerrors.Timeout, message, oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + return nil +} + +func needsAgentEnv(agent catalog.Agent) bool { + return agent.CredentialDelivery == "oneagent_env" || agent.CredentialDelivery == "native_env" +} + +func agentEnvPath(home, osID, agentID string) string { + suffix := ".env" + if osID == "windows" { + suffix = ".env.ps1" + } + return filepath.Join(home, ".oneagent", "agents", agentID+suffix) +} + +func writeManagedAgentConfig(ctx context.Context, writer configWriter.Writer, agentID string, agent catalog.Agent, path, providerName, baseURL, apiKey, model, smallFastModel string) error { + switch agent.ConfigAdapter { + case "codex": + return writer.WriteCodex(ctx, path, providerName, baseURL, model) + case "claude-code": + return writer.WriteClaude(ctx, path, baseURL, apiKey, model, smallFastModel) + case "opencode": + return writer.WriteOpenAICompatible(ctx, path, "https://opencode.ai/config.json", providerName, baseURL, model, agentID) + case "kilo-cli": + return writer.WriteOpenAICompatible(ctx, path, "https://app.kilo.ai/config.json", providerName, baseURL, model, agentID) + case "aider": + return writer.WriteAider(ctx, path, baseURL, apiKey) + default: + return oneerrors.New(oneerrors.InvalidRequest, fmt.Sprintf("Unsupported auto-config Agent: %s", agentID)) + } +} + +func restartHint(agentID string, agent catalog.Agent) string { + if agent.Command == "" { + return "Restart " + agentID + } + if agentID == "aider" { + return "Restart " + agent.Command + " in a shell that sources ~/.oneagent/aider.env" + } + if needsAgentEnv(agent) { + return fmt.Sprintf("Quit any running %s process, then start it again in a shell that sources ~/.oneagent/agents/%s.env", agent.Command, agentID) + } + return fmt.Sprintf("Quit any running %s process, then start it again", agent.Command) +} + +func nextStep(osID, agentID string, agent catalog.Agent, model string) string { + if agent.ConfigMode != "auto" || agent.Command == "" { + return "" + } + joiner := "&&" + if osID == "windows" { + joiner = ";" + } + if agentID == "aider" { + source := "source ~/.oneagent/aider.env" + if osID == "windows" { + source = `. "$HOME\\.oneagent\\aider.ps1"` + } + return fmt.Sprintf("%s %s %s --model openai/%s", source, joiner, agent.Command, model) + } + if needsAgentEnv(agent) { + source := fmt.Sprintf("source ~/.oneagent/agents/%s.env", agentID) + if osID == "windows" { + source = fmt.Sprintf(`. "$HOME\\.oneagent\\agents\\%s.env.ps1"`, agentID) + } + return fmt.Sprintf("%s %s %s", source, joiner, agent.Command) + } + return agent.Command +} diff --git a/internal/app/agent_test.go b/internal/app/agent_test.go new file mode 100644 index 00000000..aa7cdae3 --- /dev/null +++ b/internal/app/agent_test.go @@ -0,0 +1,207 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/provider" +) + +func activationCore(t *testing.T, home string, client *provider.Client, osID string) *UseCases { + t.Helper() + return NewUseCasesWithProviderClient(StatusOptions{ + Home: home, + Platform: platform.For(osID, "amd64"), + Lookup: func(string) (string, bool) { return "", false }, + }, client) +} + +func TestActivateAgentWritesPerAgentStateAndKeepsSecretsOutOfResult(t *testing.T) { + home := t.TempDir() + core := activationCore(t, home, provider.NewClient(nil), "linux") + result, err := core.ActivateAgent(context.Background(), ActivateAgentOptions{ + AgentID: "codex", + Provider: "ppio", + APIKey: "codex-secret", + Model: "model-a", + }) + if err != nil { + t.Fatal(err) + } + if result.AgentID != "codex" || result.Provider != "ppio" || result.Model != "model-a" { + t.Fatalf("activation result = %#v", result) + } + if !strings.Contains(result.Restart, "codex") || !strings.Contains(result.Next, "source ~/.oneagent/agents/codex.env") { + t.Fatalf("activation hints = %#v", result) + } + if strings.Contains(result.Restart+result.Next, "codex-secret") { + t.Fatal("activation hints leaked the API key") + } + + envPath := filepath.Join(home, ".oneagent", "agents", "codex.env") + envData, err := os.ReadFile(envPath) + if err != nil || !strings.Contains(string(envData), "codex-secret") || !strings.Contains(string(envData), "ONEAGENT_API_KEY_CODEX") { + t.Fatalf("Agent env = %q, err=%v", envData, err) + } + configData, err := os.ReadFile(result.Config) + if err != nil || strings.Contains(string(configData), "codex-secret") || !strings.Contains(string(configData), `model_provider = "oneagent"`) { + t.Fatalf("Codex config = %q, err=%v", configData, err) + } + binding, err := core.profiles.ReadAgentBinding("codex") + if err != nil || binding == nil || binding.Provider != "ppio" || binding.Model != "model-a" { + t.Fatalf("binding = %#v, err=%v", binding, err) + } + wire, err := json.Marshal(result) + if err != nil || strings.Contains(string(wire), "codex-secret") || strings.Contains(string(wire), "api_key") { + t.Fatalf("activation result leaked secret material: %s (%v)", wire, err) + } + + if _, err := core.ActivateAgent(context.Background(), ActivateAgentOptions{ + AgentID: "opencode", + Provider: "novita", + APIKey: "other-secret", + Model: "model-b", + }); err != nil { + t.Fatal(err) + } + opencodeEnv, err := os.ReadFile(filepath.Join(home, ".oneagent", "agents", "opencode.env")) + if err != nil || !strings.Contains(string(opencodeEnv), "other-secret") || strings.Contains(string(opencodeEnv), "codex-secret") { + t.Fatalf("isolated OpenCode env = %q, err=%v", opencodeEnv, err) + } + codexEnv, _ := os.ReadFile(envPath) + if strings.Contains(string(codexEnv), "other-secret") { + t.Fatal("activating OpenCode changed Codex credentials") + } +} + +func TestActivateAgentReusesProfileKeyAndDiscoversModel(t *testing.T) { + home := t.TempDir() + client := provider.NewClient(appProviderDoer(func(request *http.Request) (*http.Response, error) { + if request.Method != http.MethodGet || request.URL.Path != "/openai/v1/models" { + t.Fatalf("unexpected model discovery request: %s %s", request.Method, request.URL.Path) + } + return appProviderResponse(http.StatusOK, `{"data":[{"id":"embed-v1"},{"id":"chat-model"}]}`), nil + })) + core := activationCore(t, home, client, "linux") + if _, err := core.SaveProfile(context.Background(), SaveProfileOptions{ + ID: "team", + Provider: "ppio", + Model: "saved-model", + APIKey: "stored-secret", + AgentIDs: []string{"opencode"}, + }); err != nil { + t.Fatal(err) + } + result, err := core.ActivateAgent(context.Background(), ActivateAgentOptions{ + AgentID: "opencode", + Provider: "ppio", + ProfileID: "team", + }) + if err != nil { + t.Fatal(err) + } + if result.Model != "chat-model" { + t.Fatalf("resolved model = %q", result.Model) + } + binding, err := core.profiles.ReadAgentBinding("opencode") + if err != nil || binding == nil || binding.ProfileRef != "team" { + t.Fatalf("profile-linked binding = %#v, err=%v", binding, err) + } + configData, _ := os.ReadFile(result.Config) + if strings.Contains(string(configData), "stored-secret") { + t.Fatal("OpenCode config leaked the profile key") + } +} + +func TestActivateAgentDispatchesAllManagedAdapters(t *testing.T) { + home := t.TempDir() + core := activationCore(t, home, provider.NewClient(nil), "linux") + for _, agentID := range []string{"codex", "claude-code", "opencode", "kilo-cli", "aider"} { + t.Run(agentID, func(t *testing.T) { + result, err := core.ActivateAgent(context.Background(), ActivateAgentOptions{ + AgentID: agentID, + Provider: "ppio", + APIKey: "adapter-secret-" + agentID, + Model: "model-a", + }) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(result.Config); err != nil { + t.Fatalf("config %s was not written: %v", result.Config, err) + } + if binding, err := core.profiles.ReadAgentBinding(agentID); err != nil || binding == nil { + t.Fatalf("binding = %#v, err=%v", binding, err) + } + }) + } + claude, err := os.ReadFile(filepath.Join(home, ".claude", "settings.json")) + if err != nil || !strings.Contains(string(claude), "adapter-secret-claude-code") { + t.Fatalf("Claude native config = %q, err=%v", claude, err) + } + aider, err := os.ReadFile(filepath.Join(home, ".oneagent", "aider.env")) + if err != nil || !strings.Contains(string(aider), "adapter-secret-aider") { + t.Fatalf("Aider config = %q, err=%v", aider, err) + } +} + +func TestActivateAgentRejectsInvalidInputsAndDoesNotPublishFailedBinding(t *testing.T) { + home := t.TempDir() + core := activationCore(t, home, provider.NewClient(nil), "linux") + for _, options := range []ActivateAgentOptions{ + {AgentID: "cursor", Provider: "ppio", APIKey: "key", Model: "model"}, + {AgentID: "no-such-agent", Provider: "ppio", APIKey: "key", Model: "model"}, + {AgentID: "../escape", Provider: "ppio", APIKey: "key", Model: "model"}, + {AgentID: "codex", Provider: "ppio", Model: "model"}, + } { + if _, err := core.ActivateAgent(context.Background(), options); err == nil { + t.Errorf("invalid activation %#v unexpectedly succeeded", options) + } + } + + path := filepath.Join(home, ".config", "opencode", "opencode.jsonc") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + original := "{\n // keep this comment\n \"theme\": \"dark\"\n}\n" + if err := os.WriteFile(path, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + if _, err := core.ActivateAgent(context.Background(), ActivateAgentOptions{ + AgentID: "opencode", + Provider: "ppio", + APIKey: "key", + Model: "model", + }); err == nil || !strings.Contains(err.Error(), "JSONC comments") { + t.Fatalf("JSONC activation error = %v", err) + } + if got, _ := os.ReadFile(path); string(got) != original { + t.Fatalf("failed activation modified JSONC: %q", got) + } + if binding, err := core.profiles.ReadAgentBinding("opencode"); err != nil || binding != nil { + t.Fatalf("failed activation published binding = %#v, err=%v", binding, err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := core.ActivateAgent(ctx, ActivateAgentOptions{AgentID: "codex", Provider: "ppio", APIKey: "key", Model: "model"}); err == nil { + t.Fatal("cancelled activation unexpectedly succeeded") + } + if _, err := core.profiles.ReadAgentBinding("codex"); err != nil { + t.Fatal(err) + } +} + +func TestActivateAgentRejectsUnsupportedPlatform(t *testing.T) { + core := NewUseCases(StatusOptions{Home: t.TempDir(), Platform: platform.Info{OS: "plan9", Arch: "x64", Shell: "sh"}}) + _, err := core.ActivateAgent(context.Background(), ActivateAgentOptions{AgentID: "codex", Provider: "ppio", APIKey: "key", Model: "model"}) + if err == nil || !strings.Contains(err.Error(), "not supported") { + t.Fatalf("unsupported platform error = %v", err) + } +} diff --git a/internal/app/status.go b/internal/app/status.go index 800bfa56..874ccfb1 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -1,6 +1,6 @@ -// Package app contains transport-independent use cases. Status, Provider and -// profile reads/writes are migrated slices; installation and Agent config -// writes remain in Python until their Go equivalents pass their own gates. +// Package app contains transport-independent use cases. Status, Provider, +// profile, and single-Agent activation are migrated slices; installation +// remains in Python until its Go equivalent passes its own gates. package app import ( @@ -17,6 +17,7 @@ import ( "github.com/MaimoryLab/OneAgent/internal/platform" profileStore "github.com/MaimoryLab/OneAgent/internal/profile" "github.com/MaimoryLab/OneAgent/internal/provider" + "github.com/MaimoryLab/OneAgent/internal/securefs" ) type CommandLookup func(string) (string, bool) @@ -25,13 +26,18 @@ type StatusOptions struct { Home string Platform platform.Info Lookup CommandLookup + // FileSystem is optional and exists for tests or platform-specific hosts + // that need to inject ACL behavior. Production callers use the default + // securefs implementation for the selected platform. + FileSystem *securefs.Store } type UseCases struct { - status StatusOptions - provider *provider.Client - profiles profileStore.Store - writeMu sync.Mutex + status StatusOptions + provider *provider.Client + profiles profileStore.Store + filesystem securefs.Store + writeMu sync.Mutex } func NewUseCases(options StatusOptions) *UseCases { @@ -65,10 +71,20 @@ func newUseCases(options StatusOptions, client *provider.Client, profiles profil if profiles.Home == "" { profiles = profileStore.NewStore(options.Home, options.Platform.OS) } + filesystem := securefs.New(securefs.Options{OS: options.Platform.OS}) + if options.FileSystem != nil { + filesystem = *options.FileSystem + profiles.FS = &filesystem + } else if profiles.FS != nil { + // Reuse an injected profile filesystem so one operation has one + // security policy for profile, env, and Agent config writes. + filesystem = *profiles.FS + } return &UseCases{ - status: options, - provider: client, - profiles: profiles, + status: options, + provider: client, + profiles: profiles, + filesystem: filesystem, } } diff --git a/internal/binding/services.go b/internal/binding/services.go index ebeab02a..40df54f6 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -24,7 +24,7 @@ func NewServices(core *app.UseCases, opener BrowserOpener) *Services { return &Services{ Status: &StatusService{core: core}, Provider: NewProviderService(core, opener), - Agent: &AgentService{}, + Agent: NewAgentService(core), Profile: NewProfileService(core), } } @@ -118,7 +118,13 @@ func (s *ProviderService) OpenRegistration(ctx context.Context, request OpenRegi return OpenRegistrationResponse{OK: true, URL: provider.Home, Message: "Provider registration opened"}, nil } -type AgentService struct{} +type AgentService struct { + core *app.UseCases +} + +func NewAgentService(core *app.UseCases) *AgentService { + return &AgentService{core: core} +} func (s *AgentService) Install(ctx context.Context, request InstallRequest) (InstallResponse, error) { if err := contextError(ctx); err != nil { @@ -131,7 +137,30 @@ func (s *AgentService) Activate(ctx context.Context, request ActivateRequest) (A if err := contextError(ctx); err != nil { return ActivateResponse{}, err } - return ActivateResponse{}, notReady("Agent activation is not available in the migration foundation") + if s == nil || s.core == nil { + return ActivateResponse{}, notReady("Agent activation is not configured") + } + result, err := s.core.ActivateAgent(ctx, app.ActivateAgentOptions{ + AgentID: request.AgentID, + Provider: request.Provider, + APIBaseURL: request.APIBaseURL, + APIKey: request.APIKey, + Model: request.Model, + ProfileID: request.ProfileID, + SmallFastModel: request.SmallFastModel, + }) + if err != nil { + return ActivateResponse{}, err + } + return ActivateResponse{ + OK: true, + Agent: result.AgentID, + Config: result.Config, + Provider: result.Provider, + Model: result.Model, + Restart: result.Restart, + Next: result.Next, + }, nil } type ProfileService struct { diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index 7eb7a287..983d8bae 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -147,6 +147,35 @@ func TestProfileServiceSavesWithoutReturningSecret(t *testing.T) { } } +func TestAgentServiceActivatesThroughGoUseCase(t *testing.T) { + home := t.TempDir() + core := app.NewUseCases(app.StatusOptions{ + Home: home, + Platform: platform.For("linux", "amd64"), + Lookup: func(string) (string, bool) { return "", false }, + }) + service := NewAgentService(core) + response, err := service.Activate(context.Background(), ActivateRequest{ + AgentID: "codex", + Provider: "ppio", + APIKey: "binding-secret", + Model: "model-a", + }) + if err != nil { + t.Fatal(err) + } + if !response.OK || response.Agent != "codex" || response.Provider != "ppio" || response.Model != "model-a" { + t.Fatalf("activation response = %#v", response) + } + wire, err := json.Marshal(response) + if err != nil || strings.Contains(string(wire), "binding-secret") || strings.Contains(string(wire), "api_key") { + t.Fatalf("activation binding response leaked secret material: %s (%v)", wire, err) + } + if _, err := os.Stat(filepath.Join(home, ".codex", "config.toml")); err != nil { + t.Fatalf("Go activation did not write Codex config: %v", err) + } +} + func TestProviderServiceAggregatesSelectedAgentProtocols(t *testing.T) { seen := make([]string, 0) client := provider.NewClient(providerFakeDoer(func(request *http.Request) (*http.Response, error) { From 701e1b78287acaadddee3e0f7da815e9e645426e Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:24:00 +0800 Subject: [PATCH 20/63] feat: add cancellable Go process runner --- internal/process/process.go | 164 +++++++++++++++++++++++++++++++ internal/process/process_test.go | 115 ++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 internal/process/process.go create mode 100644 internal/process/process_test.go diff --git a/internal/process/process.go b/internal/process/process.go new file mode 100644 index 00000000..fef9f08e --- /dev/null +++ b/internal/process/process.go @@ -0,0 +1,164 @@ +// Package process provides the narrow subprocess boundary shared by the Go +// installer and CLI. It accepts argv arrays only and keeps command output +// bounded before higher layers decide what may be shown to a user. +package process + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "sort" + "strings" + "time" +) + +const MaxOutputBytes = 1 << 20 + +type Result struct { + Args []string + ExitCode int + Stdout string + Stderr string +} + +// Runner is deliberately small so install tests can assert exact argv and +// environment without starting a process. +type Runner interface { + LookPath(string) (string, bool) + Run(context.Context, []string, map[string]string, time.Duration) (Result, error) +} + +type OSRunner struct { + Env map[string]string + Lookup func(string) (string, bool) +} + +func Current() OSRunner { + return OSRunner{Env: environmentFromOS()} +} + +func New(env map[string]string) OSRunner { + values := make(map[string]string, len(env)) + for key, value := range env { + values[key] = value + } + return OSRunner{Env: values} +} + +func (r OSRunner) LookPath(command string) (string, bool) { + if r.Lookup != nil { + return r.Lookup(command) + } + path, err := exec.LookPath(command) + return path, err == nil +} + +func (r OSRunner) Run(ctx context.Context, argv []string, overrides map[string]string, timeout time.Duration) (Result, error) { + result := Result{Args: append([]string(nil), argv...), ExitCode: -1} + if len(argv) == 0 || strings.TrimSpace(argv[0]) == "" { + return result, fmt.Errorf("process argv must not be empty") + } + if ctx == nil { + ctx = context.Background() + } + runContext := ctx + var cancel context.CancelFunc + if timeout > 0 { + runContext, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + command := exec.CommandContext(runContext, argv[0], argv[1:]...) + command.Env = mergeEnvironment(r.Env, overrides) + stdout := &boundedBuffer{limit: MaxOutputBytes} + stderr := &boundedBuffer{limit: MaxOutputBytes} + command.Stdout = stdout + command.Stderr = stderr + err := command.Run() + result.Stdout = stdout.String() + result.Stderr = stderr.String() + if command.ProcessState != nil { + result.ExitCode = command.ProcessState.ExitCode() + } + if runErr := runContext.Err(); runErr != nil { + return result, runErr + } + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + result.ExitCode = exitErr.ExitCode() + return result, nil + } + return result, err + } + if result.ExitCode < 0 { + result.ExitCode = 0 + } + return result, nil +} + +type boundedBuffer struct { + buffer bytes.Buffer + limit int + truncated bool +} + +func (b *boundedBuffer) Write(data []byte) (int, error) { + if b.limit <= 0 { + return len(data), nil + } + remaining := b.limit - b.buffer.Len() + if remaining <= 0 { + b.truncated = true + return len(data), nil + } + if len(data) > remaining { + _, _ = b.buffer.Write(data[:remaining]) + b.truncated = true + return len(data), nil + } + _, _ = b.buffer.Write(data) + return len(data), nil +} + +func (b *boundedBuffer) String() string { + value := b.buffer.String() + if b.truncated { + value += "\n[output truncated]" + } + return value +} + +func mergeEnvironment(base, overrides map[string]string) []string { + values := make(map[string]string, len(base)+len(overrides)) + for key, value := range base { + values[key] = value + } + for key, value := range overrides { + values[key] = value + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]string, 0, len(keys)) + for _, key := range keys { + result = append(result, key+"="+values[key]) + } + return result +} + +func environmentFromOS() map[string]string { + values := make(map[string]string) + for _, entry := range os.Environ() { + key, value, ok := strings.Cut(entry, "=") + if ok { + values[key] = value + } + } + return values +} + +var _ io.Writer = (*boundedBuffer)(nil) diff --git a/internal/process/process_test.go b/internal/process/process_test.go new file mode 100644 index 00000000..0dfb1825 --- /dev/null +++ b/internal/process/process_test.go @@ -0,0 +1,115 @@ +package process + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestProcessHelper(t *testing.T) { + if os.Getenv("ONEAGENT_PROCESS_HELPER") != "1" { + return + } + if os.Getenv("ONEAGENT_PROCESS_EXIT") == "1" { + os.Stderr.WriteString("helper stderr") + os.Exit(7) + } + if os.Getenv("ONEAGENT_PROCESS_WAIT") == "1" { + <-time.After(10 * time.Second) + } + os.Stdout.WriteString(os.Getenv("ONEAGENT_PROCESS_VALUE")) + os.Exit(0) +} + +func helperRunner(t *testing.T) OSRunner { + t.Helper() + path, err := os.Executable() + if err != nil { + t.Fatal(err) + } + runner := New(map[string]string{"ONEAGENT_PROCESS_HELPER": "1"}) + runner.Lookup = func(command string) (string, bool) { + if command == "helper" { + return path, true + } + return "", false + } + return runner +} + +func TestOSRunnerUsesArgvAndMergesEnvironment(t *testing.T) { + runner := helperRunner(t) + result, err := runner.Run(context.Background(), []string{os.Args[0], "-test.run=TestProcessHelper"}, map[string]string{ + "ONEAGENT_PROCESS_VALUE": "safe-value", + }, time.Second) + if err != nil || result.ExitCode != 0 || result.Stdout != "safe-value" { + t.Fatalf("process result = %#v, err=%v", result, err) + } + if len(result.Args) != 2 || result.Args[1] != "-test.run=TestProcessHelper" { + t.Fatalf("argv was changed: %#v", result.Args) + } +} + +func TestOSRunnerReturnsExitCodeAndCapturesOutput(t *testing.T) { + runner := helperRunner(t) + result, err := runner.Run(context.Background(), []string{os.Args[0], "-test.run=TestProcessHelper"}, map[string]string{ + "ONEAGENT_PROCESS_EXIT": "1", + }, time.Second) + if err != nil || result.ExitCode != 7 || result.Stderr != "helper stderr" { + t.Fatalf("non-zero result = %#v, err=%v", result, err) + } +} + +func TestOSRunnerHonorsCancellationAndRejectsEmptyArgv(t *testing.T) { + runner := helperRunner(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := runner.Run(ctx, []string{os.Args[0], "-test.run=TestProcessHelper"}, nil, time.Second) + if err == nil || err != context.Canceled { + t.Fatalf("cancelled process error = %v", err) + } + if _, err := runner.Run(context.Background(), nil, nil, time.Second); err == nil { + t.Fatal("empty argv unexpectedly succeeded") + } +} + +func TestOSRunnerHonorsTimeout(t *testing.T) { + runner := helperRunner(t) + _, err := runner.Run(context.Background(), []string{os.Args[0], "-test.run=TestProcessHelper"}, map[string]string{ + "ONEAGENT_PROCESS_WAIT": "1", + }, 10*time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "deadline exceeded") { + t.Fatalf("timeout error = %v", err) + } +} + +func TestOSRunnerLookPathCanBeInjected(t *testing.T) { + runner := helperRunner(t) + if path, ok := runner.LookPath("helper"); !ok || path == "" { + t.Fatalf("injected lookup = %q, %v", path, ok) + } + if _, ok := runner.LookPath("missing"); ok { + t.Fatal("missing command unexpectedly found") + } +} + +func TestBoundedBufferDoesNotBlockProducer(t *testing.T) { + buffer := &boundedBuffer{limit: 4} + if written, err := buffer.Write([]byte("abcdef")); err != nil || written != 6 { + t.Fatalf("bounded write = %d, %v", written, err) + } + if got := buffer.String(); got != "abcd\n[output truncated]" { + t.Fatalf("bounded output = %q", got) + } +} + +func TestOSRunnerUsesExecutableWithoutShell(t *testing.T) { + runner := helperRunner(t) + result, err := runner.Run(context.Background(), []string{exec.Command("true").Path}, nil, time.Second) + if err != nil || result.ExitCode != 0 { + t.Fatalf("direct executable result = %#v, err=%v", result, err) + } +} From 4a1277da0a453381f366156262690e066ea72ff2 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:32:58 +0800 Subject: [PATCH 21/63] feat: port locked Agent installation primitives --- internal/install/install.go | 374 +++++++++++++++++++++++++++++++ internal/install/install_test.go | 269 ++++++++++++++++++++++ internal/install/runtime.go | 90 ++++++++ 3 files changed, 733 insertions(+) create mode 100644 internal/install/install.go create mode 100644 internal/install/install_test.go create mode 100644 internal/install/runtime.go diff --git a/internal/install/install.go b/internal/install/install.go new file mode 100644 index 00000000..5387683b --- /dev/null +++ b/internal/install/install.go @@ -0,0 +1,374 @@ +package install + +import ( + "context" + "errors" + "fmt" + "net/url" + "regexp" + "sort" + "strings" + "time" + + "github.com/MaimoryLab/OneAgent/internal/catalog" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/process" +) + +type Options struct { + EnforceLocked bool + Latest bool + Timeout time.Duration + Registry string +} + +type Result struct { + Installed bool + Version string + LockedVersion string + Registry string +} + +var versionPattern = regexp.MustCompile(`(^|[^0-9])([0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.+-]+)?)`) + +func VersionFromOutput(text string) string { + match := versionPattern.FindStringSubmatch(text) + if len(match) == 0 { + return "" + } + return match[2] +} + +func InstalledVersion(ctx context.Context, runtime Runtime, agent catalog.Agent) string { + if agent.Command == "" || runtime.Runner == nil { + return "" + } + executable, ok := runtime.Runner.LookPath(agent.Command) + if !ok || executable == "" { + return "" + } + args := append([]string{executable}, agent.VersionArgs...) + if len(agent.VersionArgs) == 0 { + args = append(args, "--version") + } + result, err := runtime.command(ctx, args, nil, VersionCommandTimeout) + if err != nil { + return "" + } + return VersionFromOutput(result.Stdout + "\n" + result.Stderr) +} + +func ResolvePython312(ctx context.Context, runtime Runtime) (string, error) { + if err := checkContext(ctx); err != nil { + return "", err + } + if runtime.Runner == nil { + return "", prerequisiteError("An existing Python 3.12 installation is required for Aider; OneAgent will not download Python automatically") + } + if executable, ok := runtime.Runner.LookPath("python3.12"); ok && executable != "" { + return executable, nil + } + for _, command := range []string{"python3", "python"} { + executable, ok := runtime.Runner.LookPath(command) + if !ok || executable == "" { + continue + } + result, err := runtime.command(ctx, []string{executable, "--version"}, nil, VersionCommandTimeout) + if err != nil || result.ExitCode != 0 { + if isContextError(err) { + return "", timeoutError("Checking for Python 3.12 was cancelled", err) + } + continue + } + version := VersionFromOutput(result.Stdout + "\n" + result.Stderr) + if strings.HasPrefix(version, "3.12.") { + return executable, nil + } + } + if runtime.Platform.OS == "windows" { + if launcher, ok := runtime.Runner.LookPath("py"); ok && launcher != "" { + result, err := runtime.command(ctx, []string{launcher, "-3.12", "--version"}, nil, VersionCommandTimeout) + if err != nil { + if isContextError(err) { + return "", timeoutError("Checking for Python 3.12 was cancelled", err) + } + } else if result.ExitCode == 0 { + return "3.12", nil + } + } + } + return "", prerequisiteError("An existing Python 3.12 installation is required for Aider; OneAgent will not download Python automatically") +} + +func ResolveRegistry(value string) (string, error) { + official := officialRegistry() + if value == "" { + return official, nil + } + for _, mirror := range catalog.Mirrors() { + if value == mirror.ID { + return mirror.Registry, nil + } + } + for _, character := range value { + if character < 32 || character == 127 { + return "", oneerrors.New(oneerrors.InvalidRequest, "Registry URL contains control characters") + } + } + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "", oneerrors.New(oneerrors.InvalidRequest, "Registry URL must start with https://") + } + if parsed.User != nil { + return "", oneerrors.New(oneerrors.InvalidRequest, "Registry URL must not contain credentials") + } + return strings.TrimRight(value, "/") + "/", nil +} + +func VerifyNPMIntegrity(ctx context.Context, runtime Runtime, npm, spec, expected, registry string, timeout time.Duration) error { + if expected == "" { + return nil + } + result, err := runtime.command(ctx, []string{npm, "view", spec, "dist.integrity", "--registry=" + registry}, nil, runtime.timeout(timeout)) + if err != nil { + if isContextError(err) { + return oneerrors.New(oneerrors.AgentInstallFailed, "Timed out reading the checksum for "+spec, oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + return oneerrors.New(oneerrors.AgentInstallFailed, "Cannot read the checksum for "+spec, oneerrors.WithRetryable(true), oneerrors.WithCause(err)) + } + if result.ExitCode != 0 { + return oneerrors.New(oneerrors.AgentInstallFailed, fmt.Sprintf("%s is not available on %s", spec, registry), oneerrors.WithRetryable(true)) + } + reported := strings.TrimSpace(result.Stdout) + if reported != expected { + return oneerrors.New(oneerrors.AgentInstallFailed, fmt.Sprintf("Checksum mismatch for %s on %s: manifest expects %s, registry reports %s", spec, registry, expected, valueOrNone(reported))) + } + return nil +} + +func InstallLockedAgent(ctx context.Context, runtime Runtime, agentID string, agent catalog.Agent, options Options) (Result, error) { + if err := checkContext(ctx); err != nil { + return Result{}, err + } + if agent.Package == nil { + return Result{}, prerequisiteError(fmt.Sprintf("%s has no package installation contract", agent.Name)) + } + packageInfo := *agent.Package + locked := packageInfo.Version + result := Result{LockedVersion: locked} + var executable string + if agent.Command != "" && runtime.Runner != nil { + executable, _ = runtime.Runner.LookPath(agent.Command) + } + current := "" + if executable != "" { + current = InstalledVersion(ctx, runtime, agent) + if !options.EnforceLocked { + result.Version = current + return result, nil + } + if current == locked { + result.Version = current + return result, nil + } + } + + if err := requirePrerequisites(ctx, runtime, agent); err != nil { + return Result{}, err + } + registry, err := ResolveRegistry(options.Registry) + if err != nil { + return Result{}, err + } + manager := packageInfo.Manager + packageName := packageInfo.Name + var argv []string + environment := map[string]string{} + switch manager { + case "npm": + npm, ok := runtime.Runner.LookPath("npm") + if !ok || npm == "" { + return Result{}, prerequisiteError(fmt.Sprintf("npm is required to install %s", agent.Name)) + } + spec := packageName + if !options.Latest { + spec += "@" + locked + if err := VerifyNPMIntegrity(ctx, runtime, npm, spec, pointerValue(packageInfo.Integrity), registry, options.Timeout); err != nil { + return Result{}, err + } + } + if registry != officialRegistry() { + environment["npm_config_registry"] = registry + } + argv = []string{npm, "install", "-g", spec} + case "uv": + uv, ok := runtime.Runner.LookPath("uv") + if !ok || uv == "" { + return Result{}, prerequisiteError("uv is required to install Aider") + } + python, pythonErr := ResolvePython312(ctx, runtime) + if pythonErr != nil { + return Result{}, pythonErr + } + spec := packageName + if !options.Latest { + spec += "==" + locked + } + argv = []string{uv, "tool", "install", "--force", "--python", python, "--no-python-downloads", spec} + default: + return Result{}, prerequisiteError(fmt.Sprintf("No allowlisted package manager for %s", agent.Name)) + } + commandResult, runErr := runtime.command(ctx, argv, environment, runtime.timeout(options.Timeout)) + if runErr != nil { + if isContextError(runErr) { + return Result{}, oneerrors.New(oneerrors.Timeout, fmt.Sprintf("Installing %s timed out", agent.Name), oneerrors.WithRetryable(true), oneerrors.WithCause(runErr)) + } + return Result{}, oneerrors.New(oneerrors.AgentInstallFailed, fmt.Sprintf("Cannot start installer for %s", agent.Name), oneerrors.WithRetryable(true), oneerrors.WithCause(runErr)) + } + if commandResult.ExitCode != 0 { + detail := installerFailureDetail(commandResult, runtime.Env) + message := fmt.Sprintf("Installing %s failed with exit code %d", agent.Name, commandResult.ExitCode) + if detail != "" { + message += ": " + detail + } + return Result{}, oneerrors.New(oneerrors.AgentInstallFailed, message, oneerrors.WithRetryable(true)) + } + result.Installed = true + if !options.Latest { + result.Version = locked + } + result.Registry = registry + return result, nil +} + +func requirePrerequisites(ctx context.Context, runtime Runtime, agent catalog.Agent) error { + if runtime.Runner == nil { + return prerequisiteError("A process runner is required for Agent installation") + } + packageInfo := agent.Package + if packageInfo == nil { + return prerequisiteError(fmt.Sprintf("%s has no package installation contract", agent.Name)) + } + if packageInfo.Manager == "npm" { + if _, ok := runtime.Runner.LookPath("npm"); !ok { + return prerequisiteError(fmt.Sprintf("npm is required to install %s", agent.Name)) + } + } + if packageInfo.Manager == "uv" { + if _, ok := runtime.Runner.LookPath("uv"); !ok { + return prerequisiteError("uv is required to install Aider") + } + } + if runtime.Platform.OS == "windows" { + missing := make([]string, 0) + for _, prerequisite := range agent.WindowsPrerequisites { + if _, ok := runtime.Runner.LookPath(prerequisite); !ok { + missing = append(missing, prerequisite) + } + } + if len(missing) > 0 { + sort.Strings(missing) + return prerequisiteError(fmt.Sprintf("%s is required for %s on Windows", missing[0], agent.Name)) + } + } + return nil +} + +func installerFailureDetail(result process.Result, environment map[string]string) string { + text := redact(result.Stderr+"\n"+result.Stdout, secretValues(environment)) + text = ansiPattern.ReplaceAllString(text, "") + lines := make([]string, 0) + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line != "" { + lines = append(lines, line) + } + } + if len(lines) > 3 { + lines = lines[len(lines)-3:] + } + return trimRunes(strings.Join(lines, " | "), 600) +} + +func Redact(text string, secrets []string) string { + return redact(text, secrets) +} + +func redact(text string, secrets []string) string { + for _, secret := range secrets { + if secret != "" { + text = strings.ReplaceAll(text, secret, "[redacted]") + } + } + return text +} + +func secretValues(environment map[string]string) []string { + values := make([]string, 0) + for key, value := range environment { + upper := strings.ToUpper(key) + if value != "" && (strings.Contains(upper, "KEY") || strings.Contains(upper, "TOKEN") || strings.Contains(upper, "SECRET") || strings.Contains(upper, "PASSWORD")) { + values = append(values, value) + } + } + sort.Strings(values) + return values +} + +func isContextError(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + +func checkContext(ctx context.Context) error { + if ctx == nil { + return nil + } + if err := ctx.Err(); err != nil { + return timeoutError("Agent installation request was cancelled", err) + } + return nil +} + +func timeoutError(message string, cause error) error { + return oneerrors.New(oneerrors.Timeout, message, oneerrors.WithRetryable(true), oneerrors.WithCause(cause)) +} + +func prerequisiteError(message string) error { + return oneerrors.New(oneerrors.PrerequisiteMissing, message) +} + +func pointerValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func valueOrNone(value string) string { + if value == "" { + return "(none)" + } + return value +} + +func officialRegistry() string { + for _, mirror := range catalog.Mirrors() { + if mirror.ID == "official" { + return mirror.Registry + } + } + return "https://registry.npmjs.org/" +} + +func trimRunes(value string, limit int) string { + if limit <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) diff --git a/internal/install/install_test.go b/internal/install/install_test.go new file mode 100644 index 00000000..6351f59a --- /dev/null +++ b/internal/install/install_test.go @@ -0,0 +1,269 @@ +package install + +import ( + "context" + "reflect" + "strings" + "testing" + "time" + + "github.com/MaimoryLab/OneAgent/internal/catalog" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/process" +) + +type fakeInstallRunner struct { + paths map[string]string + calls [][]string + lastCall []string + envs []map[string]string + run func([]string, map[string]string) (process.Result, error) + version string +} + +func (r *fakeInstallRunner) LookPath(command string) (string, bool) { + path, ok := r.paths[command] + return path, ok +} + +func (r *fakeInstallRunner) Run(_ context.Context, argv []string, env map[string]string, _ time.Duration) (process.Result, error) { + runnerArgs := append([]string(nil), argv...) + r.calls = append(r.calls, runnerArgs) + r.lastCall = runnerArgs + r.envs = append(r.envs, cloneEnv(env)) + if r.run != nil { + return r.run(argv, env) + } + if strings.Contains(strings.Join(argv, " "), "dist.integrity") { + return r.integrityResult(argv), nil + } + if containsArg(argv, "--version") { + version := r.version + if version == "" { + version = "0.0.1" + } + return process.Result{Args: runnerArgs, ExitCode: 0, Stdout: "tool " + version}, nil + } + return r.installResult(argv), nil +} + +func (r *fakeInstallRunner) integrityResult(argv []string) process.Result { + name := "" + if len(argv) > 2 { + spec := argv[2] + if index := strings.LastIndex(spec, "@"); index > 0 { + spec = spec[:index] + } + name = spec + } + for _, id := range catalog.AgentIDs(mustManifest()) { + agent := mustManifest().Agents[id] + if agent.Package != nil && agent.Package.Name == name && agent.Package.Integrity != nil { + return process.Result{Args: append([]string(nil), argv...), ExitCode: 0, Stdout: *agent.Package.Integrity + "\n"} + } + } + return process.Result{Args: append([]string(nil), argv...), ExitCode: 0} +} + +func (r *fakeInstallRunner) installResult(argv []string) process.Result { + return process.Result{Args: append([]string(nil), argv...), ExitCode: 0} +} + +func mustManifest() catalog.Manifest { + manifest, err := catalog.LoadEmbedded() + if err != nil { + panic(err) + } + return manifest +} + +func containsArg(argv []string, wanted string) bool { + for _, value := range argv { + if value == wanted { + return true + } + } + return false +} + +func runtimeForInstall(runner process.Runner, osID string, env map[string]string) Runtime { + return Runtime{ + Home: "/tmp/oneagent-install", + Platform: platform.Info{OS: osID, Arch: "x64", Shell: "bash"}, + Env: env, + Runner: runner, + } +} + +func TestVersionFromOutputAndInstalledVersion(t *testing.T) { + for input, want := range map[string]string{ + "codex-cli 0.145.0": "0.145.0", + "v2.1.217\n": "2.1.217", + "release 1.2.3-beta.1+build": "1.2.3-beta.1+build", + "version 12.4.0 and 9.0.0": "12.4.0", + "no version": "", + "embedded 10.20 is not a version": "", + } { + if got := VersionFromOutput(input); got != want { + t.Errorf("VersionFromOutput(%q) = %q, want %q", input, got, want) + } + } + runner := &fakeInstallRunner{paths: map[string]string{"agent": "/bin/agent"}} + runtime := runtimeForInstall(runner, "linux", nil) + agent := catalog.Agent{Command: "agent", VersionArgs: []string{"--version"}} + if got := InstalledVersion(context.Background(), runtime, agent); got != "0.0.1" { + t.Fatalf("InstalledVersion() = %q", got) + } +} + +func TestResolveRegistryValidatesHTTPSAndMirrors(t *testing.T) { + if got, err := ResolveRegistry(""); err != nil || got != "https://registry.npmjs.org/" { + t.Fatalf("default registry = %q, %v", got, err) + } + if got, err := ResolveRegistry("official"); err != nil || got != "https://registry.npmjs.org/" { + t.Fatalf("official registry = %q, %v", got, err) + } + if got, err := ResolveRegistry("https://npm.example.com"); err != nil || got != "https://npm.example.com/" { + t.Fatalf("custom registry = %q, %v", got, err) + } + for _, value := range []string{"http://npm.example.com", "https://user:secret@npm.example.com", "https://npm.example.com/\nheader"} { + if _, err := ResolveRegistry(value); err == nil || oneerrors.As(err).Code != oneerrors.InvalidRequest { + t.Errorf("invalid registry %q returned %v", value, err) + } + } +} + +func TestInstallLockedNPMUsesPinnedIntegrityAndMirrorEnvironment(t *testing.T) { + manifest := mustManifest() + agent := manifest.Agents["codex"] + runner := &fakeInstallRunner{paths: map[string]string{"npm": "/fake/npm"}} + runtime := runtimeForInstall(runner, "linux", map[string]string{"PATH": "/bin"}) + result, err := InstallLockedAgent(context.Background(), runtime, "codex", agent, Options{EnforceLocked: true, Registry: "npmmirror"}) + if err != nil { + t.Fatal(err) + } + if !result.Installed || result.Version != agent.Package.Version || result.Registry != "https://registry.npmmirror.com/" { + t.Fatalf("install result = %#v", result) + } + if len(runner.envs) != 2 || runner.envs[0]["npm_config_registry"] != "" || runner.envs[1]["npm_config_registry"] != "https://registry.npmmirror.com/" { + t.Fatalf("runner environments = %#v", runner.envs) + } + if !reflect.DeepEqual(runner.lastCall, []string{"/fake/npm", "install", "-g", "@openai/codex@0.145.0"}) { + t.Fatalf("last command = %#v", runner.lastCall) + } +} + +func TestInstallLockedAgentShortCircuitsAndSupportsLatest(t *testing.T) { + manifest := mustManifest() + agent := manifest.Agents["codex"] + runner := &fakeInstallRunner{paths: map[string]string{"npm": "/fake/npm", "codex": "/fake/codex"}, version: agent.Package.Version} + runtime := runtimeForInstall(runner, "linux", nil) + result, err := InstallLockedAgent(context.Background(), runtime, "codex", agent, Options{EnforceLocked: true}) + if err != nil || result.Installed || result.Version != agent.Package.Version || len(runner.calls) != 1 { + t.Fatalf("unlocked version result = %#v, err=%v, calls=%#v", result, err, runner.calls) + } + runner = &fakeInstallRunner{paths: map[string]string{"npm": "/fake/npm", "codex": "/fake/codex"}} + runtime.Runner = runner + result, err = InstallLockedAgent(context.Background(), runtime, "codex", agent, Options{EnforceLocked: false}) + if err != nil || result.Installed || result.Version != "0.0.1" { + t.Fatalf("non-enforced result = %#v, err=%v", result, err) + } + runner = &fakeInstallRunner{paths: map[string]string{"npm": "/fake/npm"}} + runtime.Runner = runner + result, err = InstallLockedAgent(context.Background(), runtime, "codex", agent, Options{EnforceLocked: true, Latest: true}) + if err != nil || !result.Installed || result.Version != "" { + t.Fatalf("latest result = %#v, err=%v", result, err) + } + if !reflect.DeepEqual(runner.lastCall, []string{"/fake/npm", "install", "-g", "@openai/codex"}) { + t.Fatalf("latest command = %#v", runner.lastCall) + } +} + +func TestInstallLockedAgentSupportsUVAndPythonBoundaries(t *testing.T) { + manifest := mustManifest() + agent := manifest.Agents["aider"] + runner := &fakeInstallRunner{paths: map[string]string{"uv": "/fake/uv", "python3.12": "/fake/python"}} + runtime := runtimeForInstall(runner, "linux", nil) + result, err := InstallLockedAgent(context.Background(), runtime, "aider", agent, Options{EnforceLocked: true}) + if err != nil || !result.Installed { + t.Fatalf("uv result = %#v, err=%v", result, err) + } + if !reflect.DeepEqual(runner.lastCall, []string{"/fake/uv", "tool", "install", "--force", "--python", "/fake/python", "--no-python-downloads", "aider-chat==0.86.2"}) { + t.Fatalf("uv command = %#v", runner.lastCall) + } + + pythonRunner := &fakeInstallRunner{paths: map[string]string{"python3": "/fake/python3"}} + pythonRunner.run = func(argv []string, _ map[string]string) (process.Result, error) { + return process.Result{Args: argv, ExitCode: 0, Stdout: "Python 3.12.9"}, nil + } + pythonRuntime := runtimeForInstall(pythonRunner, "linux", nil) + if got, err := ResolvePython312(context.Background(), pythonRuntime); err != nil || got != "/fake/python3" { + t.Fatalf("python3 resolution = %q, %v", got, err) + } + + windowsRunner := &fakeInstallRunner{paths: map[string]string{"py": "py.exe"}} + windowsRunner.run = func(argv []string, _ map[string]string) (process.Result, error) { + return process.Result{Args: argv, ExitCode: 0, Stdout: "Python 3.12.7"}, nil + } + windowsRuntime := runtimeForInstall(windowsRunner, "windows", nil) + if got, err := ResolvePython312(context.Background(), windowsRuntime); err != nil || got != "3.12" { + t.Fatalf("py launcher resolution = %q, %v", got, err) + } +} + +func TestInstallPrerequisitesAndFailuresAreStableAndRedacted(t *testing.T) { + manifest := mustManifest() + agent := manifest.Agents["codex"] + missing := &fakeInstallRunner{paths: map[string]string{}} + _, err := InstallLockedAgent(context.Background(), runtimeForInstall(missing, "linux", nil), "codex", agent, Options{EnforceLocked: true}) + if err == nil || oneerrors.As(err).Code != oneerrors.PrerequisiteMissing || !strings.Contains(err.Error(), "npm") { + t.Fatalf("missing npm error = %v", err) + } + + secret := "install-secret" + failing := &fakeInstallRunner{paths: map[string]string{"npm": "/fake/npm"}} + failing.run = func(argv []string, _ map[string]string) (process.Result, error) { + if strings.Contains(strings.Join(argv, " "), "dist.integrity") { + return failing.integrityResult(argv), nil + } + return process.Result{Args: argv, ExitCode: 9, Stderr: "failed with " + secret}, nil + } + runtime := runtimeForInstall(failing, "linux", map[string]string{"API_KEY": secret}) + _, err = InstallLockedAgent(context.Background(), runtime, "codex", agent, Options{EnforceLocked: true}) + if err == nil || oneerrors.As(err).Code != oneerrors.AgentInstallFailed || strings.Contains(err.Error(), secret) || !strings.Contains(err.Error(), "[redacted]") { + t.Fatalf("redacted install failure = %v", err) + } + + timed := &fakeInstallRunner{paths: map[string]string{"npm": "/fake/npm"}} + timed.run = func(argv []string, _ map[string]string) (process.Result, error) { + if strings.Contains(strings.Join(argv, " "), "dist.integrity") { + return timed.integrityResult(argv), nil + } + return process.Result{Args: argv, ExitCode: -1}, context.DeadlineExceeded + } + _, err = InstallLockedAgent(context.Background(), runtimeForInstall(timed, "linux", nil), "codex", agent, Options{EnforceLocked: true}) + if err == nil || oneerrors.As(err).Code != oneerrors.Timeout { + t.Fatalf("timeout install error = %v", err) + } + + if err := VerifyNPMIntegrity(context.Background(), runtime, "/fake/npm", "pkg@1", "sha512-expected", "https://registry.example/", time.Second); err == nil { + t.Fatal("mismatched integrity unexpectedly succeeded") + } +} + +func TestResolvePython312MissingIsPrerequisiteError(t *testing.T) { + runner := &fakeInstallRunner{paths: map[string]string{}} + _, err := ResolvePython312(context.Background(), runtimeForInstall(runner, "linux", nil)) + if err == nil || oneerrors.As(err).Code != oneerrors.PrerequisiteMissing { + t.Fatalf("missing Python error = %v", err) + } +} + +func TestInstallerFailureDetailLimitsLinesAndLength(t *testing.T) { + result := process.Result{Stderr: "one\ntwo\nthree\nfour\n" + strings.Repeat("x", 700)} + detail := installerFailureDetail(result, nil) + if strings.Contains(detail, "one") || !strings.Contains(detail, "four") || len([]rune(detail)) > 600 { + t.Fatalf("failure detail = %q", detail) + } +} diff --git a/internal/install/runtime.go b/internal/install/runtime.go new file mode 100644 index 00000000..e5c15509 --- /dev/null +++ b/internal/install/runtime.go @@ -0,0 +1,90 @@ +// Package install contains the transport-independent Agent installation +// workflow. It is intentionally separate from Wails bindings so the CLI can +// use the same checks and command construction. +package install + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/process" +) + +const ( + DefaultCommandTimeout = 180 * time.Second + VersionCommandTimeout = 30 * time.Second +) + +type Runtime struct { + Home string + Platform platform.Info + Env map[string]string + Runner process.Runner +} + +func NewRuntime(home string, info platform.Info, runner process.Runner, env map[string]string) Runtime { + if info.OS == "" { + info = platform.Current() + } + if home == "" { + home = platform.ResolveHome(env, info.OS) + } + if runner == nil { + current := process.Current() + runner = current + if env == nil { + env = current.Env + } + } + return Runtime{Home: home, Platform: info, Env: cloneEnv(env), Runner: runner} +} + +func CurrentRuntime() Runtime { + info := platform.Current() + current := process.Current() + return NewRuntime(platform.ResolveHome(current.Env, info.OS), info, current, current.Env) +} + +func (r Runtime) command(ctx context.Context, argv []string, env map[string]string, timeout time.Duration) (process.Result, error) { + if r.Runner == nil { + return process.Result{Args: append([]string(nil), argv...), ExitCode: -1}, fmt.Errorf("process runner is not configured") + } + overrides := cloneEnv(r.Env) + for key, value := range env { + overrides[key] = value + } + result, err := r.Runner.Run(ctx, argv, overrides, timeout) + if err == nil { + return result, nil + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return result, err + } + return result, err +} + +func (r Runtime) timeout(value time.Duration) time.Duration { + if value > 0 { + return value + } + return DefaultCommandTimeout +} + +func (r Runtime) environmentValue(key string) string { + return strings.TrimSpace(r.Env[key]) +} + +func cloneEnv(source map[string]string) map[string]string { + if source == nil { + return map[string]string{} + } + result := make(map[string]string, len(source)) + for key, value := range source { + result[key] = value + } + return result +} From 7566eea534f8407b60a5f8074e05e6d0eb99feb4 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:50:43 +0800 Subject: [PATCH 22/63] feat: orchestrate locked Agent installation in Go --- .../OneAgent/internal/binding/index.ts | 1 + .../OneAgent/internal/binding/models.ts | 20 +- internal/app/install.go | 525 ++++++++++++++++++ internal/app/install_test.go | 169 ++++++ internal/app/status.go | 58 +- internal/binding/services.go | 96 +++- internal/binding/services_test.go | 33 ++ 7 files changed, 881 insertions(+), 21 deletions(-) create mode 100644 internal/app/install.go create mode 100644 internal/app/install_test.go diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts index 5bd39fcd..36fe5637 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/index.ts @@ -15,6 +15,7 @@ export { export type { ActivateRequest, ActivateResponse, + AgentInstallResult, InstallRequest, InstallResponse, ModelsRequest, diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts index c3a415da..c00a43eb 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts @@ -21,8 +21,23 @@ export interface ActivateResponse { "next": string; } +export interface AgentInstallResult { + "agent": string; + "status": string; + "installed"?: boolean; + "version"?: string; + "lockedVersion"?: string; + "registry"?: string; + "config"?: string; + "code"?: number; + "error_code"?: string; + "message"?: string; + "retryable": boolean; +} + export interface InstallRequest { "agents": string[] | null; + "profile_agents": string[] | null; "provider": string; "api_base_url": string; "api_key": string; @@ -34,15 +49,18 @@ export interface InstallRequest { "locked_version": boolean; "latest": boolean; "skip_test": boolean; + "registry": string; "timeout": number; } export interface InstallResponse { "ok": boolean; "code": number; - "results": any[] | null; + "results": AgentInstallResult[] | null; "log": string; "next": string; + "probe": ProbeResponse | null; + "probes": { [_ in string]?: ProbeResponse } | null; } export interface ModelsRequest { diff --git a/internal/app/install.go b/internal/app/install.go new file mode 100644 index 00000000..5c9bbc66 --- /dev/null +++ b/internal/app/install.go @@ -0,0 +1,525 @@ +package app + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/MaimoryLab/OneAgent/internal/catalog" + configWriter "github.com/MaimoryLab/OneAgent/internal/config" + oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/install" + profileStore "github.com/MaimoryLab/OneAgent/internal/profile" + "github.com/MaimoryLab/OneAgent/internal/provider" +) + +// InstallAgentsOptions is one installation request. The use case deliberately +// keeps the request independent of Wails or CLI transport types so both entry +// points exercise the same validation and write ordering. +type InstallAgentsOptions struct { + Agents []string + ProfileAgents []string + Provider string + APIBaseURL string + APIKey string + Model string + SmallFastModel string + Configure bool + InstallAgent bool + CheckAgentOnly bool + SkipTest bool + LockedVersion bool + Latest bool + Timeout time.Duration + Registry string + // ProfileID is optional. It is used only as the binding's profile reference; + // the active profile store keeps its existing/default id semantics. + ProfileID string +} + +// InstallOptions is retained as a convenient compatibility name for callers +// that used the earlier Go orchestration prototype. +type InstallOptions = InstallAgentsOptions + +// AgentInstallResult is the public per-Agent outcome. It contains no key or +// process output; the latter is reduced to the redacted aggregate Log field. +type AgentInstallResult struct { + Agent string `json:"agent"` + Status string `json:"status"` + Config string `json:"config,omitempty"` + Installed bool `json:"installed,omitempty"` + Version string `json:"version,omitempty"` + LockedVersion string `json:"lockedVersion,omitempty"` + Registry string `json:"registry,omitempty"` + Code int `json:"code,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + Message string `json:"message,omitempty"` + Retryable bool `json:"retryable"` +} + +// AgentResult is the short compatibility name used by the first Go port. +type AgentResult = AgentInstallResult + +// InstallAgentsResult is the final aggregate outcome of one request. +type InstallAgentsResult struct { + OK bool `json:"ok"` + Code int `json:"code"` + Results []AgentInstallResult `json:"results"` + Log string `json:"log"` + Next string `json:"next"` + Probe *provider.ProbeResult `json:"probe"` + Probes map[string]provider.ProbeResult `json:"probes"` +} + +// InstallResult is retained for callers of the earlier orchestration API. +type InstallResult = InstallAgentsResult + +// InstallAgents validates, installs and configures the requested Agents. A +// failure for one Agent is recorded and the remaining Agents are still tried; +// request-shape and manifest errors are returned before any write occurs. +func (u *UseCases) InstallAgents(ctx context.Context, options InstallAgentsOptions) (InstallAgentsResult, error) { + if u == nil { + return InstallAgentsResult{}, oneerrors.New(oneerrors.InternalError, "Agent service is not configured", oneerrors.WithStatus(501)) + } + if ctx == nil { + ctx = context.Background() + } + if err := contextError(ctx, "Agent installation request was cancelled"); err != nil { + return InstallAgentsResult{}, err + } + + // Installation writes env/config/profile/binding files. Keep the whole + // operation under the same coordinator as Activate and SaveProfile so two + // Wails calls cannot interleave backups or publish a stale profile pointer. + u.writeMu.Lock() + defer u.writeMu.Unlock() + + manifest, err := catalog.LoadEmbedded() + if err != nil { + return InstallAgentsResult{}, err + } + options, err = u.validateInstall(ctx, manifest, options) + if err != nil { + return InstallAgentsResult{}, err + } + + autoAgents := make([]string, 0, len(options.Agents)) + for _, id := range options.Agents { + agent := manifest.Agents[id] + if agent.ConfigMode == "auto" { + autoAgents = append(autoAgents, id) + } + } + + baseURL, providerName, err := u.prepareInstallCredentials(ctx, options, manifest, autoAgents) + if err != nil { + return InstallAgentsResult{}, err + } + probes, err := u.probeInstallProtocols(ctx, options, manifest, autoAgents) + if err != nil { + return InstallAgentsResult{}, err + } + + run := installRun{ + core: u, + manifest: manifest, + options: options, + providerName: providerName, + probes: probes, + } + for _, agentID := range options.Agents { + run.step(ctx, agentID) + } + return run.finish(ctx, baseURL), nil +} + +// validateInstall rejects an unusable request before credentials or config are +// written. Model resolution happens here so every later step uses one value. +func (u *UseCases) validateInstall(ctx context.Context, manifest catalog.Manifest, options InstallAgentsOptions) (InstallAgentsOptions, error) { + if options.LockedVersion && options.Latest { + return options, oneerrors.New(oneerrors.InvalidRequest, "locked_version and latest cannot be enabled together") + } + if options.Timeout <= 0 { + return options, oneerrors.New(oneerrors.InvalidRequest, "timeout must be greater than zero") + } + if len(options.Agents) == 0 { + return options, oneerrors.New(oneerrors.InvalidRequest, "At least one Agent is required") + } + if strings.TrimSpace(options.Provider) == "" { + options.Provider = "ppio" + } + profileAgents := append([]string(nil), options.ProfileAgents...) + if len(profileAgents) == 0 { + profileAgents = append([]string(nil), options.Agents...) + } + options.ProfileAgents = profileAgents + listed := make(map[string]bool, len(profileAgents)) + for _, id := range profileAgents { + listed[id] = true + } + for _, id := range options.Agents { + if !listed[id] { + return options, oneerrors.New(oneerrors.InvalidRequest, "profile_agents must include every requested Agent") + } + } + for _, id := range append(append([]string(nil), options.Agents...), profileAgents...) { + if _, present := manifest.Agents[id]; !present { + return options, oneerrors.New(oneerrors.InvalidRequest, "Unknown Agent: "+id) + } + } + // Validate the registry even when all commands are already installed. This + // prevents an invalid setting from being silently accepted on a no-op run. + if _, err := install.ResolveRegistry(options.Registry); err != nil { + return options, err + } + if strings.TrimSpace(options.Model) == "" { + if options.SkipTest { + options.Model = provider.FallbackProbeModel(options.Provider) + } else { + if u.provider == nil { + return options, oneerrors.New(oneerrors.InternalError, "Model discovery is not configured", oneerrors.WithStatus(501)) + } + model, err := u.provider.ResolveProbeModel(ctx, options.Provider, options.APIKey, "", options.APIBaseURL) + if err != nil { + return options, err + } + options.Model = model + } + } + return options, nil +} + +func (u *UseCases) prepareInstallCredentials(ctx context.Context, options InstallAgentsOptions, manifest catalog.Manifest, autoAgents []string) (baseURL, providerName string, err error) { + if !options.Configure || len(autoAgents) == 0 || options.CheckAgentOnly { + return "", "", nil + } + if options.APIKey == "" { + return "", "", oneerrors.New(oneerrors.InvalidRequest, "API key is required") + } + baseURL, err = provider.ProviderBase(options.Provider, options.APIBaseURL) + if err != nil { + return "", "", err + } + providerName = "Custom" + if definition, found := catalog.ProviderByID(options.Provider); found { + providerName = definition.Name + } + wroteAny := false + for _, agentID := range autoAgents { + agent := manifest.Agents[agentID] + if !needsAgentEnv(agent) { + continue + } + path := agentEnvPath(u.status.Home, u.status.Platform.OS, agentID) + if err := configWriter.WriteAgentEnv(ctx, u.filesystem, path, u.status.Platform.OS, agentID, options.APIKey, baseURL, options.Model, options.SmallFastModel, agent.EnvVars); err != nil { + return "", "", err + } + wroteAny = true + } + if wroteAny { + // Keep the legacy shared file while configurations written by older + // versions may still reference ONEAGENT_API_KEY. + path := filepath.Join(u.status.Home, ".oneagent", envFilename(u.status.Platform.OS)) + if err := configWriter.WriteSharedEnv(ctx, u.filesystem, path, u.status.Platform.OS, options.APIKey, baseURL); err != nil { + return "", "", err + } + } + return baseURL, providerName, nil +} + +func (u *UseCases) probeInstallProtocols(ctx context.Context, options InstallAgentsOptions, manifest catalog.Manifest, autoAgents []string) (map[string]provider.ProbeResult, error) { + probes := make(map[string]provider.ProbeResult) + if !options.Configure || len(autoAgents) == 0 || options.SkipTest || options.CheckAgentOnly { + return probes, nil + } + protocols := make(map[string]bool) + for _, agentID := range autoAgents { + protocols[provider.ProtocolForAdapter(manifest.Agents[agentID].ConfigAdapter)] = true + } + ordered := make([]string, 0, len(protocols)) + for protocolID := range protocols { + ordered = append(ordered, protocolID) + } + sort.Strings(ordered) + for _, protocolID := range ordered { + if u.provider == nil { + return nil, oneerrors.New(oneerrors.InternalError, "Provider probing is not configured", oneerrors.WithStatus(501)) + } + verdict, err := u.provider.Probe(ctx, protocolID, options.Provider, options.APIKey, options.Model, options.APIBaseURL) + if err != nil { + return nil, err + } + probes[protocolID] = verdict + } + u.sharpenInstallModelDiagnosis(ctx, probes, options) + return probes, nil +} + +func (u *UseCases) sharpenInstallModelDiagnosis(ctx context.Context, probes map[string]provider.ProbeResult, options InstallAgentsOptions) { + failing := false + for _, verdict := range probes { + if !verdict.OK { + failing = true + break + } + } + if !failing || u.provider == nil { + return + } + listing, err := u.provider.ListModels(ctx, options.Provider, options.APIKey, options.APIBaseURL) + if err != nil || !listing.OK || len(listing.Models) == 0 { + return + } + for _, model := range listing.Models { + if model == options.Model { + return + } + } + sample := listing.Models + if len(sample) > 5 { + sample = sample[:5] + } + for protocolID, verdict := range probes { + if verdict.OK { + continue + } + code := oneerrors.ModelsUnsupported + verdict.ErrorCode = &code + verdict.Retryable = false + verdict.Message = fmt.Sprintf("Model %q was not found in the endpoint's model list; the %s probe refused it. Available models include: %s.", options.Model, provider.ProtocolLabel(protocolID), strings.Join(sample, ", ")) + probes[protocolID] = verdict + } +} + +type installRun struct { + core *UseCases + manifest catalog.Manifest + options InstallAgentsOptions + providerName string + probes map[string]provider.ProbeResult + results []AgentInstallResult + logs []string + nextSteps []string + firstCode int +} + +func (r *installRun) step(ctx context.Context, agentID string) { + agent := r.manifest.Agents[agentID] + if !contains(agent.Platforms, r.core.status.Platform.OS) { + r.fail(agentID, oneerrors.New(oneerrors.PrerequisiteMissing, fmt.Sprintf("%s is not supported on %s", agent.Name, r.core.status.Platform.OS))) + return + } + if agent.ConfigMode == "guide" { + guide := agent.Guide + r.results = append(r.results, AgentInstallResult{Agent: agentID, Status: "guide-only", Message: guide, Retryable: false}) + r.logs = append(r.logs, "## "+agentID+"\nGuide only. "+guide) + r.nextSteps = append(r.nextSteps, guide) + return + } + if err := r.configure(ctx, agentID, agent); err != nil { + r.fail(agentID, err) + } +} + +func (r *installRun) configure(ctx context.Context, agentID string, agent catalog.Agent) error { + runtime := install.NewRuntime(r.core.status.Home, r.core.status.Platform, r.core.runner, r.core.environment) + installed := install.Result{Version: install.InstalledVersion(ctx, runtime, agent)} + if agent.Package != nil { + installed.LockedVersion = agent.Package.Version + } + if r.options.InstallAgent { + result, err := install.InstallLockedAgent(ctx, runtime, agentID, agent, install.Options{ + EnforceLocked: r.options.LockedVersion, + Latest: r.options.Latest, + Timeout: r.options.Timeout, + Registry: r.options.Registry, + }) + if err != nil { + return err + } + installed = result + if result.Registry != "" { + r.logs = append(r.logs, "## "+agentID+"\nregistry: "+result.Registry) + } + } else if agent.Command != "" { + if _, present := r.core.runner.LookPath(agent.Command); !present { + r.logs = append(r.logs, "## "+agentID+"\nofficial install: "+officialInstallCommand(agent)) + } + } + if r.options.CheckAgentOnly { + _, present := r.core.runner.LookPath(agent.Command) + status := "skipped" + if (agent.Command != "" && present) || installed.Installed { + status = "installed" + } + r.results = append(r.results, installResultFor(agentID, status, "", installed)) + r.logs = append(r.logs, "## "+agentID+"\nAgent check complete.") + return nil + } + configPathValue := "" + if r.options.Configure { + protocolID := provider.ProtocolForAdapter(agent.ConfigAdapter) + if verdict, found := r.probes[protocolID]; found && !verdict.OK { + code := pointerString(verdict.ErrorCode) + if code == "" { + code = oneerrors.ProviderUnreachable + } + failure := oneerrors.New(code, fmt.Sprintf("%s: %s", agent.Name, verdict.Message), oneerrors.WithRetryable(verdict.Retryable)) + return failure + } + configBase, err := provider.ProviderConfigBase(r.options.Provider, r.options.APIBaseURL, protocolID) + if err != nil { + return err + } + configPathValue = configPath(r.core.status.Home, r.core.status.Platform.OS, agent) + if configPathValue == "" { + return oneerrors.New(oneerrors.ConfigWriteFailed, fmt.Sprintf("Managed Agent %s has no configuration path", agentID)) + } + writer := configWriter.NewWriter(r.core.status.Home, r.core.status.Platform.OS, r.core.filesystem) + if err := writeManagedAgentConfig(ctx, writer, agentID, agent, configPathValue, r.providerName, configBase, r.options.APIKey, r.options.Model, r.options.SmallFastModel); err != nil { + return err + } + if _, err := r.core.profiles.WriteAgentBinding(ctx, agentID, profileStore.BindingWriteRequest{ + Provider: r.options.Provider, + BaseURL: configBase, + Model: r.options.Model, + ProfileRef: r.options.ProfileID, + }); err != nil { + return err + } + } + status := "skipped" + message := "Model configuration skipped" + if r.options.Configure { + status = "configured" + message = "Configured" + } + r.results = append(r.results, installResultFor(agentID, status, configPathValue, installed)) + r.logs = append(r.logs, "## "+agentID+"\n"+message+".") + if next := nextStep(r.core.status.Platform.OS, agentID, agent, r.options.Model); next != "" { + r.nextSteps = append(r.nextSteps, next) + } + return nil +} + +func (r *installRun) fail(agentID string, err error) { + converted := oneerrors.As(err) + if r.firstCode == 0 { + r.firstCode = converted.ExitCode + } + r.results = append(r.results, AgentInstallResult{ + Agent: agentID, Status: "failed", Code: converted.ExitCode, + ErrorCode: converted.Code, Message: converted.Message, Retryable: converted.Retryable, + }) + r.logs = append(r.logs, "## "+agentID+"\n"+converted.Message) +} + +func (r *installRun) finish(ctx context.Context, baseURL string) InstallAgentsResult { + chosen := chooseInstallProbe(r.probes) + for _, protocolID := range sortedProbeIDs(r.probes) { + verdict := r.probes[protocolID] + if verdict.OK { + continue + } + if r.firstCode == 0 { + r.firstCode = oneerrors.ExitCodes[pointerString(verdict.ErrorCode)] + if r.firstCode == 0 { + r.firstCode = oneerrors.ExitCodes[oneerrors.ProviderUnreachable] + } + } + r.logs = append(r.logs, "## provider ("+protocolID+")\n"+verdict.Message) + } + failed := false + for _, result := range r.results { + if result.Status == "failed" { + failed = true + break + } + } + probeOK := chosen == nil || chosen.OK + if !failed && probeOK && !r.options.CheckAgentOnly { + if _, err := r.core.profiles.WriteActive(ctx, profileStore.ActiveRequest{ + Agents: r.options.ProfileAgents, + Configure: r.options.Configure, + Provider: r.options.Provider, + BaseURL: baseURL, + Model: r.options.Model, + APIKey: r.options.APIKey, + }); err != nil { + // Config files are the source of truth for running Agents; preserve + // them and surface profile bookkeeping failure in the redacted log. + r.logs = append(r.logs, "## profile\n"+oneerrors.As(err).Message) + } + } + return InstallAgentsResult{ + OK: !failed && probeOK, + Code: r.firstCode, + Results: r.results, + Log: install.Redact(strings.Join(r.logs, "\n\n"), []string{r.options.APIKey}), + Next: strings.Join(r.nextSteps, "\n"), + Probe: chosen, + Probes: r.probes, + } +} + +func installResultFor(agentID, status, path string, installed install.Result) AgentInstallResult { + return AgentInstallResult{ + Agent: agentID, + Status: status, + Config: path, + Installed: installed.Installed, + Version: installed.Version, + LockedVersion: installed.LockedVersion, + Registry: installed.Registry, + Retryable: false, + } +} + +func officialInstallCommand(agent catalog.Agent) string { + if agent.Package == nil { + return "Unsupported package manager: missing" + } + switch agent.Package.Manager { + case "npm": + return "npm install -g " + agent.Package.Name + "@" + agent.Package.Version + case "uv": + return "uv tool install --force --python python3.12 --no-python-downloads " + agent.Package.Name + "==" + agent.Package.Version + default: + manager := agent.Package.Manager + if manager == "" { + manager = "missing" + } + return "Unsupported package manager: " + manager + } +} + +func chooseInstallProbe(probes map[string]provider.ProbeResult) *provider.ProbeResult { + var chosen *provider.ProbeResult + for _, protocolID := range sortedProbeIDs(probes) { + verdict := probes[protocolID] + if chosen == nil || (!verdict.OK && chosen.OK) { + copy := verdict + chosen = © + } + } + return chosen +} + +func sortedProbeIDs(probes map[string]provider.ProbeResult) []string { + ids := make([]string, 0, len(probes)) + for id := range probes { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +func pointerString(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/internal/app/install_test.go b/internal/app/install_test.go new file mode 100644 index 00000000..94b2e010 --- /dev/null +++ b/internal/app/install_test.go @@ -0,0 +1,169 @@ +package app + +import ( + "context" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/process" + "github.com/MaimoryLab/OneAgent/internal/provider" +) + +type installAppRunner struct { + paths map[string]string + calls [][]string + envs []map[string]string +} + +func (r *installAppRunner) LookPath(command string) (string, bool) { + path, ok := r.paths[command] + return path, ok +} + +func (r *installAppRunner) Run(_ context.Context, argv []string, env map[string]string, _ time.Duration) (process.Result, error) { + r.calls = append(r.calls, append([]string(nil), argv...)) + copyEnv := make(map[string]string, len(env)) + for key, value := range env { + copyEnv[key] = value + } + r.envs = append(r.envs, copyEnv) + if strings.Contains(strings.Join(argv, " "), "dist.integrity") { + return process.Result{Args: argv, ExitCode: 0, Stdout: "sha512-test\n"}, nil + } + if len(argv) > 1 && argv[1] == "--version" { + return process.Result{Args: argv, ExitCode: 0, Stdout: "tool 1.0.0"}, nil + } + return process.Result{Args: argv, ExitCode: 0}, nil +} + +type installAppDoer func(*http.Request) (*http.Response, error) + +func (d installAppDoer) Do(request *http.Request) (*http.Response, error) { return d(request) } + +func installAppResponse(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)} +} + +func installCore(t *testing.T, home string, runner process.Runner, doer provider.HTTPDoer) *UseCases { + t.Helper() + return NewUseCasesWithProviderClient(StatusOptions{ + Home: home, + Platform: platform.For("linux", "amd64"), + Runner: runner, + Environment: map[string]string{"HOME": home}, + }, provider.NewClient(doer)) +} + +func installOptions(agents ...string) InstallAgentsOptions { + return InstallAgentsOptions{ + Agents: agents, Provider: "ppio", APIKey: "install-secret", Model: "model-a", + Configure: true, SkipTest: true, Timeout: 30 * time.Second, + } +} + +func TestInstallAgentsWritesAllManagedAdaptersAndPublishesProfileLast(t *testing.T) { + home := t.TempDir() + runner := &installAppRunner{paths: map[string]string{ + "codex": "/fake/codex", "claude": "/fake/claude", "opencode": "/fake/opencode", + "kilo": "/fake/kilo", "aider": "/fake/aider", "npm": "/fake/npm", "uv": "/fake/uv", + }} + core := installCore(t, home, runner, installAppDoer(func(*http.Request) (*http.Response, error) { + return installAppResponse(http.StatusNoContent, ""), nil + })) + result, err := core.InstallAgents(context.Background(), installOptions("codex", "claude-code", "opencode", "kilo-cli", "aider")) + if err != nil { + t.Fatal(err) + } + if !result.OK || len(result.Results) != 5 { + t.Fatalf("install result = %#v", result) + } + for _, item := range result.Results { + if item.Status != "configured" || item.Config == "" { + t.Errorf("result = %#v", item) + } + } + for _, path := range []string{ + filepath.Join(home, ".codex", "config.toml"), + filepath.Join(home, ".claude", "settings.json"), + filepath.Join(home, ".config", "opencode", "opencode.jsonc"), + filepath.Join(home, ".config", "kilo", "kilo.jsonc"), + filepath.Join(home, ".oneagent", "aider.env"), + filepath.Join(home, ".oneagent", "profile.json"), + } { + if _, err := os.Stat(path); err != nil { + t.Errorf("expected file %s: %v", path, err) + } + } + if strings.Contains(result.Log, "install-secret") || strings.Contains(result.Next, "install-secret") { + t.Fatal("API key leaked through install result") + } + active := core.profiles.LoadActive() + if active.Profile == nil || len(active.Profile.AgentIDs) != 5 { + t.Fatalf("active profile = %#v", active) + } +} + +func TestInstallAgentsRefusesInvalidRequestBeforeWriting(t *testing.T) { + home := t.TempDir() + runner := &installAppRunner{paths: map[string]string{"codex": "/fake/codex"}} + core := installCore(t, home, runner, nil) + options := installOptions("codex") + options.LockedVersion = true + options.Latest = true + if _, err := core.InstallAgents(context.Background(), options); err == nil { + t.Fatal("invalid request unexpectedly succeeded") + } + entries, err := os.ReadDir(home) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("invalid request left files: %v", entries) + } +} + +func TestInstallAgentsDoesNotPublishProfileWhenProbeFails(t *testing.T) { + home := t.TempDir() + runner := &installAppRunner{paths: map[string]string{"codex": "/fake/codex"}} + core := installCore(t, home, runner, installAppDoer(func(*http.Request) (*http.Response, error) { + return installAppResponse(http.StatusUnauthorized, `{"error":"bad key"}`), nil + })) + options := installOptions("codex") + options.SkipTest = false + result, err := core.InstallAgents(context.Background(), options) + if err != nil { + t.Fatal(err) + } + if result.OK || len(result.Results) != 1 || result.Results[0].Status != "failed" { + t.Fatalf("failed install result = %#v", result) + } + if active := core.profiles.LoadActive(); active.Profile != nil || active.ID != "" { + t.Fatalf("failed install published active profile: %#v", active) + } +} + +func TestInstallAgentsSharpenModelDiagnosis(t *testing.T) { + home := t.TempDir() + runner := &installAppRunner{paths: map[string]string{"codex": "/fake/codex"}} + core := installCore(t, home, runner, installAppDoer(func(request *http.Request) (*http.Response, error) { + if request.Method == http.MethodGet { + return installAppResponse(http.StatusOK, `{"data":[{"id":"real-model"}]}`), nil + } + return installAppResponse(http.StatusNotFound, ""), nil + })) + options := installOptions("codex") + options.SkipTest = false + result, err := core.InstallAgents(context.Background(), options) + if err != nil { + t.Fatal(err) + } + if result.Probe == nil || pointerString(result.Probe.ErrorCode) != "MODELS_UNSUPPORTED" || !strings.Contains(result.Probe.Message, "real-model") { + t.Fatalf("diagnosis = %#v", result.Probe) + } +} diff --git a/internal/app/status.go b/internal/app/status.go index 874ccfb1..80778d9f 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -1,6 +1,6 @@ -// Package app contains transport-independent use cases. Status, Provider, -// profile, and single-Agent activation are migrated slices; installation -// remains in Python until its Go equivalent passes its own gates. +// Package app contains transport-independent use cases shared by the desktop +// binding and the headless CLI. The Python production path remains available +// during migration, but migrated Go operations are exercised independently. package app import ( @@ -15,6 +15,7 @@ import ( configReader "github.com/MaimoryLab/OneAgent/internal/config" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/process" profileStore "github.com/MaimoryLab/OneAgent/internal/profile" "github.com/MaimoryLab/OneAgent/internal/provider" "github.com/MaimoryLab/OneAgent/internal/securefs" @@ -29,15 +30,19 @@ type StatusOptions struct { // FileSystem is optional and exists for tests or platform-specific hosts // that need to inject ACL behavior. Production callers use the default // securefs implementation for the selected platform. - FileSystem *securefs.Store + FileSystem *securefs.Store + Runner process.Runner + Environment map[string]string } type UseCases struct { - status StatusOptions - provider *provider.Client - profiles profileStore.Store - filesystem securefs.Store - writeMu sync.Mutex + status StatusOptions + provider *provider.Client + profiles profileStore.Store + filesystem securefs.Store + runner process.Runner + environment map[string]string + writeMu sync.Mutex } func NewUseCases(options StatusOptions) *UseCases { @@ -63,11 +68,26 @@ func newUseCases(options StatusOptions, client *provider.Client, profiles profil options.Home = platform.ResolveHome(nil, options.Platform.OS) } if options.Lookup == nil { - options.Lookup = defaultLookup + if options.Runner != nil { + options.Lookup = options.Runner.LookPath + } else { + options.Lookup = defaultLookup + } } if client == nil { client = provider.NewClient(nil) } + runner := options.Runner + if runner == nil { + current := process.Current() + runner = current + if options.Environment == nil { + options.Environment = current.Env + } + } + if options.Environment == nil { + options.Environment = map[string]string{} + } if profiles.Home == "" { profiles = profileStore.NewStore(options.Home, options.Platform.OS) } @@ -81,13 +101,23 @@ func newUseCases(options StatusOptions, client *provider.Client, profiles profil filesystem = *profiles.FS } return &UseCases{ - status: options, - provider: client, - profiles: profiles, - filesystem: filesystem, + status: options, + provider: client, + profiles: profiles, + filesystem: filesystem, + runner: runner, + environment: cloneEnvironment(options.Environment), } } +func cloneEnvironment(source map[string]string) map[string]string { + result := make(map[string]string, len(source)) + for key, value := range source { + result[key] = value + } + return result +} + func NewUseCasesFromEnvironment() *UseCases { info := platform.Current() return NewUseCases(StatusOptions{ diff --git a/internal/binding/services.go b/internal/binding/services.go index 40df54f6..de022909 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -6,6 +6,7 @@ import ( "context" "net/url" "strings" + "time" "github.com/MaimoryLab/OneAgent/internal/app" "github.com/MaimoryLab/OneAgent/internal/catalog" @@ -130,7 +131,38 @@ func (s *AgentService) Install(ctx context.Context, request InstallRequest) (Ins if err := contextError(ctx); err != nil { return InstallResponse{}, err } - return InstallResponse{}, notReady("Agent installation is not available in the migration foundation") + if s == nil || s.core == nil { + return InstallResponse{}, notReady("Agent installation is not configured") + } + timeout := 180 * time.Second + if request.Timeout < 0 || request.Timeout > 3600 { + return InstallResponse{}, oneerrors.New(oneerrors.InvalidRequest, "timeout must be an integer between 1 and 3600") + } + if request.Timeout > 0 { + timeout = time.Duration(request.Timeout) * time.Second + } + result, err := s.core.InstallAgents(ctx, app.InstallAgentsOptions{ + Agents: append([]string(nil), request.Agents...), + ProfileAgents: append([]string(nil), request.ProfileAgents...), + Provider: request.Provider, + APIBaseURL: request.APIBaseURL, + APIKey: request.APIKey, + Model: request.Model, + SmallFastModel: request.SmallFastModel, + ProfileID: request.ProfileID, + Configure: request.Configure, + InstallAgent: request.InstallAgent, + CheckAgentOnly: false, + SkipTest: request.SkipTest, + LockedVersion: request.LockedVersion, + Latest: request.Latest, + Timeout: timeout, + Registry: request.Registry, + }) + if err != nil { + return InstallResponse{}, err + } + return installResponse(result), nil } func (s *AgentService) Activate(ctx context.Context, request ActivateRequest) (ActivateResponse, error) { @@ -243,6 +275,7 @@ type ModelsResponse struct { type InstallRequest struct { Agents []string `json:"agents"` + ProfileAgents []string `json:"profile_agents"` Provider string `json:"provider"` APIBaseURL string `json:"api_base_url"` APIKey string `json:"api_key"` @@ -254,15 +287,32 @@ type InstallRequest struct { LockedVersion bool `json:"locked_version"` Latest bool `json:"latest"` SkipTest bool `json:"skip_test"` + Registry string `json:"registry"` Timeout int `json:"timeout"` } +type AgentInstallResult struct { + Agent string `json:"agent"` + Status string `json:"status"` + Installed bool `json:"installed,omitempty"` + Version string `json:"version,omitempty"` + LockedVersion string `json:"lockedVersion,omitempty"` + Registry string `json:"registry,omitempty"` + Config string `json:"config,omitempty"` + Code int `json:"code,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + Message string `json:"message,omitempty"` + Retryable bool `json:"retryable"` +} + type InstallResponse struct { - OK bool `json:"ok"` - Code int `json:"code"` - Results []any `json:"results"` - Log string `json:"log"` - Next string `json:"next"` + OK bool `json:"ok"` + Code int `json:"code"` + Results []AgentInstallResult `json:"results"` + Log string `json:"log"` + Next string `json:"next"` + Probe *ProbeResponse `json:"probe"` + Probes map[string]ProbeResponse `json:"probes"` } type ActivateRequest struct { @@ -333,3 +383,37 @@ func modelsResponse(result provider.ModelsResult) ModelsResponse { Models: result.Models, } } + +func installResponse(result app.InstallAgentsResult) InstallResponse { + response := InstallResponse{ + OK: result.OK, + Code: result.Code, + Log: result.Log, + Next: result.Next, + Results: make([]AgentInstallResult, 0, len(result.Results)), + Probes: make(map[string]ProbeResponse, len(result.Probes)), + } + for _, item := range result.Results { + response.Results = append(response.Results, AgentInstallResult{ + Agent: item.Agent, + Status: item.Status, + Installed: item.Installed, + Version: item.Version, + LockedVersion: item.LockedVersion, + Registry: item.Registry, + Config: item.Config, + Code: item.Code, + ErrorCode: item.ErrorCode, + Message: item.Message, + Retryable: item.Retryable, + }) + } + if result.Probe != nil { + probe := probeResponse(*result.Probe) + response.Probe = &probe + } + for protocolID, verdict := range result.Probes { + response.Probes[protocolID] = probeResponse(verdict) + } + return response +} diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index 983d8bae..689b0bf1 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -176,6 +176,39 @@ func TestAgentServiceActivatesThroughGoUseCase(t *testing.T) { } } +func TestAgentServiceInstallsThroughGoUseCase(t *testing.T) { + home := t.TempDir() + core := app.NewUseCases(app.StatusOptions{ + Home: home, + Platform: platform.For("linux", "amd64"), + Lookup: func(string) (string, bool) { return "", false }, + }) + service := NewAgentService(core) + response, err := service.Install(context.Background(), InstallRequest{ + Agents: []string{"codex"}, + ProfileAgents: []string{"codex"}, + Provider: "ppio", + APIKey: "binding-install-secret", + Model: "model-a", + Configure: true, + SkipTest: true, + Timeout: 30, + }) + if err != nil { + t.Fatal(err) + } + if !response.OK || len(response.Results) != 1 || response.Results[0].Status != "configured" { + t.Fatalf("install response = %#v", response) + } + wire, err := json.Marshal(response) + if err != nil || strings.Contains(string(wire), "binding-install-secret") || strings.Contains(string(wire), "api_key") { + t.Fatalf("install binding response leaked secret material: %s (%v)", wire, err) + } + if _, err := os.Stat(filepath.Join(home, ".oneagent", "profile.json")); err != nil { + t.Fatalf("Go install did not publish profile: %v", err) + } +} + func TestProviderServiceAggregatesSelectedAgentProtocols(t *testing.T) { seen := make([]string, 0) client := provider.NewClient(providerFakeDoer(func(request *http.Request) (*http.Response, error) { From 1f69f52f3153956aacca7c4c44d92c702f050a44 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:58:54 +0800 Subject: [PATCH 23/63] feat: complete Go CLI agent commands --- cmd/oneagent/main.go | 301 +++++++++++++++++++++++++++++------- cmd/oneagent/main_test.go | 82 ++++++++++ internal/app/agent.go | 14 +- internal/app/status.go | 15 ++ internal/profile/binding.go | 16 +- 5 files changed, 360 insertions(+), 68 deletions(-) create mode 100644 cmd/oneagent/main_test.go diff --git a/cmd/oneagent/main.go b/cmd/oneagent/main.go index 3f2ee2f2..96ef8f44 100644 --- a/cmd/oneagent/main.go +++ b/cmd/oneagent/main.go @@ -1,17 +1,26 @@ package main import ( + "bufio" "context" "encoding/json" "flag" "fmt" "io" "os" + "os/exec" + "runtime" + "sort" + "strings" + "time" "github.com/MaimoryLab/OneAgent/internal/app" "github.com/MaimoryLab/OneAgent/internal/catalog" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/process" + profileStore "github.com/MaimoryLab/OneAgent/internal/profile" + "github.com/MaimoryLab/OneAgent/internal/provider" "github.com/MaimoryLab/OneAgent/internal/version" ) @@ -24,20 +33,16 @@ func run(args []string, stdout, stderr io.Writer) int { printUsage(stdout) return 0 } - if args[0] == "--version" || args[0] == "version" { + switch args[0] { + case "--version", "version": _, _ = fmt.Fprintln(stdout, version.Version) return 0 - } - switch args[0] { case "status": return runStatus(args[1:], stdout, stderr) case "agent": return runAgent(args[1:], stdout, stderr) default: - // Keep the common check-only compatibility flags available while the - // Python wrapper still points at its existing implementation. This Go - // command is safe to opt into and never installs or writes credentials. - return runCompatibilityFlags(args, stdout, stderr) + return runInstall(args, stdout, stderr) } } @@ -53,75 +58,251 @@ func runStatus(args []string, stdout, stderr io.Writer) int { core := app.NewUseCases(app.StatusOptions{Home: *home, Platform: info}) status, err := core.GetStatus(flagsContext()) if err != nil { - return writeError(stdout, err, *jsonOutput) + return writeError(stdout, stderr, err, *jsonOutput, "") } return writeValue(stdout, status, *jsonOutput) } func runAgent(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 || args[0] == "list" { + if len(args) == 0 { + args = []string{"list"} + } + switch args[0] { + case "list": flags := flag.NewFlagSet("agent list", flag.ContinueOnError) flags.SetOutput(stderr) jsonOutput := flags.Bool("json", false, "write JSON") - remaining := args - if len(args) > 0 && args[0] == "list" { - remaining = args[1:] - } - if err := flags.Parse(remaining); err != nil { + home := flags.String("home", "", "override the home directory") + if err := flags.Parse(args[1:]); err != nil { return oneerrors.ExitCodes[oneerrors.InvalidRequest] } - manifest, err := catalog.LoadEmbedded() + core := newCLIUseCases(*home) + bindings, err := core.ListAgentBindings(flagsContext()) if err != nil { - return writeError(stdout, err, *jsonOutput) + return writeError(stdout, stderr, err, *jsonOutput, "") } - items := catalog.PublicCatalog(manifest, platform.Current().OS) if *jsonOutput { - return writeJSON(stdout, map[string]any{"agents": items}) + return writeJSON(stdout, map[string]any{"ok": true, "agents": bindings}) + } + if len(bindings) == 0 { + _, _ = fmt.Fprintln(stdout, "[oneagent] no Agent has been configured yet") + return 0 } - for _, item := range items { - _, _ = fmt.Fprintf(stdout, "%s\t%s\n", item.ID, item.Name) + for _, agentID := range sortedBindingIDs(bindings) { + binding := bindings[agentID] + _, _ = fmt.Fprintf(stdout, "%-14s %-10s %s\n", agentID, binding.Provider, binding.Model) } return 0 + case "set": + if len(args) < 2 || strings.TrimSpace(args[1]) == "" { + return writeError(stdout, stderr, oneerrors.New(oneerrors.InvalidRequest, "agent_id is required"), false, "") + } + flags := flag.NewFlagSet("agent set", flag.ContinueOnError) + flags.SetOutput(stderr) + providerID := flags.String("provider", "ppio", "Provider ID") + baseURL := flags.String("api-base-url", "", "custom Provider base URL") + apiKey := flags.String("api-key", "", "API key") + model := flags.String("model", "", "model ID") + profileID := flags.String("profile", "", "reuse a saved profile key") + smallFast := flags.String("small-fast-model", "", "Claude Code fast model") + jsonOutput := flags.Bool("json", false, "write JSON") + home := flags.String("home", "", "override the home directory") + if err := flags.Parse(args[2:]); err != nil { + return oneerrors.ExitCodes[oneerrors.InvalidRequest] + } + key := *apiKey + if key == "" { + key = os.Getenv("ONEAGENT_API_KEY") + } + core := newCLIUseCases(*home) + result, err := core.ActivateAgent(flagsContext(), app.ActivateAgentOptions{ + AgentID: args[1], Provider: *providerID, APIBaseURL: *baseURL, + APIKey: key, Model: *model, ProfileID: *profileID, SmallFastModel: *smallFast, + }) + if err != nil { + return writeError(stdout, stderr, err, *jsonOutput, key) + } + if *jsonOutput { + payload := map[string]any{ + "ok": true, "agent": result.AgentID, "config": result.Config, + "provider": result.Provider, "model": result.Model, "binding": result.Binding, + "restart": result.Restart, "next": result.Next, + } + return writeJSON(stdout, payload) + } + _, _ = fmt.Fprintf(stdout, "[oneagent] %s -> %s / %s\n", result.AgentID, result.Provider, result.Model) + _, _ = fmt.Fprintln(stdout, "[oneagent] "+result.Restart) + _, _ = fmt.Fprintln(stdout, "[oneagent] next: "+result.Next) + return 0 + default: + return writeError(stdout, stderr, oneerrors.New(oneerrors.InvalidRequest, "Unknown agent command"), false, "") } - return writeError(stdout, oneerrors.New(oneerrors.InvalidRequest, "Unknown agent command"), false) } -func runCompatibilityFlags(args []string, stdout, stderr io.Writer) int { +type installCLIFlags struct { + Agent string + Provider string + APIBaseURL string + APIKey string + Model string + SmallFastModel string + RegisterURL string + Channel string + InstallAgent bool + CheckOnly bool + SkipTest bool + NoOpen bool + JSON bool + Locked bool + Latest bool + Registry string + Home string + Timeout int +} + +func runInstall(args []string, stdout, stderr io.Writer) int { flags := flag.NewFlagSet("oneagent", flag.ContinueOnError) flags.SetOutput(stderr) - agentID := flags.String("agent", "", "Agent ID") - checkOnly := flags.Bool("check-agent-only", false, "only inspect the Agent") - jsonOutput := flags.Bool("json", false, "write JSON") - home := flags.String("home", "", "override the home directory") + options := installCLIFlags{} + flags.StringVar(&options.Agent, "agent", "codex", "Agent ID; comma-separated for several") + flags.StringVar(&options.Provider, "provider", "ppio", "Provider ID") + flags.StringVar(&options.APIBaseURL, "api-base-url", "", "custom Provider base URL") + flags.StringVar(&options.APIKey, "api-key", "", "API key") + flags.StringVar(&options.Model, "model", "", "model ID") + flags.StringVar(&options.SmallFastModel, "small-fast-model", "", "Claude Code fast model") + flags.StringVar(&options.RegisterURL, "register-url", "", "registration URL") + flags.StringVar(&options.Channel, "channel", "direct", "launch channel") + flags.BoolVar(&options.InstallAgent, "install-agent", false, "install missing Agent packages") + flags.BoolVar(&options.CheckOnly, "check-agent-only", false, "only inspect Agents") + flags.BoolVar(&options.SkipTest, "skip-test", false, "skip Provider probes") + flags.BoolVar(&options.NoOpen, "no-open", false, "do not open registration URL") + flags.BoolVar(&options.JSON, "json", false, "write JSON") + flags.BoolVar(&options.Locked, "locked-version", false, "enforce locked versions") + flags.BoolVar(&options.Latest, "latest", false, "install latest version") + flags.StringVar(&options.Registry, "registry", "", "package registry mirror or HTTPS URL") + flags.StringVar(&options.Home, "home", "", "override the home directory") + flags.IntVar(&options.Timeout, "timeout", 180, "operation timeout in seconds") if err := flags.Parse(args); err != nil { return oneerrors.ExitCodes[oneerrors.InvalidRequest] } - if !*checkOnly { - return writeError(stdout, oneerrors.New(oneerrors.InvalidRequest, "The migration CLI currently supports status and check-agent-only only"), *jsonOutput) + if options.Locked && options.Latest { + return writeError(stdout, stderr, oneerrors.New(oneerrors.InvalidRequest, "--locked-version and --latest cannot be used together"), options.JSON, "") } - manifest, err := catalog.LoadEmbedded() + key, err := resolveCLIKey(options, stderr) if err != nil { - return writeError(stdout, err, *jsonOutput) + return writeError(stdout, stderr, err, options.JSON, key) } - if *agentID == "" { - return writeError(stdout, oneerrors.New(oneerrors.InvalidRequest, "--agent is required with --check-agent-only"), *jsonOutput) + agents := splitAgents(options.Agent) + if len(agents) == 0 { + return writeError(stdout, stderr, oneerrors.New(oneerrors.InvalidRequest, "At least one Agent is required"), options.JSON, key) } - agent, ok := manifest.Agents[*agentID] - if !ok { - return writeError(stdout, oneerrors.New(oneerrors.InvalidRequest, "Unknown Agent: "+*agentID), *jsonOutput) + if options.Timeout <= 0 { + return writeError(stdout, stderr, oneerrors.New(oneerrors.InvalidRequest, "timeout must be greater than zero"), options.JSON, key) } - _, installed := app.NewUseCases(app.StatusOptions{Home: *home, Platform: platform.Current()}).LookupForCLI(agent.Command) - payload := map[string]any{ - "ok": true, - "agent": *agentID, - "installed": installed, - "guideOnly": agent.ConfigMode == "guide", + core := newCLIUseCases(options.Home) + result, err := core.InstallAgents(flagsContext(), app.InstallAgentsOptions{ + Agents: agents, Provider: options.Provider, APIBaseURL: options.APIBaseURL, + APIKey: key, Model: options.Model, SmallFastModel: options.SmallFastModel, + Configure: !options.CheckOnly, InstallAgent: options.InstallAgent, + CheckAgentOnly: options.CheckOnly, SkipTest: options.SkipTest, + LockedVersion: options.Locked, Latest: options.Latest, + Timeout: time.Duration(options.Timeout) * time.Second, Registry: options.Registry, + }) + if err != nil { + return writeError(stdout, stderr, err, options.JSON, key) } - if *jsonOutput { - return writeJSON(stdout, payload) + if options.JSON { + return writeJSON(stdout, result) } - _, _ = fmt.Fprintf(stdout, "%s: %t\n", agent.Name, installed) - return 0 + if result.Log != "" { + _, _ = fmt.Fprintln(stdout, result.Log) + } + for _, line := range strings.Split(result.Next, "\n") { + if strings.TrimSpace(line) != "" { + _, _ = fmt.Fprintln(stdout, "[oneagent] next: "+line) + } + } + if result.OK { + return 0 + } + return result.Code +} + +func splitAgents(raw string) []string { + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if value := strings.TrimSpace(part); value != "" { + result = append(result, value) + } + } + return result +} + +func newCLIUseCases(home string) *app.UseCases { + info := platform.Current() + current := process.Current() + return app.NewUseCases(app.StatusOptions{ + Home: home, Platform: info, Runner: current, Environment: current.Env, + }) +} + +func resolveCLIKey(options installCLIFlags, stderr io.Writer) (string, error) { + if options.CheckOnly { + return "", nil + } + if value := os.Getenv("ONEAGENT_API_KEY"); value != "" { + return value, nil + } + if options.APIKey != "" { + return options.APIKey, nil + } + if !stdinIsTerminal() { + return "", oneerrors.New(oneerrors.InvalidRequest, "API key is required; set ONEAGENT_API_KEY or pass --api-key (pasting interactively needs a TTY)") + } + registration := options.RegisterURL + if registration == "" { + if home, ok := catalog.ProviderByID(options.Provider); ok { + registration = home.Home + } else { + registration, _ = provider.ProviderHome("ppio") + } + } + if !options.NoOpen { + _ = openRegistrationURL(registration) + } + _, _ = fmt.Fprintln(stderr, "Create or copy an API key from: "+registration) + _, _ = fmt.Fprint(stderr, "Paste API key: ") + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && len(line) == 0 { + return "", oneerrors.New(oneerrors.InvalidRequest, "API key is required") + } + return strings.TrimSpace(line), nil +} + +func stdinIsTerminal() bool { + info, err := os.Stdin.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} + +func openRegistrationURL(value string) error { + parsed, err := provider.ValidateBaseURL(value) + if err != nil { + // Provider home URLs end in a path and satisfy the same URL safety rules; + // preserve the stable request error if a caller supplied an unsafe value. + return err + } + var command string + var args []string + switch runtime.GOOS { + case "darwin": + command, args = "open", []string{parsed} + case "windows": + command, args = "rundll32", []string{"url.dll,FileProtocolHandler", parsed} + default: + command, args = "xdg-open", []string{parsed} + } + return exec.Command(command, args...).Start() } func writeValue(stdout io.Writer, value any, jsonOutput bool) int { @@ -141,22 +322,36 @@ func writeJSON(stdout io.Writer, value any) int { return 0 } -func writeError(stdout io.Writer, err error, jsonOutput bool) int { +func writeError(stdout, stderr io.Writer, err error, jsonOutput bool, secret string) int { oneErr := oneerrors.As(err) if jsonOutput { - _ = writeJSON(stdout, oneErr.APIShape()) - } else { - _, _ = fmt.Fprintln(stdout, oneErr.Message) + if code := writeJSON(stdout, oneErr.APIShape()); code != 0 { + return code + } + return oneErr.ExitCode + } + message := oneErr.Message + if secret != "" { + message = strings.ReplaceAll(message, secret, "[redacted]") } + _, _ = fmt.Fprintln(stderr, "[oneagent] error: "+message) return oneErr.ExitCode } func printUsage(stdout io.Writer) { _, _ = fmt.Fprintln(stdout, "OneAgent Go migration CLI") - _, _ = fmt.Fprintln(stdout, "Usage: oneagent status [--json] | oneagent agent list [--json]") - _, _ = fmt.Fprintln(stdout, " oneagent --agent --check-agent-only [--json]") + _, _ = fmt.Fprintln(stdout, "Usage: oneagent [flags] | oneagent status [--json] | oneagent agent list|set") } -// flagsContext is kept as a function so future CLI cancellation wiring can be -// added without changing command handlers. +// flagsContext is kept as a function so future CLI signal cancellation wiring +// can be added without changing command handlers. func flagsContext() context.Context { return context.Background() } + +func sortedBindingIDs(bindings map[string]profileStore.AgentBinding) []string { + ids := make([]string, 0, len(bindings)) + for id := range bindings { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} diff --git a/cmd/oneagent/main_test.go b/cmd/oneagent/main_test.go new file mode 100644 index 00000000..0cd45255 --- /dev/null +++ b/cmd/oneagent/main_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFlatInstallCLIEmitsStructuredGuideResult(t *testing.T) { + home := t.TempDir() + var stdout, stderr bytes.Buffer + code := run([]string{"--agent", "openclaw", "--check-agent-only", "--json", "--home", home}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit=%d stderr=%s", code, stderr.String()) + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["ok"] != true { + t.Fatalf("payload=%v", payload) + } + results, ok := payload["results"].([]any) + if !ok || len(results) != 1 || results[0].(map[string]any)["status"] != "guide-only" { + t.Fatalf("results=%v", payload["results"]) + } +} + +func TestAgentSetAndListCLIUseGoBindingsWithoutLeakingKey(t *testing.T) { + home := t.TempDir() + var stdout, stderr bytes.Buffer + code := run([]string{"agent", "set", "codex", "--provider", "ppio", "--model", "model-a", "--api-key", "cli-secret", "--json", "--home", home}, &stdout, &stderr) + if code != 0 { + t.Fatalf("set exit=%d stderr=%s", code, stderr.String()) + } + if strings.Contains(stdout.String(), "cli-secret") { + t.Fatal("API key appeared in agent set output") + } + var setPayload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &setPayload); err != nil || setPayload["provider"] != "ppio" { + t.Fatalf("set payload=%s err=%v", stdout.String(), err) + } + if _, err := os.Stat(filepath.Join(home, ".oneagent", "agents", "codex.json")); err != nil { + t.Fatal(err) + } + + stdout.Reset() + stderr.Reset() + code = run([]string{"agent", "list", "--json", "--home", home}, &stdout, &stderr) + if code != 0 { + t.Fatalf("list exit=%d stderr=%s", code, stderr.String()) + } + var listPayload struct { + OK bool `json:"ok"` + Agents map[string]map[string]any `json:"agents"` + } + if err := json.Unmarshal(stdout.Bytes(), &listPayload); err != nil || !listPayload.OK { + t.Fatalf("list payload=%s err=%v", stdout.String(), err) + } + if listPayload.Agents["codex"]["model"] != "model-a" { + t.Fatalf("agents=%v", listPayload.Agents) + } +} + +func TestFlatCLIRejectsEmptyAgentListAndConflictingVersionModes(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := run([]string{"--agent", ",", "--check-agent-only"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "At least one Agent") { + t.Fatalf("empty agents exit=%d stderr=%q", code, stderr.String()) + } + stdout.Reset() + stderr.Reset() + if code := run([]string{"--check-agent-only", "--latest", "--locked-version", "--json"}, &stdout, &stderr); code != 2 { + t.Fatalf("conflicting modes exit=%d output=%q", code, stdout.String()) + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil || payload["error_code"] != "INVALID_REQUEST" { + t.Fatalf("error payload=%q err=%v", stdout.String(), err) + } +} diff --git a/internal/app/agent.go b/internal/app/agent.go index c1c102f7..6d432ae7 100644 --- a/internal/app/agent.go +++ b/internal/app/agent.go @@ -30,13 +30,13 @@ type ActivateAgentOptions struct { // ActivateAgentResult contains only the public outcome needed by the UI and // CLI. The binding itself is persisted separately and is not repeated here. type ActivateAgentResult struct { - AgentID string - Config string - Provider string - Model string - Restart string - Next string - Binding profileStore.AgentBinding + AgentID string `json:"agent"` + Config string `json:"config"` + Provider string `json:"provider"` + Model string `json:"model"` + Restart string `json:"restart"` + Next string `json:"next"` + Binding profileStore.AgentBinding `json:"binding"` } var managedAgentIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) diff --git a/internal/app/status.go b/internal/app/status.go index 80778d9f..af05f9d5 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -329,6 +329,21 @@ func (u *UseCases) ListProfiles(ctx context.Context) ([]ProfileSummary, error) { return u.profileSummaries(), nil } +// ListAgentBindings returns the public per-Agent routing records used by the +// CLI's `agent list` command. Binding files contain no credential material. +func (u *UseCases) ListAgentBindings(ctx context.Context) (map[string]profileStore.AgentBinding, error) { + if u == nil { + return nil, oneerrors.New(oneerrors.InternalError, "Agent service is not configured", oneerrors.WithStatus(501)) + } + if ctx == nil { + ctx = context.Background() + } + if err := contextError(ctx, "Agent listing request was cancelled"); err != nil { + return nil, err + } + return u.profiles.ListAgentBindings(), nil +} + type SaveProfileOptions struct { ID string Label string diff --git a/internal/profile/binding.go b/internal/profile/binding.go index df166918..c60d7702 100644 --- a/internal/profile/binding.go +++ b/internal/profile/binding.go @@ -14,14 +14,14 @@ import ( ) type AgentBinding struct { - SchemaVersion int - AgentID string - Provider string - BaseURL string - Model string - ProfileRef string - CreatedAt string - UpdatedAt string + SchemaVersion int `json:"schema_version"` + AgentID string `json:"agent_id"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + ProfileRef string `json:"profile_ref"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } type BindingWriteRequest struct { From 1458fecf10c2674c89f9397c355a7393881a3dae Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:11:59 +0800 Subject: [PATCH 24/63] fix: preserve Python JSON ordering in migrated writes --- internal/config/write.go | 78 +++--- internal/config/write_test.go | 9 + internal/jsonorder/document.go | 376 ++++++++++++++++++++++++++++ internal/jsonorder/document_test.go | 84 +++++++ internal/jsonorder/number.go | 60 +++++ internal/profile/store.go | 10 +- internal/profile/write.go | 2 +- internal/profile/write_test.go | 3 + 8 files changed, 576 insertions(+), 46 deletions(-) create mode 100644 internal/jsonorder/document.go create mode 100644 internal/jsonorder/document_test.go create mode 100644 internal/jsonorder/number.go diff --git a/internal/config/write.go b/internal/config/write.go index 8ff0730f..f36d1522 100644 --- a/internal/config/write.go +++ b/internal/config/write.go @@ -2,7 +2,6 @@ package config import ( "context" - "encoding/json" "fmt" "os" "regexp" @@ -10,6 +9,7 @@ import ( "strings" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/jsonorder" "github.com/MaimoryLab/OneAgent/internal/provider" "github.com/MaimoryLab/OneAgent/internal/securefs" "github.com/pelletier/go-toml/v2" @@ -49,55 +49,49 @@ func (w Writer) WriteCodex(ctx context.Context, path, providerName, baseURL, mod } func (w Writer) WriteClaude(ctx context.Context, path, baseURL, apiKey, model, smallFastModel string) error { - data, err := loadJSON(path) + document, err := loadJSON(path) if err != nil { return err } - env, ok := data["env"] - if env == nil { - env = map[string]any{} - data["env"] = env - } - envMap, ok := env.(map[string]any) - if !ok { + env, err := document.Child("env") + if err != nil { return configError("Existing Claude Code env configuration must contain an object: %s", path) } - envMap["ANTHROPIC_BASE_URL"] = baseURL - envMap["ANTHROPIC_AUTH_TOKEN"] = apiKey - envMap["ANTHROPIC_MODEL"] = model + env.Set("ANTHROPIC_BASE_URL", baseURL) + env.Set("ANTHROPIC_AUTH_TOKEN", apiKey) + env.Set("ANTHROPIC_MODEL", model) if smallFastModel == "" { smallFastModel = model } - envMap["ANTHROPIC_SMALL_FAST_MODEL"] = smallFastModel - return w.writeJSON(ctx, path, data, true) + env.Set("ANTHROPIC_SMALL_FAST_MODEL", smallFastModel) + return w.writeJSON(ctx, path, document, true) } func (w Writer) WriteOpenAICompatible(ctx context.Context, path, schemaURL, providerName, baseURL, model, agentID string) error { - data, err := loadJSON(path) + document, err := loadJSON(path) if err != nil { return err } - data["$schema"] = schemaURL - providersValue := data["provider"] - if providersValue == nil { - providersValue = map[string]any{} - data["provider"] = providersValue - } - providers, ok := providersValue.(map[string]any) - if !ok { + document.Set("$schema", schemaURL) + providers, err := document.Child("provider") + if err != nil { return configError("Existing provider configuration must contain an object: %s", path) } - providers["oneagent"] = map[string]any{ - "npm": "@ai-sdk/openai-compatible", - "name": providerName, - "options": map[string]any{ - "baseURL": provider.OpenAIBaseURL(baseURL), - "apiKey": "{env:" + agentEnvVar(agentID) + "}", - }, - "models": map[string]any{model: map[string]any{"name": model}}, - } - data["model"] = "oneagent/" + model - return w.writeJSON(ctx, path, data, false) + options := jsonorder.NewObject() + options.Set("baseURL", provider.OpenAIBaseURL(baseURL)) + options.Set("apiKey", "{env:"+agentEnvVar(agentID)+"}") + models := jsonorder.NewObject() + modelEntry := jsonorder.NewObject() + modelEntry.Set("name", model) + models.Set(model, modelEntry) + oneagent := jsonorder.NewObject() + oneagent.Set("npm", "@ai-sdk/openai-compatible") + oneagent.Set("name", providerName) + oneagent.Set("options", options) + oneagent.Set("models", models) + providers.Set("oneagent", oneagent) + document.Set("model", "oneagent/"+model) + return w.writeJSON(ctx, path, document, false) } func (w Writer) WriteAider(ctx context.Context, path, baseURL, apiKey string) error { @@ -120,12 +114,11 @@ func (w Writer) write(ctx context.Context, path string, data []byte, secret bool return nil } -func (w Writer) writeJSON(ctx context.Context, path string, value map[string]any, secret bool) error { - data, err := json.MarshalIndent(value, "", " ") +func (w Writer) writeJSON(ctx context.Context, path string, value *jsonorder.Object, secret bool) error { + data, err := jsonorder.Marshal(value) if err != nil { return configError("Cannot encode JSON configuration %s: %v", path, err) } - data = append(data, '\n') return w.write(ctx, path, data, secret) } @@ -140,24 +133,21 @@ func readText(path string) (string, error) { return string(data), nil } -func loadJSON(path string) (map[string]any, error) { +func loadJSON(path string) (*jsonorder.Object, error) { text, err := readText(path) if err != nil { return nil, configError("Cannot read existing JSON configuration %s: %v", path, err) } if strings.TrimSpace(text) == "" { - return map[string]any{}, nil + return jsonorder.NewObject(), nil } - var value map[string]any - if err := json.Unmarshal([]byte(text), &value); err != nil { + value, err := jsonorder.Parse([]byte(text)) + if err != nil { if strings.HasSuffix(path, ".jsonc") && jsoncCommentPattern.MatchString(text) { return nil, configError("%s contains JSONC comments, which OneAgent cannot preserve when it rewrites the file", path) } return nil, configError("Existing JSON configuration is invalid: %s: %v", path, err) } - if value == nil { - return nil, configError("Existing JSON configuration must contain an object: %s", path) - } return value, nil } diff --git a/internal/config/write_test.go b/internal/config/write_test.go index e1d6d715..39c966ef 100644 --- a/internal/config/write_test.go +++ b/internal/config/write_test.go @@ -74,10 +74,16 @@ func TestWriteJSONAdaptersPreserveFieldsAndRejectJSONC(t *testing.T) { if err := json.Unmarshal(data, &claude); err != nil || claude["keep"] != true { t.Fatalf("Claude config = %s, %v", data, err) } + if got := string(data); !strings.Contains(got, "\"keep\": true,\n \"env\":") { + t.Fatalf("Claude top-level key order changed: %s", data) + } env := claude["env"].(map[string]any) if env["ANTHROPIC_SMALL_FAST_MODEL"] != "model-a" || env["CUSTOM"] != "value" { t.Fatalf("Claude env = %#v", env) } + if got := string(data); !strings.Contains(got, "\"CUSTOM\": \"value\",\n \"ANTHROPIC_BASE_URL\":") { + t.Fatalf("Claude nested key order changed: %s", data) + } if !strings.Contains(string(data), "sk-claude-secret") { t.Fatal("Claude config did not contain its required native credential") } @@ -97,6 +103,9 @@ func TestWriteJSONAdaptersPreserveFieldsAndRejectJSONC(t *testing.T) { if err := json.Unmarshal(data, &open); err != nil || open["keep"] != true || open["model"] != "oneagent/model-a" { t.Fatalf("OpenCode config = %s, %v", data, err) } + if got := string(data); !strings.Contains(got, "\"keep\": true,\n \"provider\":") || !strings.Contains(got, "\"provider\":") { + t.Fatalf("OpenCode top-level key order changed: %s", data) + } providers := open["provider"].(map[string]any) if _, ok := providers["other"]; !ok { t.Fatalf("unmanaged provider removed: %#v", providers) diff --git a/internal/jsonorder/document.go b/internal/jsonorder/document.go new file mode 100644 index 00000000..28f1c8d1 --- /dev/null +++ b/internal/jsonorder/document.go @@ -0,0 +1,376 @@ +// Package jsonorder edits JSON objects without reordering them. +// +// encoding/json sorts map keys when it writes them, while Python dictionaries +// retain insertion order. Config adapters use this package so rewriting a +// user's file changes only the fields OneAgent manages. +package jsonorder + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "sort" + "strings" +) + +// Object is a JSON object that remembers the order its keys arrived in. +// Existing keys are updated in place; new keys are appended. +type Object struct { + keys []string + values map[string]any +} + +// NewObject builds an empty object. +func NewObject() *Object { + return &Object{values: map[string]any{}} +} + +// Len reports the number of keys in the object. +func (o *Object) Len() int { + if o == nil { + return 0 + } + return len(o.keys) +} + +// Keys returns keys in their current order. +func (o *Object) Keys() []string { + if o == nil { + return nil + } + return append([]string(nil), o.keys...) +} + +// Get returns a value and whether the key is present. +func (o *Object) Get(key string) (any, bool) { + if o == nil { + return nil, false + } + value, present := o.values[key] + return value, present +} + +// Set updates an existing key in place, or appends a new key. +func (o *Object) Set(key string, value any) { + if o.values == nil { + o.values = map[string]any{} + } + if _, present := o.values[key]; !present { + o.keys = append(o.keys, key) + } + o.values[key] = value +} + +// Delete removes a key and preserves the order of the remaining keys. +func (o *Object) Delete(key string) { + if o == nil { + return + } + if _, present := o.values[key]; !present { + return + } + delete(o.values, key) + for index, existing := range o.keys { + if existing == key { + o.keys = append(o.keys[:index], o.keys[index+1:]...) + return + } + } +} + +// Child returns the nested object at key, creating it when absent. It refuses +// to replace a non-object value because doing so would discard user data. +func (o *Object) Child(key string) (*Object, error) { + existing, present := o.Get(key) + if !present || existing == nil { + child := NewObject() + o.Set(key, child) + return child, nil + } + child, ok := existing.(*Object) + if !ok { + return nil, fmt.Errorf("%q is %s, not an object", key, describe(existing)) + } + return child, nil +} + +// GetString returns a string value, or an empty string for another type. +func (o *Object) GetString(key string) string { + value, present := o.Get(key) + if !present { + return "" + } + text, ok := value.(string) + if !ok { + return "" + } + return text +} + +// GetObject returns a nested object without creating one. +func (o *Object) GetObject(key string) (*Object, bool) { + value, present := o.Get(key) + if !present { + return nil, false + } + child, ok := value.(*Object) + return child, ok +} + +// MarshalJSON lets an Object nested in an ordinary struct retain its contents. +func (o *Object) MarshalJSON() ([]byte, error) { + if o == nil { + return []byte("null"), nil + } + var buffer bytes.Buffer + if err := writeValue(&buffer, o, 0); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +// UnmarshalJSON reads an object and records key order. +func (o *Object) UnmarshalJSON(raw []byte) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, ok := token.(json.Delim) + if !ok || delimiter != '{' { + return fmt.Errorf("expected a JSON object") + } + o.keys = nil + o.values = map[string]any{} + return o.decodeInto(decoder) +} + +func (o *Object) decodeInto(decoder *json.Decoder) error { + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return err + } + key, ok := token.(string) + if !ok { + return fmt.Errorf("expected an object key") + } + value, err := decodeValue(decoder) + if err != nil { + return err + } + o.Set(key, value) + } + _, err := decoder.Token() + return err +} + +func decodeValue(decoder *json.Decoder) (any, error) { + token, err := decoder.Token() + if err != nil { + return nil, err + } + if delimiter, ok := token.(json.Delim); ok { + switch delimiter { + case '{': + child := NewObject() + if err := child.decodeInto(decoder); err != nil { + return nil, err + } + return child, nil + case '[': + items := []any{} + for decoder.More() { + item, err := decodeValue(decoder) + if err != nil { + return nil, err + } + items = append(items, item) + } + if _, err := decoder.Token(); err != nil { + return nil, err + } + return items, nil + default: + return nil, fmt.Errorf("unexpected %v", delimiter) + } + } + return token, nil +} + +// Parse reads a JSON object, preserving key order. +func Parse(raw []byte) (*Object, error) { + object := NewObject() + if err := json.Unmarshal(raw, object); err != nil { + return nil, err + } + return object, nil +} + +// Marshal renders an object with two-space indentation and one trailing +// newline, matching json.dumps(..., ensure_ascii=False, indent=2) plus the +// newline used by OneAgent's config files. +func Marshal(object *Object) ([]byte, error) { + var buffer bytes.Buffer + if err := writeValue(&buffer, object, 0); err != nil { + return nil, err + } + buffer.WriteByte('\n') + return buffer.Bytes(), nil +} + +const indentStep = " " + +func writeValue(out io.Writer, value any, depth int) error { + switch typed := value.(type) { + case *Object: + return writeObject(out, typed, depth) + case []any: + return writeArray(out, typed, depth) + case nil: + _, err := io.WriteString(out, "null") + return err + case string: + return writeString(out, typed) + case bool: + if typed { + _, err := io.WriteString(out, "true") + return err + } + _, err := io.WriteString(out, "false") + return err + case json.Number: + _, err := io.WriteString(out, renderNumber(typed)) + return err + default: + encoded, err := json.Marshal(typed) + if err != nil { + return err + } + _, err = out.Write(encoded) + return err + } +} + +func writeObject(out io.Writer, object *Object, depth int) error { + if object == nil || len(object.keys) == 0 { + _, err := io.WriteString(out, "{}") + return err + } + if _, err := io.WriteString(out, "{\n"); err != nil { + return err + } + inner := strings.Repeat(indentStep, depth+1) + for index, key := range object.keys { + if _, err := io.WriteString(out, inner); err != nil { + return err + } + if err := writeString(out, key); err != nil { + return err + } + if _, err := io.WriteString(out, ": "); err != nil { + return err + } + if err := writeValue(out, object.values[key], depth+1); err != nil { + return err + } + if index < len(object.keys)-1 { + if _, err := io.WriteString(out, ","); err != nil { + return err + } + } + if _, err := io.WriteString(out, "\n"); err != nil { + return err + } + } + _, err := io.WriteString(out, strings.Repeat(indentStep, depth)+"}") + return err +} + +func writeArray(out io.Writer, items []any, depth int) error { + if len(items) == 0 { + _, err := io.WriteString(out, "[]") + return err + } + if _, err := io.WriteString(out, "[\n"); err != nil { + return err + } + inner := strings.Repeat(indentStep, depth+1) + for index, item := range items { + if _, err := io.WriteString(out, inner); err != nil { + return err + } + if err := writeValue(out, item, depth+1); err != nil { + return err + } + if index < len(items)-1 { + if _, err := io.WriteString(out, ","); err != nil { + return err + } + } + if _, err := io.WriteString(out, "\n"); err != nil { + return err + } + } + _, err := io.WriteString(out, strings.Repeat(indentStep, depth)+"]") + return err +} + +func writeString(out io.Writer, value string) error { + var builder strings.Builder + builder.WriteByte('"') + for _, character := range value { + switch character { + case '"': + builder.WriteString(`\"`) + case '\\': + builder.WriteString(`\\`) + case '\n': + builder.WriteString(`\n`) + case '\r': + builder.WriteString(`\r`) + case '\t': + builder.WriteString(`\t`) + case '\b': + builder.WriteString(`\b`) + case '\f': + builder.WriteString(`\f`) + default: + if character < 0x20 { + builder.WriteString(fmt.Sprintf(`\u%04x`, character)) + continue + } + builder.WriteRune(character) + } + } + builder.WriteByte('"') + _, err := io.WriteString(out, builder.String()) + return err +} + +// SortedKeys returns a sorted copy for diagnostics without changing output +// order. +func SortedKeys(object *Object) []string { + keys := object.Keys() + sort.Strings(keys) + return keys +} + +func describe(value any) string { + switch value.(type) { + case string: + return "a string" + case bool: + return "a boolean" + case json.Number: + return "a number" + case []any: + return "an array" + case nil: + return "null" + default: + return fmt.Sprintf("%T", value) + } +} diff --git a/internal/jsonorder/document_test.go b/internal/jsonorder/document_test.go new file mode 100644 index 00000000..67256c25 --- /dev/null +++ b/internal/jsonorder/document_test.go @@ -0,0 +1,84 @@ +package jsonorder + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestRoundTripPreservesOrderAndPythonStringEncoding(t *testing.T) { + input := `{"zeta":1,"alpha":"通义-max","nested":{"b":true,"a":null},"items":[{"x":1},2]}` + object, err := Parse([]byte(input)) + if err != nil { + t.Fatal(err) + } + got, err := Marshal(object) + if err != nil { + t.Fatal(err) + } + want := "{\n \"zeta\": 1,\n \"alpha\": \"通义-max\",\n \"nested\": {\n \"b\": true,\n \"a\": null\n },\n \"items\": [\n {\n \"x\": 1\n },\n 2\n ]\n}\n" + if string(got) != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} + +func TestExistingKeysUpdateInPlaceAndNewKeysAppend(t *testing.T) { + object, err := Parse([]byte(`{"first":1,"target":"old","last":3}`)) + if err != nil { + t.Fatal(err) + } + object.Set("target", "new") + object.Set("added", 4) + if got := strings.Join(object.Keys(), ","); got != "first,target,last,added" { + t.Fatalf("keys = %s", got) + } +} + +func TestChildRejectsWrongTypeAndCreatesMissingObject(t *testing.T) { + object, err := Parse([]byte(`{"env":"wrong"}`)) + if err != nil { + t.Fatal(err) + } + if _, err := object.Child("env"); err == nil { + t.Fatal("expected wrong-type error") + } + fresh := NewObject() + child, err := fresh.Child("env") + if err != nil { + t.Fatal(err) + } + child.Set("KEY", "value") + if got, ok := fresh.GetObject("env"); !ok || got.GetString("KEY") != "value" { + t.Fatalf("child was not attached: %#v", fresh) + } +} + +func TestMarshalJSONWorksWhenNestedInStruct(t *testing.T) { + object := NewObject() + object.Set("second", "b") + object.Set("first", "a") + value, err := json.Marshal(struct { + Profile *Object `json:"profile"` + }{Profile: object}) + if err != nil { + t.Fatal(err) + } + text := string(value) + if strings.Index(text, "second") > strings.Index(text, "first") { + t.Fatalf("nested object was reordered: %s", text) + } +} + +func TestExponentNumbersMatchPythonStyleRendering(t *testing.T) { + object, err := Parse([]byte(`{"large":1e10,"small":1e-5,"plain":1.5}`)) + if err != nil { + t.Fatal(err) + } + got, err := Marshal(object) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), `"large": 10000000000.0`) || !strings.Contains(string(got), `"small": 1e-05`) { + t.Fatalf("numbers were not rendered like Python: %s", got) + } +} diff --git a/internal/jsonorder/number.go b/internal/jsonorder/number.go new file mode 100644 index 00000000..da100298 --- /dev/null +++ b/internal/jsonorder/number.go @@ -0,0 +1,60 @@ +package jsonorder + +import ( + "encoding/json" + "strconv" + "strings" +) + +// renderNumber matches Python's json.loads/json.dumps round trip. Plain +// integers and decimals retain their text; exponent forms are promoted to a +// float and rendered using Python's fixed/scientific notation rules. +func renderNumber(number json.Number) string { + text := number.String() + if !strings.ContainsAny(text, "eE") { + return text + } + value, err := strconv.ParseFloat(text, 64) + if err != nil { + return text + } + return pythonFloat(value) +} + +func pythonFloat(value float64) string { + if value != value { + return "NaN" + } + if value > maxFloat { + return "Infinity" + } + if value < -maxFloat { + return "-Infinity" + } + if exponent := decimalExponent(value); exponent < -4 || exponent >= 16 { + return strconv.FormatFloat(value, 'e', -1, 64) + } + text := strconv.FormatFloat(value, 'f', -1, 64) + if !strings.Contains(text, ".") { + text += ".0" + } + return text +} + +const maxFloat = 1.7976931348623157e308 + +func decimalExponent(value float64) int { + if value == 0 { + return 0 + } + text := strconv.FormatFloat(value, 'e', -1, 64) + index := strings.IndexByte(text, 'e') + if index < 0 { + return 0 + } + exponent, err := strconv.Atoi(text[index+1:]) + if err != nil { + return 0 + } + return exponent +} diff --git a/internal/profile/store.go b/internal/profile/store.go index 95f61607..b685191f 100644 --- a/internal/profile/store.go +++ b/internal/profile/store.go @@ -72,6 +72,14 @@ type storedProfile struct { ActivatedAt *string `json:"activated_at"` } +// activePointer is deliberately a struct rather than a map. Python writes +// schema_version before active, and encoding/json sorts map keys, which would +// otherwise produce a byte-level difference for every activation. +type activePointer struct { + SchemaVersion int `json:"schema_version"` + Active string `json:"active"` +} + func NewStore(home, osID string) Store { filesystem := securefs.New(securefs.Options{OS: osID}) return Store{Home: home, OS: osID, FS: &filesystem, Now: time.Now, mu: &sync.Mutex{}} @@ -252,7 +260,7 @@ func (s Store) persistLegacy(ctx context.Context, profile Profile) (Profile, err if err := s.writeStored(ctx, stored); err != nil { return Profile{}, err } - pointer := map[string]any{"schema_version": 2, "active": stored.ID} + pointer := activePointer{SchemaVersion: 2, Active: stored.ID} data, err := json.MarshalIndent(pointer, "", " ") if err != nil { return Profile{}, writeError("Cannot encode migrated profile pointer: %v", err) diff --git a/internal/profile/write.go b/internal/profile/write.go index 7409e562..23206bc6 100644 --- a/internal/profile/write.go +++ b/internal/profile/write.go @@ -163,7 +163,7 @@ func (s Store) WriteActive(ctx context.Context, request ActiveRequest) (string, return "", err } } - pointer := map[string]any{"schema_version": 2, "active": profileID} + pointer := activePointer{SchemaVersion: 2, Active: profileID} data, err := json.MarshalIndent(pointer, "", " ") if err != nil { return "", writeError("Cannot encode active profile: %v", err) diff --git a/internal/profile/write_test.go b/internal/profile/write_test.go index 8601daf9..25a741cc 100644 --- a/internal/profile/write_test.go +++ b/internal/profile/write_test.go @@ -144,6 +144,9 @@ func TestWriteActiveMergesAgentsAndSupportsExistingAccount(t *testing.T) { if err := json.Unmarshal(data, &pointer); err != nil || pointer["active"] != "default" || pointer["schema_version"] != float64(2) { t.Fatalf("active pointer = %s, %v", data, err) } + if got := string(data); !strings.HasPrefix(got, "{\n \"schema_version\": 2,\n \"active\": \"default\"\n}") { + t.Fatalf("active pointer key order changed: %s", data) + } } func TestProfileWritesHonorCancellation(t *testing.T) { From db130d9f53a0ef0df4e986f21c09b631c5ed6627 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:14:36 +0800 Subject: [PATCH 25/63] test: add Python JSON writer parity gate --- internal/config/write_parity_test.go | 156 +++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 internal/config/write_parity_test.go diff --git a/internal/config/write_parity_test.go b/internal/config/write_parity_test.go new file mode 100644 index 00000000..65f39207 --- /dev/null +++ b/internal/config/write_parity_test.go @@ -0,0 +1,156 @@ +package config + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/MaimoryLab/OneAgent/internal/securefs" +) + +// This focused parity test compares the actual Python writers with the Go +// writers on the files where map ordering used to produce a byte difference. +// It is skipped when Python is unavailable so clean Go-only builds remain +// possible; CI can set ONEAGENT_REQUIRE_PARITY to make it mandatory. +func TestJSONWriterParityWithPython(t *testing.T) { + python := "" + for _, name := range []string{"python3.12", "python3"} { + if path, err := exec.LookPath(name); err == nil { + python = path + break + } + } + if python == "" { + if os.Getenv("ONEAGENT_REQUIRE_PARITY") != "" { + t.Fatal("Python is required for JSON writer parity") + } + t.Skip("Python is not available") + } + + cases := []struct { + name string + kind string + relative string + existing string + }{ + { + name: "claude preserves nested order", + kind: "claude", + relative: filepath.Join(".claude", "settings.json"), + existing: `{"keep":true,"env":{"CUSTOM":"value","ANTHROPIC_MODEL":"old"},"other":true}`, + }, + { + name: "opencode preserves provider order", + kind: "opencode", + relative: filepath.Join(".config", "opencode", "opencode.jsonc"), + existing: `{"keep":true,"provider":{"other":{"x":1}},"theme":"dark"}`, + }, + { + name: "kilo preserves provider order", + kind: "kilo", + relative: filepath.Join(".config", "kilo", "kilo.jsonc"), + existing: `{"provider":{"other":{"x":1}},"keep":true}`, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + want := pythonJSONWriterOutput(t, python, testCase.kind, testCase.relative, testCase.existing) + got := goJSONWriterOutput(t, testCase.kind, testCase.relative, testCase.existing) + if string(got) != string(want) { + t.Fatalf("writer output differs:\nGo:\n%s\nPython:\n%s", got, want) + } + }) + } +} + +func pythonJSONWriterOutput(t *testing.T, python, kind, relative, existing string) []byte { + t.Helper() + root := repoRootForParity(t) + home := t.TempDir() + script := ` +import sys +from pathlib import Path +sys.path.insert(0, sys.argv[1]) +from oneagent.installer import Runtime, write_claude_config, write_openai_compatible_config + +home = Path(sys.argv[2]) +kind = sys.argv[3] +relative = Path(sys.argv[4]) +existing = sys.argv[5] +path = home / relative +path.parent.mkdir(parents=True, exist_ok=True) +path.write_text(existing, encoding="utf-8") +runtime = Runtime.create(home=home, os_id="linux", env={"HOME": str(home)}) +meta = {"config_path": str(relative)} +if kind == "claude": + write_claude_config(runtime, meta, "https://api.ppio.com/anthropic", "sk-parity", "model-new") +else: + schema = "https://opencode.ai/config.json" if kind == "opencode" else "https://app.kilo.ai/config.json" + agent = "opencode" if kind == "opencode" else "kilo-cli" + write_openai_compatible_config(runtime, meta, "PPIO", "https://api.ppio.com/openai", "model-new", schema, agent) +` + cmd := exec.Command(python, "-c", script, root, home, kind, relative, existing) + cmd.Dir = root + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Python writer failed: %v\n%s", err, output) + } + data, err := os.ReadFile(filepath.Join(home, relative)) + if err != nil { + t.Fatal(err) + } + return data +} + +func goJSONWriterOutput(t *testing.T, kind, relative, existing string) []byte { + t.Helper() + home := t.TempDir() + path := filepath.Join(home, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(existing), 0o600); err != nil { + t.Fatal(err) + } + filesystem := securefs.New(securefs.Options{OS: "linux"}) + writer := NewWriter(home, "linux", filesystem) + var err error + switch kind { + case "claude": + err = writer.WriteClaude(context.Background(), path, "https://api.ppio.com/anthropic", "sk-parity", "model-new", "") + case "opencode": + err = writer.WriteOpenAICompatible(context.Background(), path, "https://opencode.ai/config.json", "PPIO", "https://api.ppio.com/openai", "model-new", "opencode") + case "kilo": + err = writer.WriteOpenAICompatible(context.Background(), path, "https://app.kilo.ai/config.json", "PPIO", "https://api.ppio.com/openai", "model-new", "kilo-cli") + default: + t.Fatalf("unknown parity kind %q", kind) + } + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return data +} + +func repoRootForParity(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "agents.lock.json")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("could not find repository root") + } + dir = parent + } +} From 988e68563e56f4f76bc6b3ce3851c045a4407eee Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:20:22 +0800 Subject: [PATCH 26/63] fix: preserve install result wire fields --- internal/app/install.go | 11 +++++--- internal/app/install_test.go | 31 ++++++++++++++++++++++ internal/app/install_wire.go | 43 +++++++++++++++++++++++++++++++ internal/binding/install_wire.go | 21 +++++++++++++++ internal/binding/services_test.go | 3 +++ 5 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 internal/app/install_wire.go create mode 100644 internal/binding/install_wire.go diff --git a/internal/app/install.go b/internal/app/install.go index 5c9bbc66..3cbf4aca 100644 --- a/internal/app/install.go +++ b/internal/app/install.go @@ -58,6 +58,10 @@ type AgentInstallResult struct { ErrorCode string `json:"error_code,omitempty"` Message string `json:"message,omitempty"` Retryable bool `json:"retryable"` + // checkOnly distinguishes the check path's skipped result from a normal + // configure=false result. Python includes config: "" for the latter but + // does not include a config field in check-agent-only results. + checkOnly bool } // AgentResult is the short compatibility name used by the first Go port. @@ -355,7 +359,7 @@ func (r *installRun) configure(ctx context.Context, agentID string, agent catalo if (agent.Command != "" && present) || installed.Installed { status = "installed" } - r.results = append(r.results, installResultFor(agentID, status, "", installed)) + r.results = append(r.results, installResultFor(agentID, status, "", installed, true)) r.logs = append(r.logs, "## "+agentID+"\nAgent check complete.") return nil } @@ -397,7 +401,7 @@ func (r *installRun) configure(ctx context.Context, agentID string, agent catalo status = "configured" message = "Configured" } - r.results = append(r.results, installResultFor(agentID, status, configPathValue, installed)) + r.results = append(r.results, installResultFor(agentID, status, configPathValue, installed, false)) r.logs = append(r.logs, "## "+agentID+"\n"+message+".") if next := nextStep(r.core.status.Platform.OS, agentID, agent, r.options.Model); next != "" { r.nextSteps = append(r.nextSteps, next) @@ -465,7 +469,7 @@ func (r *installRun) finish(ctx context.Context, baseURL string) InstallAgentsRe } } -func installResultFor(agentID, status, path string, installed install.Result) AgentInstallResult { +func installResultFor(agentID, status, path string, installed install.Result, checkOnly bool) AgentInstallResult { return AgentInstallResult{ Agent: agentID, Status: status, @@ -475,6 +479,7 @@ func installResultFor(agentID, status, path string, installed install.Result) Ag LockedVersion: installed.LockedVersion, Registry: installed.Registry, Retryable: false, + checkOnly: checkOnly, } } diff --git a/internal/app/install_test.go b/internal/app/install_test.go index 94b2e010..c7005c75 100644 --- a/internal/app/install_test.go +++ b/internal/app/install_test.go @@ -2,6 +2,7 @@ package app import ( "context" + "encoding/json" "io" "net/http" "os" @@ -15,6 +16,36 @@ import ( "github.com/MaimoryLab/OneAgent/internal/provider" ) +func TestInstallResultWirePreservesPythonFieldPresence(t *testing.T) { + configured, err := json.Marshal(AgentInstallResult{ + Agent: "codex", Status: "configured", Config: "", Installed: false, LockedVersion: "0.145.0", + }) + if err != nil { + t.Fatal(err) + } + if got, want := string(configured), `{"agent":"codex","status":"configured","config":"","installed":false,"version":null,"lockedVersion":"0.145.0","retryable":false}`; got != want { + t.Fatalf("configured wire = %s, want %s", got, want) + } + + checkOnly, err := json.Marshal(AgentInstallResult{ + Agent: "codex", Status: "skipped", Installed: false, Version: "1.0.0", LockedVersion: "0.145.0", checkOnly: true, + }) + if err != nil { + t.Fatal(err) + } + if got, want := string(checkOnly), `{"agent":"codex","status":"skipped","installed":false,"version":"1.0.0","lockedVersion":"0.145.0","retryable":false}`; got != want { + t.Fatalf("check-only wire = %s, want %s", got, want) + } + + guide, err := json.Marshal(AgentInstallResult{Agent: "gemini-cli", Status: "guide-only", Message: "use login"}) + if err != nil { + t.Fatal(err) + } + if got, want := string(guide), `{"agent":"gemini-cli","status":"guide-only","message":"use login","retryable":false}`; got != want { + t.Fatalf("guide wire = %s, want %s", got, want) + } +} + type installAppRunner struct { paths map[string]string calls [][]string diff --git a/internal/app/install_wire.go b/internal/app/install_wire.go new file mode 100644 index 00000000..2f2160b5 --- /dev/null +++ b/internal/app/install_wire.go @@ -0,0 +1,43 @@ +package app + +import "github.com/MaimoryLab/OneAgent/internal/jsonorder" + +// MarshalJSON preserves the Python install response's field presence and +// insertion order. The distinction matters to clients: a normal automatic +// result reports installed=false and version=null, while guide and failed +// results do not pretend those fields exist. +func (r AgentInstallResult) MarshalJSON() ([]byte, error) { + document := jsonorder.NewObject() + document.Set("agent", r.Agent) + document.Set("status", r.Status) + + automatic := r.Status == "configured" || r.Status == "skipped" || r.Status == "installed" + if automatic { + if !r.checkOnly { + document.Set("config", r.Config) + } + document.Set("installed", r.Installed) + if r.Version == "" { + document.Set("version", nil) + } else { + document.Set("version", r.Version) + } + if r.LockedVersion == "" { + document.Set("lockedVersion", nil) + } else { + document.Set("lockedVersion", r.LockedVersion) + } + if r.Registry != "" { + document.Set("registry", r.Registry) + } + } + if r.Status == "failed" { + document.Set("code", r.Code) + document.Set("error_code", r.ErrorCode) + document.Set("message", r.Message) + } else if r.Status == "guide-only" { + document.Set("message", r.Message) + } + document.Set("retryable", r.Retryable) + return document.MarshalJSON() +} diff --git a/internal/binding/install_wire.go b/internal/binding/install_wire.go new file mode 100644 index 00000000..1e3c5947 --- /dev/null +++ b/internal/binding/install_wire.go @@ -0,0 +1,21 @@ +package binding + +import "github.com/MaimoryLab/OneAgent/internal/app" + +// MarshalJSON delegates the transport DTO's wire shape to the use-case result +// so CLI and Wails callers receive identical field presence and ordering. +func (r AgentInstallResult) MarshalJSON() ([]byte, error) { + return (app.AgentInstallResult{ + Agent: r.Agent, + Status: r.Status, + Installed: r.Installed, + Version: r.Version, + LockedVersion: r.LockedVersion, + Registry: r.Registry, + Config: r.Config, + Code: r.Code, + ErrorCode: r.ErrorCode, + Message: r.Message, + Retryable: r.Retryable, + }).MarshalJSON() +} diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index 689b0bf1..fed1f8ef 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -204,6 +204,9 @@ func TestAgentServiceInstallsThroughGoUseCase(t *testing.T) { if err != nil || strings.Contains(string(wire), "binding-install-secret") || strings.Contains(string(wire), "api_key") { t.Fatalf("install binding response leaked secret material: %s (%v)", wire, err) } + if !strings.Contains(string(wire), `"installed":false`) || !strings.Contains(string(wire), `"version":`) { + t.Fatalf("install binding response lost Python result fields: %s", wire) + } if _, err := os.Stat(filepath.Join(home, ".oneagent", "profile.json")); err != nil { t.Fatalf("Go install did not publish profile: %v", err) } From 129c807203a4247a8315ebc4416b5fa828443b35 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:31:54 +0800 Subject: [PATCH 27/63] feat: add Wails frontend backend adapter --- frontend/src/api/client.ts | 31 +-- frontend/src/backend/errors.ts | 31 +++ frontend/src/backend/wails.test.ts | 96 +++++++ frontend/src/backend/wails.ts | 409 +++++++++++++++++++++++++++++ frontend/src/types/api.ts | 3 + 5 files changed, 542 insertions(+), 28 deletions(-) create mode 100644 frontend/src/backend/errors.ts create mode 100644 frontend/src/backend/wails.test.ts create mode 100644 frontend/src/backend/wails.ts diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 544286a6..18082980 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -7,35 +7,10 @@ import type { ProviderId, StatusResponse, } from "../types/api"; +import { OneAgentApiError } from "../backend/errors"; -export class OneAgentApiError extends Error { - readonly code: string; - readonly retryable: boolean; - readonly status: number; - - constructor(message: string, code: string, retryable: boolean, status: number) { - super(message); - this.name = "OneAgentApiError"; - this.code = code; - this.retryable = retryable; - this.status = status; - } -} - -export interface FailureDetail { - message: string; - code: string; - retryable: boolean; -} - -/** Normalise any thrown value into the API error contract, so callers keep - * the backend's error_code/retryable instead of hard-coding replacements. */ -export function describeError(error: unknown, fallback: string): FailureDetail { - if (error instanceof OneAgentApiError) { - return { message: error.message, code: error.code, retryable: error.retryable }; - } - return { message: error instanceof Error ? error.message : fallback, code: "INTERNAL_ERROR", retryable: true }; -} +export { OneAgentApiError, describeError } from "../backend/errors"; +export type { FailureDetail } from "../backend/errors"; async function request(path: string, init?: RequestInit): Promise { let response: Response; diff --git a/frontend/src/backend/errors.ts b/frontend/src/backend/errors.ts new file mode 100644 index 00000000..aadc13e9 --- /dev/null +++ b/frontend/src/backend/errors.ts @@ -0,0 +1,31 @@ +export class OneAgentApiError extends Error { + readonly code: string; + readonly retryable: boolean; + readonly status: number; + + constructor(message: string, code: string, retryable: boolean, status: number) { + super(message); + this.name = "OneAgentApiError"; + this.code = code; + this.retryable = retryable; + this.status = status; + } +} + +export interface FailureDetail { + message: string; + code: string; + retryable: boolean; +} + +/** Normalize any thrown value into the stable frontend error contract. */ +export function describeError(error: unknown, fallback: string): FailureDetail { + if (error instanceof OneAgentApiError) { + return { message: error.message, code: error.code, retryable: error.retryable }; + } + return { + message: error instanceof Error ? error.message : fallback, + code: "INTERNAL_ERROR", + retryable: true, + }; +} diff --git a/frontend/src/backend/wails.test.ts b/frontend/src/backend/wails.test.ts new file mode 100644 index 00000000..207d5106 --- /dev/null +++ b/frontend/src/backend/wails.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const bridge = vi.hoisted(() => ({ + status: vi.fn(), + probe: vi.fn(), + models: vi.fn(), + install: vi.fn(), + register: vi.fn(), + activate: vi.fn(), + profiles: vi.fn(), +})); + +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.js", () => ({ + GetStatus: bridge.status, +})); +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.js", () => ({ + Probe: bridge.probe, + ListModels: bridge.models, + OpenRegistration: bridge.register, +})); +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.js", () => ({ + Install: bridge.install, + Activate: bridge.activate, +})); +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.js", () => ({ + ListProfiles: bridge.profiles, +})); + +import { normalizeWailsError, wailsApi } from "./wails"; + +describe("Wails backend adapter", () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it("normalizes a generated status payload without exposing bridge-specific nulls", async () => { + bridge.status.mockResolvedValue({ + apiVersion: 1, + platform: { os: "linux", arch: "arm64", shell: "bash" }, + capabilities: { canInstall: { codex: true }, supportedAgentIds: ["codex"] }, + agents: { codex: { installed: true, configured: false, guideOnly: false, config: "", version: null, lockedVersion: "0.1.0", canInstall: true, provider: null, model: null, baseUrl: null, updatedAt: null, detected: null } }, + catalog: [{ id: "codex", name: "Codex", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "0.1.0", protocol: "responses", platforms: ["linux"], platformNote: "", rank: 1 }], + groups: [{ id: "auto", name: "自动配置" }], + providers: { ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai" } }, + mirrors: [], paths: {}, backups: {}, profiles: null, activeProfile: null, environment: null, environmentError: null, + }); + + await expect(wailsApi.status()).resolves.toMatchObject({ + platform: { os: "linux", arch: "arm64" }, + agents: { codex: { installed: true, detected: null } }, + profiles: [], + }); + }); + + it("maps provider requests to generated snake_case bindings", async () => { + bridge.probe.mockResolvedValue({ ok: true, reachable: true, status: 204, message: "ok", error_code: null, retryable: false, protocol: "responses" }); + await expect(wailsApi.probe({ provider: "custom", apiBaseUrl: "https://proxy.test/v1", apiKey: "secret", model: "m", agents: [] })).resolves.toMatchObject({ ok: true, protocol: "responses" }); + expect(bridge.probe).toHaveBeenCalledWith({ + provider: "custom", api_base_url: "https://proxy.test/v1", api_key: "secret", model: "m", agents: null, + }); + }); + + it("fills binding defaults and preserves install null fields", async () => { + bridge.install.mockResolvedValue({ + ok: true, code: 0, + results: [{ agent: "codex", status: "configured", config: "", installed: false, version: null, lockedVersion: "0.145.0", retryable: false }], + log: "", next: "", probe: null, probes: null, + }); + const result = await wailsApi.install({ + agents: ["codex"], provider: "ppio", api_base_url: "", api_key: "secret", model: "m", + configure: true, install_agent: false, skip_test: true, + }); + expect(result.results[0]).toMatchObject({ installed: false, version: null, lockedVersion: "0.145.0" }); + expect(bridge.install).toHaveBeenCalledWith(expect.objectContaining({ + agents: ["codex"], profile_agents: null, profile_id: "", timeout: 180, locked_version: false, latest: false, + })); + }); + + it("maps activation and registration calls", async () => { + bridge.activate.mockResolvedValue({ ok: true, agent: "codex", config: "/c", provider: "ppio", model: "m", restart: "r", next: "n" }); + bridge.register.mockResolvedValue({ ok: true, url: "https://ppio.com/", message: "opened" }); + await wailsApi.activateAgent("codex", { provider: "ppio", apiBaseUrl: "", apiKey: "secret", model: "m" }); + await wailsApi.openRegister("ppio", []); + expect(bridge.activate).toHaveBeenCalledWith(expect.objectContaining({ agent_id: "codex", profile_id: "", small_fast_model: "" })); + expect(bridge.register).toHaveBeenCalledWith({ provider: "ppio", agents: null }); + }); + + it("restores a structured Go error cause and hides unknown bridge failures", () => { + expect(normalizeWailsError({ name: "RuntimeError", cause: { error_code: "API_KEY_REJECTED", message: "key rejected", status: 401, retryable: false } })).toMatchObject({ + message: "key rejected", code: "API_KEY_REJECTED", status: 401, retryable: false, + }); + expect(normalizeWailsError(new Error("internal implementation detail"))).toMatchObject({ + message: "无法调用本机 OneAgent 服务", code: "INTERNAL_ERROR", retryable: true, + }); + }); +}); diff --git a/frontend/src/backend/wails.ts b/frontend/src/backend/wails.ts new file mode 100644 index 00000000..f5c6490d --- /dev/null +++ b/frontend/src/backend/wails.ts @@ -0,0 +1,409 @@ +import * as AgentService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.js"; +import * as ProfileService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.js"; +import * as ProviderService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.js"; +import * as StatusService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.js"; +import { OneAgentApiError } from "./errors"; +import type { + ActivateAgentResponse, + AgentCatalogItem, + AgentInstallResult, + AgentStatus, + DetectedConfig, + EnvironmentProfile, + InstallRequest, + InstallResponse, + ModelsResponse, + PlatformId, + AgentGroupId, + ProbeResponse, + ProfileSummary, + ProviderId, + StatusResponse, +} from "../types/api"; + +export { OneAgentApiError, describeError } from "./errors"; + +type RecordValue = Record; + +function record(value: unknown): RecordValue { + return value !== null && typeof value === "object" ? (value as RecordValue) : {}; +} + +function stringValue(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function nullableString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function optionalNullableString(source: RecordValue, key: string): string | null | undefined { + if (!(key in source)) { + return undefined; + } + return nullableString(source[key]); +} + +function booleanValue(value: unknown, fallback = false): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function numberValue(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; +} + +function stringMap(value: unknown): Record { + const source = record(value); + return Object.fromEntries( + Object.entries(source).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ); +} + +function booleanMap(value: unknown): Record { + const source = record(value); + return Object.fromEntries( + Object.entries(source).filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean"), + ); +} + +function protocolId(value: unknown): ProbeResponse["protocol"] { + return value === "openai" || value === "anthropic" || value === "responses" ? value : undefined; +} + +function platformId(value: unknown): PlatformId { + return value === "macos" || value === "windows" || value === "linux" ? value : "linux"; +} + +function groupId(value: unknown): AgentGroupId { + return value === "auto" || value === "gateway" || value === "platform" || value === "ide" ? value : "auto"; +} + +function normalizeDetected(value: unknown): DetectedConfig | null { + if (value === null || value === undefined) { + return null; + } + const source = record(value); + return { + baseUrl: stringValue(source.baseUrl), + model: stringValue(source.model), + managedByOneAgent: booleanValue(source.managedByOneAgent), + unreadable: nullableString(source.unreadable), + }; +} + +function normalizeAgentStatus(value: unknown): AgentStatus { + const source = record(value); + return { + installed: booleanValue(source.installed), + configured: booleanValue(source.configured), + guideOnly: booleanValue(source.guideOnly), + config: stringValue(source.config), + version: nullableString(source.version), + lockedVersion: nullableString(source.lockedVersion), + canInstall: booleanValue(source.canInstall), + provider: nullableString(source.provider), + model: nullableString(source.model), + baseUrl: nullableString(source.baseUrl), + updatedAt: nullableString(source.updatedAt), + detected: normalizeDetected(source.detected), + }; +} + +function normalizeCatalogItem(value: unknown): AgentCatalogItem { + const source = record(value); + const configMode = source.configMode; + return { + id: stringValue(source.id), + name: stringValue(source.name), + group: groupId(source.group), + configMode: configMode === "guide" ? "guide" : "auto", + guideOnly: booleanValue(source.guideOnly), + lockedVersion: nullableString(source.lockedVersion), + protocol: protocolId(source.protocol) ?? null, + platforms: stringArray(source.platforms) as AgentCatalogItem["platforms"], + platformNote: stringValue(source.platformNote), + rank: numberValue(source.rank), + }; +} + +function normalizeStatus(value: unknown): StatusResponse { + const source = record(value); + const rawAgents = record(source.agents); + const agents: Record = {}; + for (const [id, item] of Object.entries(rawAgents)) { + agents[id] = normalizeAgentStatus(item); + } + const rawProviders = record(source.providers); + const providers: StatusResponse["providers"] = {}; + for (const [id, item] of Object.entries(rawProviders)) { + const provider = record(item); + providers[id] = { + name: stringValue(provider.name), + home: stringValue(provider.home), + base_url: stringValue(provider.base_url), + ...(typeof provider.anthropic_base_url === "string" ? { anthropic_base_url: provider.anthropic_base_url } : {}), + }; + } + const rawMirrors = Array.isArray(source.mirrors) ? source.mirrors : []; + const mirrors = rawMirrors.map((item) => { + const mirror = record(item); + return { + id: stringValue(mirror.id), + name: stringValue(mirror.name), + registry: stringValue(mirror.registry), + upstream: stringValue(mirror.upstream), + note: stringValue(mirror.note), + }; + }); + const rawGroups = Array.isArray(source.groups) ? source.groups : []; + const groups = rawGroups.map((item) => { + const group = record(item); + return { + id: groupId(group.id), + name: stringValue(group.name), + }; + }); + const environment = source.environment && typeof source.environment === "object" + ? (source.environment as EnvironmentProfile) + : null; + return { + apiVersion: numberValue(source.apiVersion, 1), + platform: { + os: platformId(record(source.platform).os), + arch: stringValue(record(source.platform).arch), + shell: stringValue(record(source.platform).shell), + }, + capabilities: { + canInstall: booleanMap(record(source.capabilities).canInstall), + supportedAgentIds: stringArray(record(source.capabilities).supportedAgentIds), + }, + agents, + catalog: (Array.isArray(source.catalog) ? source.catalog : []).map(normalizeCatalogItem), + groups, + providers, + mirrors, + paths: stringMap(source.paths), + backups: booleanMap(source.backups), + environment, + environmentError: nullableString(source.environmentError), + profiles: Array.isArray(source.profiles) ? source.profiles.map(normalizeProfile) : [], + activeProfile: nullableString(source.activeProfile), + }; +} + +function normalizeProfile(value: unknown): ProfileSummary { + const source = record(value); + return { + id: stringValue(source.id), + label: stringValue(source.label, stringValue(source.id)), + provider: stringValue(source.provider), + baseUrl: nullableString(source.baseUrl), + model: nullableString(source.model), + agentIds: stringArray(source.agentIds), + activatedAt: nullableString(source.activatedAt), + hasKey: booleanValue(source.hasKey), + }; +} + +function normalizeProbe(value: unknown): ProbeResponse { + const source = record(value); + const protocols: Partial, ProbeResponse>> = {}; + const rawProtocols = record(source.protocols); + for (const [key, item] of Object.entries(rawProtocols)) { + if (key === "openai" || key === "anthropic" || key === "responses") { + protocols[key] = normalizeProbe(item); + } + } + return { + ok: booleanValue(source.ok), + reachable: booleanValue(source.reachable), + status: numberValue(source.status), + message: stringValue(source.message), + error_code: nullableString(source.error_code), + retryable: booleanValue(source.retryable), + ...(protocolId(source.protocol) ? { protocol: protocolId(source.protocol) } : {}), + ...(Object.keys(protocols).length ? { protocols } : {}), + }; +} + +function normalizeInstallResult(value: unknown): AgentInstallResult { + const source = record(value); + return { + agent: stringValue(source.agent), + status: stringValue(source.status) as AgentInstallResult["status"], + ...(typeof source.installed === "boolean" ? { installed: source.installed } : {}), + ...("version" in source ? { version: optionalNullableString(source, "version") } : {}), + ...("lockedVersion" in source ? { lockedVersion: optionalNullableString(source, "lockedVersion") } : {}), + ...(typeof source.registry === "string" ? { registry: source.registry } : {}), + ...(typeof source.config === "string" ? { config: source.config } : {}), + ...(typeof source.code === "number" ? { code: source.code } : {}), + ...(typeof source.error_code === "string" ? { error_code: source.error_code } : {}), + ...(typeof source.message === "string" ? { message: source.message } : {}), + retryable: booleanValue(source.retryable), + }; +} + +function normalizeInstall(value: unknown): InstallResponse { + const source = record(value); + const rawProbes = record(source.probes); + const probes: Partial, ProbeResponse>> = {}; + for (const [key, item] of Object.entries(rawProbes)) { + if (key === "openai" || key === "anthropic" || key === "responses") { + probes[key] = normalizeProbe(item); + } + } + return { + ok: booleanValue(source.ok), + code: numberValue(source.code), + results: Array.isArray(source.results) ? source.results.map(normalizeInstallResult) : [], + log: stringValue(source.log), + next: stringValue(source.next), + probe: source.probe === null || source.probe === undefined ? null : normalizeProbe(source.probe), + ...(Object.keys(probes).length ? { probes } : {}), + }; +} + +function normalizeModels(value: unknown): ModelsResponse { + const source = record(value); + return { + ...normalizeProbe(source), + models: stringArray(source.models), + }; +} + +function parseCause(value: unknown): RecordValue { + if (typeof value === "string") { + try { + return record(JSON.parse(value)); + } catch { + return {}; + } + } + return record(value); +} + +/** Convert a Wails bridge rejection into the stable frontend error contract. */ +export function normalizeWailsError(error: unknown): OneAgentApiError { + if (error instanceof OneAgentApiError) { + return error; + } + const source = record(error); + const cause = parseCause(source.cause); + const hasCause = Object.keys(cause).length > 0; + const message = hasCause + ? stringValue(cause.message, "OneAgent request failed") + : "无法调用本机 OneAgent 服务"; + const code = hasCause ? stringValue(cause.error_code, "INTERNAL_ERROR") : "INTERNAL_ERROR"; + const status = hasCause ? numberValue(cause.status, 500) : 500; + const retryable = hasCause ? booleanValue(cause.retryable, true) : true; + return new OneAgentApiError(message, code, retryable, status); +} + +async function call(operation: () => PromiseLike): Promise { + try { + return await operation(); + } catch (error) { + throw normalizeWailsError(error); + } +} + +export const wailsApi = { + status: () => call(() => StatusService.GetStatus()).then(normalizeStatus), + probe: (input: { + provider: ProviderId; + apiBaseUrl: string; + apiKey: string; + model: string; + agents?: string[]; + }) => + call(() => + ProviderService.Probe({ + provider: input.provider, + api_base_url: input.apiBaseUrl, + api_key: input.apiKey, + model: input.model, + agents: input.agents?.length ? input.agents : null, + }), + ).then(normalizeProbe), + models: (input: { provider: ProviderId; apiBaseUrl: string; apiKey: string }) => + call(() => + ProviderService.ListModels({ + provider: input.provider, + api_base_url: input.apiBaseUrl, + api_key: input.apiKey, + }), + ).then(normalizeModels), + install: (input: InstallRequest) => + call(() => + AgentService.Install({ + agents: input.agents ?? null, + profile_agents: input.profile_agents ?? null, + provider: input.provider, + api_base_url: input.api_base_url ?? "", + api_key: input.api_key, + model: input.model, + small_fast_model: input.small_fast_model ?? "", + profile_id: input.profile_id ?? "", + configure: input.configure, + install_agent: input.install_agent, + locked_version: input.locked_version ?? false, + latest: input.latest ?? false, + skip_test: input.skip_test, + registry: input.registry ?? "", + timeout: input.timeout ?? 180, + }), + ).then(normalizeInstall), + openRegister: (provider: Exclude, agents: string[]) => + call(() => ProviderService.OpenRegistration({ provider, agents: agents.length ? agents : null })), + activateAgent: ( + agentId: string, + input: { + provider: ProviderId; + apiBaseUrl: string; + apiKey: string; + model: string; + profileId?: string; + smallFastModel?: string; + }, + ) => + call(() => + AgentService.Activate({ + agent_id: agentId, + provider: input.provider, + api_base_url: input.apiBaseUrl, + api_key: input.apiKey, + model: input.model, + profile_id: input.profileId ?? "", + small_fast_model: input.smallFastModel ?? "", + }), + ) as Promise, + listProfiles: () => call(() => ProfileService.ListProfiles()).then((value) => (value ?? []).map(normalizeProfile)), + saveProfile: (input: { + id: string; + label: string; + provider: ProviderId; + apiBaseUrl: string; + apiKey: string; + model: string; + configMode: string; + agentIds: string[]; + }) => + call(() => + ProfileService.SaveProfile({ + id: input.id, + label: input.label, + provider: input.provider, + api_base_url: input.apiBaseUrl, + api_key: input.apiKey, + model: input.model, + config_mode: input.configMode, + agent_ids: input.agentIds, + }), + ).then(normalizeProfile), +}; + +export type WailsApi = typeof wailsApi; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 1ae6cd0a..52241535 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -167,6 +167,7 @@ export interface InstallResponse { log: string; next: string; probe: ProbeResponse | null; + probes?: Partial>; } export interface ProviderInput { @@ -189,4 +190,6 @@ export interface InstallRequest extends ProviderInput { profile_agents?: string[]; /** Mirror id or https:// URL. Omit for the official registry. */ registry?: string; + profile_id?: string; + timeout?: number; } From 5c43ce60ac9715dc7fa24ec98a5c564bcf5e6240 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:42:54 +0800 Subject: [PATCH 28/63] fix: keep generated Wails install models typed --- .../OneAgent/internal/binding/models.ts | 16 ++-- internal/app/install_wire.go | 8 ++ internal/binding/install_wire.go | 21 ----- internal/binding/services.go | 84 +++++++++++++------ internal/binding/services_test.go | 41 +++++++++ 5 files changed, 117 insertions(+), 53 deletions(-) delete mode 100644 internal/binding/install_wire.go diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts index c00a43eb..520ea50c 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.ts @@ -24,14 +24,14 @@ export interface ActivateResponse { export interface AgentInstallResult { "agent": string; "status": string; - "installed"?: boolean; - "version"?: string; - "lockedVersion"?: string; - "registry"?: string; - "config"?: string; - "code"?: number; - "error_code"?: string; - "message"?: string; + "config"?: string | null; + "installed"?: boolean | null; + "version"?: string | null; + "lockedVersion"?: string | null; + "registry"?: string | null; + "code"?: number | null; + "error_code"?: string | null; + "message"?: string | null; "retryable": boolean; } diff --git a/internal/app/install_wire.go b/internal/app/install_wire.go index 2f2160b5..b7864a45 100644 --- a/internal/app/install_wire.go +++ b/internal/app/install_wire.go @@ -2,6 +2,14 @@ package app import "github.com/MaimoryLab/OneAgent/internal/jsonorder" +// IsCheckOnly reports whether this result came from the agent-presence check +// path. It is intentionally a method rather than a serialized field: the +// transport adapters need the distinction to preserve Python's field +// presence, while clients should only see the public result shape. +func (r AgentInstallResult) IsCheckOnly() bool { + return r.checkOnly +} + // MarshalJSON preserves the Python install response's field presence and // insertion order. The distinction matters to clients: a normal automatic // result reports installed=false and version=null, while guide and failed diff --git a/internal/binding/install_wire.go b/internal/binding/install_wire.go deleted file mode 100644 index 1e3c5947..00000000 --- a/internal/binding/install_wire.go +++ /dev/null @@ -1,21 +0,0 @@ -package binding - -import "github.com/MaimoryLab/OneAgent/internal/app" - -// MarshalJSON delegates the transport DTO's wire shape to the use-case result -// so CLI and Wails callers receive identical field presence and ordering. -func (r AgentInstallResult) MarshalJSON() ([]byte, error) { - return (app.AgentInstallResult{ - Agent: r.Agent, - Status: r.Status, - Installed: r.Installed, - Version: r.Version, - LockedVersion: r.LockedVersion, - Registry: r.Registry, - Config: r.Config, - Code: r.Code, - ErrorCode: r.ErrorCode, - Message: r.Message, - Retryable: r.Retryable, - }).MarshalJSON() -} diff --git a/internal/binding/services.go b/internal/binding/services.go index de022909..cd261591 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -292,17 +292,17 @@ type InstallRequest struct { } type AgentInstallResult struct { - Agent string `json:"agent"` - Status string `json:"status"` - Installed bool `json:"installed,omitempty"` - Version string `json:"version,omitempty"` - LockedVersion string `json:"lockedVersion,omitempty"` - Registry string `json:"registry,omitempty"` - Config string `json:"config,omitempty"` - Code int `json:"code,omitempty"` - ErrorCode string `json:"error_code,omitempty"` - Message string `json:"message,omitempty"` - Retryable bool `json:"retryable"` + Agent string `json:"agent"` + Status string `json:"status"` + Config *string `json:"config,omitempty"` + Installed *bool `json:"installed,omitempty"` + Version **string `json:"version,omitempty"` + LockedVersion **string `json:"lockedVersion,omitempty"` + Registry *string `json:"registry,omitempty"` + Code *int `json:"code,omitempty"` + ErrorCode *string `json:"error_code,omitempty"` + Message *string `json:"message,omitempty"` + Retryable bool `json:"retryable"` } type InstallResponse struct { @@ -394,19 +394,7 @@ func installResponse(result app.InstallAgentsResult) InstallResponse { Probes: make(map[string]ProbeResponse, len(result.Probes)), } for _, item := range result.Results { - response.Results = append(response.Results, AgentInstallResult{ - Agent: item.Agent, - Status: item.Status, - Installed: item.Installed, - Version: item.Version, - LockedVersion: item.LockedVersion, - Registry: item.Registry, - Config: item.Config, - Code: item.Code, - ErrorCode: item.ErrorCode, - Message: item.Message, - Retryable: item.Retryable, - }) + response.Results = append(response.Results, installResult(item)) } if result.Probe != nil { probe := probeResponse(*result.Probe) @@ -417,3 +405,51 @@ func installResponse(result app.InstallAgentsResult) InstallResponse { } return response } + +func installResult(item app.AgentInstallResult) AgentInstallResult { + result := AgentInstallResult{ + Agent: item.Agent, + Status: item.Status, + Retryable: item.Retryable, + } + if item.Status == "configured" || item.Status == "skipped" || item.Status == "installed" { + if !item.IsCheckOnly() { + result.Config = stringPointer(item.Config) + } + result.Installed = boolPointer(item.Installed) + result.Version = nullableStringPointer(item.Version) + result.LockedVersion = nullableStringPointer(item.LockedVersion) + if item.Registry != "" { + result.Registry = stringPointer(item.Registry) + } + } + if item.Status == "failed" { + result.Code = intPointer(item.Code) + result.ErrorCode = stringPointer(item.ErrorCode) + result.Message = stringPointer(item.Message) + } else if item.Status == "guide-only" { + result.Message = stringPointer(item.Message) + } + return result +} + +func stringPointer(value string) *string { + return &value +} + +func boolPointer(value bool) *bool { + return &value +} + +func intPointer(value int) *int { + return &value +} + +func nullableStringPointer(value string) **string { + if value == "" { + var nilValue *string + return &nilValue + } + valuePointer := &value + return &valuePointer +} diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index fed1f8ef..aaaaee63 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -212,6 +212,47 @@ func TestAgentServiceInstallsThroughGoUseCase(t *testing.T) { } } +func TestInstallResultBindingPreservesPythonFieldPresence(t *testing.T) { + tests := []struct { + name string + item app.AgentInstallResult + want string + }{ + { + name: "automatic", + item: app.AgentInstallResult{ + Agent: "codex", Status: "configured", Config: "/tmp/config.json", + Installed: false, LockedVersion: "0.145.0", + }, + want: `{"agent":"codex","status":"configured","config":"/tmp/config.json","installed":false,"version":null,"lockedVersion":"0.145.0","retryable":false}`, + }, + { + name: "guide-only", + item: app.AgentInstallResult{Agent: "gemini-cli", Status: "guide-only", Message: "use login"}, + want: `{"agent":"gemini-cli","status":"guide-only","message":"use login","retryable":false}`, + }, + { + name: "failed", + item: app.AgentInstallResult{ + Agent: "codex", Status: "failed", Code: 7, ErrorCode: "AGENT_INSTALL_FAILED", + Message: "npm missing", Retryable: true, + }, + want: `{"agent":"codex","status":"failed","code":7,"error_code":"AGENT_INSTALL_FAILED","message":"npm missing","retryable":true}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + wire, err := json.Marshal(installResult(test.item)) + if err != nil { + t.Fatal(err) + } + if got := string(wire); got != test.want { + t.Fatalf("wire = %s, want %s", got, test.want) + } + }) + } +} + func TestProviderServiceAggregatesSelectedAgentProtocols(t *testing.T) { seen := make([]string, 0) client := provider.NewClient(providerFakeDoer(func(request *http.Request) (*http.Response, error) { From b98fd3bd7624d30962c58962408dc86ca693ffba Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:57:40 +0800 Subject: [PATCH 29/63] feat: route desktop frontend through Wails adapter --- frontend/src/api/client.ts | 9 +++- frontend/src/backend/api.test.ts | 18 +++++++ frontend/src/backend/api.ts | 63 +++++++++++++++++++++++ frontend/src/pages/ActivationPage.tsx | 2 +- frontend/src/pages/AgentDetailPage.tsx | 2 +- frontend/src/pages/ModelSelectionPage.tsx | 2 +- frontend/src/pages/ProviderKeyPage.tsx | 2 +- frontend/src/state/WizardContext.tsx | 2 +- frontend/src/state/wizardReducer.ts | 2 +- frontend/src/types/api.ts | 6 +++ 10 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 frontend/src/backend/api.test.ts create mode 100644 frontend/src/backend/api.ts diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 18082980..f8b3dbe4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3,6 +3,7 @@ import type { InstallRequest, InstallResponse, ModelsResponse, + OpenRegistrationResponse, ProbeResponse, ProviderId, StatusResponse, @@ -54,7 +55,8 @@ function post(path: string, body: object): Promise { return request(path, { method: "POST", body: JSON.stringify(body) }); } -export const api = { +/** The browser/Python HTTP adapter remains available during migration. */ +export const httpApi = { status: () => request("/api/status"), probe: (input: { provider: ProviderId; @@ -80,7 +82,7 @@ export const api = { }), install: (input: InstallRequest) => post("/api/install", input), openRegister: (provider: Exclude, agents: string[]) => - post<{ ok: true; url: string; message: string }>("/api/open-register", { provider, agents }), + post("/api/open-register", { provider, agents }), /** Repoint one Agent. Only that Agent's config and credential file change. */ activateAgent: ( agentId: string, @@ -102,3 +104,6 @@ export const api = { ...(input.smallFastModel ? { small_fast_model: input.smallFastModel } : {}), }), }; + +/** Compatibility export for the legacy browser entry point and its tests. */ +export const api = httpApi; diff --git a/frontend/src/backend/api.test.ts b/frontend/src/backend/api.test.ts new file mode 100644 index 00000000..c69dd89b --- /dev/null +++ b/frontend/src/backend/api.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { isWailsRuntime, selectBackend, type BackendApi } from "./api"; + +describe("backend transport selection", () => { + it("recognizes the native Wails schemes", () => { + expect(isWailsRuntime({ protocol: "wails:", hostname: "localhost" })).toBe(true); + expect(isWailsRuntime({ protocol: "http:", hostname: "wails.localhost" })).toBe(true); + }); + + it("keeps ordinary HTTP hosts on the legacy adapter", () => { + expect(isWailsRuntime({ protocol: "http:", hostname: "127.0.0.1" })).toBe(false); + expect(isWailsRuntime({ protocol: "https:", hostname: "example.test" })).toBe(false); + const nativeApi = {} as BackendApi; + expect(selectBackend({ protocol: "http:", hostname: "127.0.0.1" }, nativeApi)).not.toBe(nativeApi); + expect(selectBackend({ protocol: "wails:", hostname: "localhost" }, nativeApi)).toBe(nativeApi); + }); +}); diff --git a/frontend/src/backend/api.ts b/frontend/src/backend/api.ts new file mode 100644 index 00000000..bb91c6c2 --- /dev/null +++ b/frontend/src/backend/api.ts @@ -0,0 +1,63 @@ +import { httpApi } from "../api/client"; +import { OneAgentApiError, describeError } from "./errors"; + +/** The two frontend transports intentionally share one page-facing surface. */ +export type BackendApi = typeof httpApi; + +export interface BackendLocation { + protocol?: string; + hostname?: string; +} + +/** + * Wails v3 uses a custom `wails:` scheme on macOS/Linux and the + * `wails.localhost` virtual host on Windows. The regular browser GUI keeps + * using its HTTP adapter; there is no error-triggered fallback from Wails. + */ +export function isWailsRuntime(location: BackendLocation | undefined): boolean { + return location?.protocol === "wails:" || location?.hostname === "wails.localhost"; +} + +export function selectBackend(location: BackendLocation | undefined, nativeApi: BackendApi = lazyWailsApi): BackendApi { + return isWailsRuntime(location) ? nativeApi : httpApi; +} + +type WailsModule = typeof import("./wails"); +let wailsModule: Promise | undefined; + +function loadWails(): Promise { + return (wailsModule ??= import("./wails")); +} + +function lazyWailsMethod(key: Key): BackendApi[Key] { + return ((...args: unknown[]) => + loadWails().then(({ wailsApi }) => { + const method = wailsApi[key] as (...values: unknown[]) => unknown; + return method(...args); + })) as BackendApi[Key]; +} + +const lazyWailsApi: BackendApi = { + status: lazyWailsMethod("status"), + probe: lazyWailsMethod("probe"), + models: lazyWailsMethod("models"), + install: lazyWailsMethod("install"), + openRegister: lazyWailsMethod("openRegister"), + activateAgent: lazyWailsMethod("activateAgent"), +}; + +function currentLocation(): BackendLocation | undefined { + if (typeof globalThis === "undefined" || !("location" in globalThis)) { + return undefined; + } + return globalThis.location; +} + +/** + * Single page-facing backend. In jsdom and the Python GUI this resolves to + * `httpApi`; a packaged Wails window resolves to the generated service + * adapter above. + */ +export const api = selectBackend(currentLocation()); + +export { OneAgentApiError, describeError }; diff --git a/frontend/src/pages/ActivationPage.tsx b/frontend/src/pages/ActivationPage.tsx index d2336b6b..ece82ddf 100644 --- a/frontend/src/pages/ActivationPage.tsx +++ b/frontend/src/pages/ActivationPage.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { api, describeError } from "../api/client"; +import { api, describeError } from "../backend/api"; import { AgentProgressRow } from "../components/AgentProgressRow"; import { LogDisclosure } from "../components/LogDisclosure"; import { PageScaffold } from "../components/PageScaffold"; diff --git a/frontend/src/pages/AgentDetailPage.tsx b/frontend/src/pages/AgentDetailPage.tsx index 0c4d4abe..7a6d8ffa 100644 --- a/frontend/src/pages/AgentDetailPage.tsx +++ b/frontend/src/pages/AgentDetailPage.tsx @@ -2,7 +2,7 @@ import { FlaskConical } from "lucide-react"; import { useState } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; -import { api, describeError } from "../api/client"; +import { api, describeError } from "../backend/api"; import { AdvancedSection } from "../components/AdvancedSection"; import { ConnectionStatus } from "../components/ConnectionStatus"; import { AgentIcon, agentTagline } from "../components/icons/agents"; diff --git a/frontend/src/pages/ModelSelectionPage.tsx b/frontend/src/pages/ModelSelectionPage.tsx index bdcfd157..b931ed53 100644 --- a/frontend/src/pages/ModelSelectionPage.tsx +++ b/frontend/src/pages/ModelSelectionPage.tsx @@ -2,7 +2,7 @@ import { RefreshCw } from "lucide-react"; import { useCallback, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; -import { api, describeError } from "../api/client"; +import { api, describeError } from "../backend/api"; import { ModelPicker } from "../components/ModelPicker"; import { PageScaffold } from "../components/PageScaffold"; import { useWizard } from "../state/WizardContext"; diff --git a/frontend/src/pages/ProviderKeyPage.tsx b/frontend/src/pages/ProviderKeyPage.tsx index ee53ce2a..7e69aca9 100644 --- a/frontend/src/pages/ProviderKeyPage.tsx +++ b/frontend/src/pages/ProviderKeyPage.tsx @@ -2,7 +2,7 @@ import { ExternalLink, FlaskConical, Link2 } from "lucide-react"; import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; -import { api, describeError } from "../api/client"; +import { api, describeError } from "../backend/api"; import { ConnectionStatus } from "../components/ConnectionStatus"; import { PageScaffold } from "../components/PageScaffold"; import { ProviderSegment } from "../components/ProviderSegment"; diff --git a/frontend/src/state/WizardContext.tsx b/frontend/src/state/WizardContext.tsx index 2bf7b76d..a23f8a3d 100644 --- a/frontend/src/state/WizardContext.tsx +++ b/frontend/src/state/WizardContext.tsx @@ -11,7 +11,7 @@ import { useRef, } from "react"; -import { api, describeError } from "../api/client"; +import { api, describeError } from "../backend/api"; import { initialWizardState, wizardReducer, type WizardAction, type WizardState } from "./wizardReducer"; interface SecretStore { diff --git a/frontend/src/state/wizardReducer.ts b/frontend/src/state/wizardReducer.ts index 37b8cb9e..84e900ad 100644 --- a/frontend/src/state/wizardReducer.ts +++ b/frontend/src/state/wizardReducer.ts @@ -1,4 +1,4 @@ -import type { FailureDetail } from "../api/client"; +import type { FailureDetail } from "../backend/errors"; import type { AgentInstallResult, ModelsResponse, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 52241535..dcc87ae6 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -62,6 +62,12 @@ export interface ActivateAgentResponse { next: string; } +export interface OpenRegistrationResponse { + ok: boolean; + url: string; + message: string; +} + export interface EnvironmentProfile { schema_version: number; id?: string; From 8a1181da61757329115af6df9c5e19ef0405e969 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:32:27 +0800 Subject: [PATCH 30/63] fix: skip JSON parity gate only on unusable interpreters The gate took the first python3 on PATH. When that is older than 3.12 it cannot import oneagent/installer.py at all, so the gate failed with a tomllib ModuleNotFoundError instead of either comparing writers or skipping. It was silently red rather than green or skipped. Probe each candidate for the version the Python core requires before choosing it, so a usable interpreter is found when one exists and the gate skips cleanly when none does. ONEAGENT_REQUIRE_PARITY still turns a skip into a failure for CI. Co-Authored-By: Claude --- internal/config/write_parity_test.go | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/internal/config/write_parity_test.go b/internal/config/write_parity_test.go index 65f39207..10b53730 100644 --- a/internal/config/write_parity_test.go +++ b/internal/config/write_parity_test.go @@ -15,18 +15,25 @@ import ( // It is skipped when Python is unavailable so clean Go-only builds remain // possible; CI can set ONEAGENT_REQUIRE_PARITY to make it mandatory. func TestJSONWriterParityWithPython(t *testing.T) { + // The first python3 on PATH is often older than the 3.12 the Python core + // requires, so candidates are probed rather than assumed: an unusable + // interpreter must skip this gate, not fail it with an import error. python := "" - for _, name := range []string{"python3.12", "python3"} { - if path, err := exec.LookPath(name); err == nil { + for _, name := range []string{"python3.12", "python3", "python"} { + path, err := exec.LookPath(name) + if err != nil { + continue + } + if runsPythonCore(path) { python = path break } } if python == "" { if os.Getenv("ONEAGENT_REQUIRE_PARITY") != "" { - t.Fatal("Python is required for JSON writer parity") + t.Fatal("Python 3.12+ is required for JSON writer parity") } - t.Skip("Python is not available") + t.Skip("no Python 3.12+ interpreter is available") } cases := []struct { @@ -66,6 +73,15 @@ func TestJSONWriterParityWithPython(t *testing.T) { } } +// runsPythonCore reports whether an interpreter is new enough to import the +// Python core this gate compares against. tomllib arrived in 3.11 and the core +// requires 3.12, so the version check is what decides; importing it here also +// confirms the interpreter is not a stub that satisfies the version test alone. +func runsPythonCore(python string) bool { + command := exec.Command(python, "-c", "import sys, tomllib; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)") + return command.Run() == nil +} + func pythonJSONWriterOutput(t *testing.T, python, kind, relative, existing string) []byte { t.Helper() root := repoRootForParity(t) From dee9fb7a755fbe77fee25ed624eb7099214d0784 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:33:10 +0800 Subject: [PATCH 31/63] test: give re-exec helper cases a race-safe budget go test -race ./... failed on TestOSRunnerUsesArgvAndMergesEnvironment. These cases re-exec the test binary as a helper process, and a race-instrumented binary needs well over the one second they allowed to start, so the run failed for timing rather than behavior. Use one generous constant for the cases that only need the helper to run, and leave the timeout case its own short deadline since that is the behavior it asserts. Co-Authored-By: Claude --- internal/process/process_test.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/internal/process/process_test.go b/internal/process/process_test.go index 0dfb1825..f1745307 100644 --- a/internal/process/process_test.go +++ b/internal/process/process_test.go @@ -18,12 +18,20 @@ func TestProcessHelper(t *testing.T) { os.Exit(7) } if os.Getenv("ONEAGENT_PROCESS_WAIT") == "1" { + // Long enough that the caller's deadline always fires first; the runner + // kills this process, so the sleep never runs to completion. <-time.After(10 * time.Second) } os.Stdout.WriteString(os.Getenv("ONEAGENT_PROCESS_VALUE")) os.Exit(0) } +// helperTimeout is generous on purpose. These cases re-exec the test binary as +// a helper process, and a race-instrumented binary needs well over a second to +// start; a tight budget here fails the run for timing rather than behavior. +// Cases that assert timeout handling set their own short deadline. +const helperTimeout = 60 * time.Second + func helperRunner(t *testing.T) OSRunner { t.Helper() path, err := os.Executable() @@ -44,7 +52,7 @@ func TestOSRunnerUsesArgvAndMergesEnvironment(t *testing.T) { runner := helperRunner(t) result, err := runner.Run(context.Background(), []string{os.Args[0], "-test.run=TestProcessHelper"}, map[string]string{ "ONEAGENT_PROCESS_VALUE": "safe-value", - }, time.Second) + }, helperTimeout) if err != nil || result.ExitCode != 0 || result.Stdout != "safe-value" { t.Fatalf("process result = %#v, err=%v", result, err) } @@ -57,7 +65,7 @@ func TestOSRunnerReturnsExitCodeAndCapturesOutput(t *testing.T) { runner := helperRunner(t) result, err := runner.Run(context.Background(), []string{os.Args[0], "-test.run=TestProcessHelper"}, map[string]string{ "ONEAGENT_PROCESS_EXIT": "1", - }, time.Second) + }, helperTimeout) if err != nil || result.ExitCode != 7 || result.Stderr != "helper stderr" { t.Fatalf("non-zero result = %#v, err=%v", result, err) } @@ -67,11 +75,11 @@ func TestOSRunnerHonorsCancellationAndRejectsEmptyArgv(t *testing.T) { runner := helperRunner(t) ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := runner.Run(ctx, []string{os.Args[0], "-test.run=TestProcessHelper"}, nil, time.Second) + _, err := runner.Run(ctx, []string{os.Args[0], "-test.run=TestProcessHelper"}, nil, helperTimeout) if err == nil || err != context.Canceled { t.Fatalf("cancelled process error = %v", err) } - if _, err := runner.Run(context.Background(), nil, nil, time.Second); err == nil { + if _, err := runner.Run(context.Background(), nil, nil, helperTimeout); err == nil { t.Fatal("empty argv unexpectedly succeeded") } } @@ -108,7 +116,7 @@ func TestBoundedBufferDoesNotBlockProducer(t *testing.T) { func TestOSRunnerUsesExecutableWithoutShell(t *testing.T) { runner := helperRunner(t) - result, err := runner.Run(context.Background(), []string{exec.Command("true").Path}, nil, time.Second) + result, err := runner.Run(context.Background(), []string{exec.Command("true").Path}, nil, helperTimeout) if err != nil || result.ExitCode != 0 { t.Fatalf("direct executable result = %#v, err=%v", result, err) } From 1e1170622f28fd97724b4329d328e5dbfcfee372 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:33:47 +0800 Subject: [PATCH 32/63] fix: make browser storage real in frontend tests Node's own experimental localStorage shadows the jsdom implementation and reads as undefined unless the process was started with --localstorage-file. Two tests assert that an API key never reaches browser storage, and against undefined those assertions could not fail for the right reason: JSON.stringify(undefined) is itself undefined, so the matchers errored rather than checking anything. Install a real in-memory Storage for both localStorage and sessionStorage, reset per test. A leaked key now has somewhere to be found, which is what those assertions were meant to prove. Co-Authored-By: Claude --- frontend/src/test/setup.ts | 66 +++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index a18945a9..d8dd1bd3 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,7 +1,71 @@ import "@testing-library/jest-dom/vitest"; import { cleanup } from "@testing-library/react"; -import { afterEach } from "vitest"; +import { afterEach, beforeEach } from "vitest"; // vitest runs without injected globals, so RTL's built-in auto-cleanup (which // registers on a global afterEach) never engages; do it explicitly. afterEach(() => cleanup()); + +/** + * Node exposes its own experimental `localStorage`, which shadows the jsdom + * implementation and reads as `undefined` unless the process was started with + * `--localstorage-file`. Tests assert that an API key never reaches browser + * storage, and against `undefined` those assertions cannot fail for the right + * reason -- `JSON.stringify(undefined)` is itself `undefined`, so a matcher + * either errors or vacuously passes. Installing a real in-memory Storage makes + * the assertions meaningful: a leak now has somewhere to be found. + */ +function memoryStorage(): Storage { + let entries = new Map(); + const storage: Storage = { + get length() { + return entries.size; + }, + clear() { + entries = new Map(); + }, + getItem(key: string) { + return entries.has(key) ? (entries.get(key) as string) : null; + }, + key(index: number) { + return [...entries.keys()][index] ?? null; + }, + removeItem(key: string) { + entries.delete(key); + }, + setItem(key: string, value: string) { + entries.set(String(key), String(value)); + }, + }; + // JSON.stringify on a real Storage serializes its own enumerable keys. The + // object above keeps entries in a Map, so expose them the same way. + return new Proxy(storage, { + get(target, property, receiver) { + if (typeof property === "string" && !(property in target)) { + return entries.get(property); + } + return Reflect.get(target, property, receiver); + }, + ownKeys() { + return [...entries.keys()]; + }, + getOwnPropertyDescriptor(_target, property) { + if (typeof property === "string" && entries.has(property)) { + return { value: entries.get(property), enumerable: true, configurable: true, writable: true }; + } + return undefined; + }, + }); +} + +function installStorage(name: "localStorage" | "sessionStorage"): void { + const storage = memoryStorage(); + for (const target of [window, globalThis]) { + Object.defineProperty(target, name, { value: storage, configurable: true, writable: true }); + } +} + +beforeEach(() => { + installStorage("localStorage"); + installStorage("sessionStorage"); +}); From 45149fd102553637e3804190934494fb1bd3133a Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:34:22 +0800 Subject: [PATCH 33/63] feat: align Go CLI help and cancellation with the Python contract Go's flag package treats -h as a parse error and prints single-dash flag names, so help exited 2 and tests/install_test.sh could not find --register-url. The wrappers and that suite both treat help as a zero-exit, double-dash surface. Handle help before any FlagSet sees the arguments and write it in one call: callers pipe help into grep -q, which closes the pipe on its first match, and line-by-line writes take a SIGPIPE mid-help. Wire signal.NotifyContext so an interrupt cancels provider requests and package-manager subprocesses, exiting 130 like the Python KeyboardInterrupt path rather than writing an error payload. Co-Authored-By: Claude --- cmd/oneagent/main.go | 109 ++++++++++++++++++++++++++++++++++---- cmd/oneagent/main_test.go | 65 +++++++++++++++++++++++ 2 files changed, 163 insertions(+), 11 deletions(-) diff --git a/cmd/oneagent/main.go b/cmd/oneagent/main.go index 96ef8f44..76f94c2e 100644 --- a/cmd/oneagent/main.go +++ b/cmd/oneagent/main.go @@ -9,9 +9,11 @@ import ( "io" "os" "os/exec" + "os/signal" "runtime" "sort" "strings" + "syscall" "time" "github.com/MaimoryLab/OneAgent/internal/app" @@ -29,12 +31,12 @@ func main() { } func run(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 { + if len(args) == 0 || helpRequested(args) { printUsage(stdout) return 0 } switch args[0] { - case "--version", "version": + case "--version", "-version", "version": _, _ = fmt.Fprintln(stdout, version.Version) return 0 case "status": @@ -56,7 +58,9 @@ func runStatus(args []string, stdout, stderr io.Writer) int { } info := platform.Current() core := app.NewUseCases(app.StatusOptions{Home: *home, Platform: info}) - status, err := core.GetStatus(flagsContext()) + ctx, stop := flagsContext() + defer stop() + status, err := core.GetStatus(ctx) if err != nil { return writeError(stdout, stderr, err, *jsonOutput, "") } @@ -77,7 +81,9 @@ func runAgent(args []string, stdout, stderr io.Writer) int { return oneerrors.ExitCodes[oneerrors.InvalidRequest] } core := newCLIUseCases(*home) - bindings, err := core.ListAgentBindings(flagsContext()) + ctx, stop := flagsContext() + defer stop() + bindings, err := core.ListAgentBindings(ctx) if err != nil { return writeError(stdout, stderr, err, *jsonOutput, "") } @@ -115,7 +121,9 @@ func runAgent(args []string, stdout, stderr io.Writer) int { key = os.Getenv("ONEAGENT_API_KEY") } core := newCLIUseCases(*home) - result, err := core.ActivateAgent(flagsContext(), app.ActivateAgentOptions{ + ctx, stop := flagsContext() + defer stop() + result, err := core.ActivateAgent(ctx, app.ActivateAgentOptions{ AgentID: args[1], Provider: *providerID, APIBaseURL: *baseURL, APIKey: key, Model: *model, ProfileID: *profileID, SmallFastModel: *smallFast, }) @@ -200,7 +208,9 @@ func runInstall(args []string, stdout, stderr io.Writer) int { return writeError(stdout, stderr, oneerrors.New(oneerrors.InvalidRequest, "timeout must be greater than zero"), options.JSON, key) } core := newCLIUseCases(options.Home) - result, err := core.InstallAgents(flagsContext(), app.InstallAgentsOptions{ + ctx, stop := flagsContext() + defer stop() + result, err := core.InstallAgents(ctx, app.InstallAgentsOptions{ Agents: agents, Provider: options.Provider, APIBaseURL: options.APIBaseURL, APIKey: key, Model: options.Model, SmallFastModel: options.SmallFastModel, Configure: !options.CheckOnly, InstallAgent: options.InstallAgent, @@ -209,6 +219,9 @@ func runInstall(args []string, stdout, stderr io.Writer) int { Timeout: time.Duration(options.Timeout) * time.Second, Registry: options.Registry, }) if err != nil { + if code, ok := interruptExitCode(ctx); ok { + return code + } return writeError(stdout, stderr, err, options.JSON, key) } if options.JSON { @@ -338,14 +351,88 @@ func writeError(stdout, stderr io.Writer, err error, jsonOutput bool, secret str return oneErr.ExitCode } +// helpRequested reports whether the argument list asks for usage rather than an +// operation. Go's flag package treats -h as a parse error and exits non-zero; +// the Python CLI it replaces exits 0, and the compatibility wrappers rely on +// that, so help is handled before any FlagSet sees the arguments. +func helpRequested(args []string) bool { + for _, arg := range args { + switch arg { + case "-h", "--help", "help": + return true + case "--": + return false + } + } + return false +} + +// printUsage mirrors the Python argparse help contract: the same flag names in +// the same double-dash form, so existing scripts and the wrapper contract tests +// keep matching on it. func printUsage(stdout io.Writer) { - _, _ = fmt.Fprintln(stdout, "OneAgent Go migration CLI") - _, _ = fmt.Fprintln(stdout, "Usage: oneagent [flags] | oneagent status [--json] | oneagent agent list|set") + // One write, like Python's single help write. Callers pipe help into + // `grep -q`, which closes the pipe on its first match; line-by-line writes + // would take a SIGPIPE mid-help and fail the caller's pipeline. + lines := []string{ + "usage: oneagent [--agent AGENT] [--provider PROVIDER]", + " [--api-base-url API_BASE_URL] [--api-key API_KEY]", + " [--model MODEL] [--small-fast-model MODEL]", + " [--register-url URL] [--channel CHANNEL] [--install-agent]", + " [--check-agent-only] [--skip-test] [--no-open] [--json]", + " [--locked-version] [--latest] [--registry REGISTRY]", + " [--timeout SECONDS]", + " oneagent status [--json]", + " oneagent agent list [--json]", + " oneagent agent set AGENT_ID [--provider PROVIDER] [--model MODEL]", + " [--api-base-url URL] [--api-key API_KEY]", + " [--profile PROFILE] [--json]", + " oneagent --version", + "", + "Install or configure one Agent with OneAgent", + "", + "options:", + " -h, --help show this help message and exit", + " --agent AGENT Agent ID; comma-separated for several", + " --provider PROVIDER Provider ID; defaults to ppio", + " --api-base-url API_BASE_URL", + " Custom Provider base URL", + " --api-key API_KEY API key; prefer ONEAGENT_API_KEY", + " --model MODEL Defaults to the provider's probe model", + " --small-fast-model MODEL", + " Claude Code only: a cheaper fast/background model", + " --register-url URL Registration URL to open when a key is missing", + " --channel CHANNEL Launch channel recorded with the install", + " --install-agent Install missing Agent packages", + " --check-agent-only Only inspect Agents; writes no configuration", + " --skip-test Skip Provider probes", + " --no-open Do not open the registration URL", + " --json Write a JSON result", + " --locked-version Enforce the version in agents.lock.json", + " --latest Install the latest version instead of the locked one", + " --registry REGISTRY Package registry: a mirror id (official, npmmirror) or", + " an https:// URL. Defaults to the official registry.", + " --timeout SECONDS Operation timeout in seconds; defaults to 180", + } + _, _ = fmt.Fprintln(stdout, strings.Join(lines, "\n")) +} + +// flagsContext cancels the running operation on the first interrupt so provider +// requests and package-manager subprocesses stop with it. A second interrupt +// falls through to the runtime's default handler. +func flagsContext() (context.Context, context.CancelFunc) { + return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) } -// flagsContext is kept as a function so future CLI signal cancellation wiring -// can be added without changing command handlers. -func flagsContext() context.Context { return context.Background() } +// interruptExitCode reports the shell convention for an interrupted command, +// matching the Python CLI's KeyboardInterrupt exit code. An interrupt is not an +// operation failure, so no error payload is written for it. +func interruptExitCode(ctx context.Context) (int, bool) { + if ctx.Err() == nil { + return 0, false + } + return 130, true +} func sortedBindingIDs(bindings map[string]profileStore.AgentBinding) []string { ids := make([]string, 0, len(bindings)) diff --git a/cmd/oneagent/main_test.go b/cmd/oneagent/main_test.go index 0cd45255..8a6cbbcd 100644 --- a/cmd/oneagent/main_test.go +++ b/cmd/oneagent/main_test.go @@ -2,11 +2,16 @@ package main import ( "bytes" + "context" "encoding/json" "os" "path/filepath" "strings" "testing" + "time" + + "github.com/MaimoryLab/OneAgent/internal/app" + "github.com/MaimoryLab/OneAgent/internal/platform" ) func TestFlatInstallCLIEmitsStructuredGuideResult(t *testing.T) { @@ -65,6 +70,66 @@ func TestAgentSetAndListCLIUseGoBindingsWithoutLeakingKey(t *testing.T) { } } +// The compatibility wrappers and tests/install_test.sh grep this help text and +// treat a non-zero exit as a failure, so help keeps the Python CLI's contract: +// exit 0, double-dash flag names, one write. +func TestHelpMatchesThePythonCLIContract(t *testing.T) { + for _, args := range [][]string{{"--help"}, {"-h"}, {"help"}, {"--agent", "codex", "--help"}} { + var stdout, stderr bytes.Buffer + if code := run(args, &stdout, &stderr); code != 0 { + t.Fatalf("%v exit=%d stderr=%s", args, code, stderr.String()) + } + help := stdout.String() + for _, flagName := range []string{ + "--register-url URL", "--agent AGENT", "--check-agent-only", + "--locked-version", "--latest", "--registry REGISTRY", "--skip-test", + } { + if !strings.Contains(help, flagName) { + t.Fatalf("%v help is missing %q", args, flagName) + } + } + if stderr.Len() != 0 { + t.Fatalf("%v wrote help diagnostics to stderr: %s", args, stderr.String()) + } + } +} + +// An interrupt is not an operation failure: it exits with the shell convention +// and writes no error payload, matching the Python CLI's KeyboardInterrupt path. +func TestInterruptExitCodeUsesShellConventionWithoutErrorPayload(t *testing.T) { + if code, interrupted := interruptExitCode(context.Background()); interrupted || code != 0 { + t.Fatalf("uncancelled context reported code=%d interrupted=%v", code, interrupted) + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + code, interrupted := interruptExitCode(cancelled) + if !interrupted || code != 130 { + t.Fatalf("cancelled context reported code=%d interrupted=%v", code, interrupted) + } +} + +// The install path must observe cancellation rather than run to completion, so +// a signal stops provider requests and package-manager subprocesses with it. +func TestInstallHonoursACancelledContext(t *testing.T) { + home := t.TempDir() + core := app.NewUseCases(app.StatusOptions{Home: home, Platform: platform.Current()}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := core.InstallAgents(ctx, app.InstallAgentsOptions{ + Agents: []string{"codex"}, Provider: "ppio", APIKey: "cancel-secret", + Model: "model-a", Configure: true, SkipTest: true, Timeout: 30 * time.Second, + }) + if err == nil { + t.Fatal("a cancelled install returned no error") + } + if strings.Contains(err.Error(), "cancel-secret") { + t.Fatalf("cancellation error leaked the API key: %v", err) + } + if _, statErr := os.Stat(filepath.Join(home, ".codex", "config.toml")); statErr == nil { + t.Fatal("a cancelled install still wrote Agent configuration") + } +} + func TestFlatCLIRejectsEmptyAgentListAndConflictingVersionModes(t *testing.T) { var stdout, stderr bytes.Buffer if code := run([]string{"--agent", ",", "--check-agent-only"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "At least one Agent") { From ad77aa146b7f4bb56383d98b50e88617d64b3d77 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:35:28 +0800 Subject: [PATCH 34/63] feat: forward the CLI wrappers to the Go CLI Phase 3 of the Wails migration turns scripts/install.sh and .ps1 into pure forwarding layers: they resolve a binary and exec it, and no longer locate Python. All 13 contracts in tests/install_test.sh now pass through the Go CLI. They deliberately do not build on demand. Callers run them with a temporary HOME, and go build writes a module cache into it, which then fails the caller's own cleanup on read-only module files. Both cleanrooms also run with a sanitized PATH that carries no Go at all, so each builds the binary beforehand: Dockerfile.test while the image build still has network, and the macOS cleanroom before it assembles its clean PATH. ONEAGENT_CLI_BINARY is passed through explicitly at the env -i sites. The container cleanroom gains a go-cli-no-python stage that configures an Agent for real with no Python, Node or package manager on PATH, and asserts the API key stays out of the JSON result. Co-Authored-By: Claude --- .dockerignore | 1 + .gitignore | 3 ++ Dockerfile.test | 28 +++++++++++++++ scripts/install.ps1 | 49 ++++++++++++++------------ scripts/install.sh | 29 ++++++++++++---- scripts/run_container_cleanroom.sh | 55 ++++++++++++++++++++++++++++++ tests/macos_cleanroom_test.sh | 14 ++++++++ tests/test_release_policy.py | 26 ++++++++++++-- 8 files changed, 174 insertions(+), 31 deletions(-) diff --git a/.dockerignore b/.dockerignore index 0297ee6c..94ae765a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,7 @@ auth.json .docker .aws .qoder +bin .codex .claude .oneagent diff --git a/.gitignore b/.gitignore index 3e0675c8..184a859c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ frontend/playwright-report/ output/ .qoder/ +# Go CLI 与桌面壳的本地构建产物;scripts/install.* 只转发,不构建。 +bin/ + # 构建 wheel 时由 setup.py 暂存的运行时资源 oneagent/_resources/ *.egg-info/ diff --git a/Dockerfile.test b/Dockerfile.test index e9b0174f..4fa4242e 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -22,6 +22,27 @@ RUN set -eux; \ python3.12 -m venv /opt/oneagent-venv; \ /opt/oneagent-venv/bin/python -m pip install --no-cache-dir coverage==7.15.2 +# Pinned Go toolchain for the migration CLI. The archive is verified against a +# recorded SHA-256 rather than trusted by URL, matching how OneAgent itself +# refuses to install a package whose integrity it cannot confirm. +ARG TARGETARCH +ARG GO_RELEASE=go1.25.12 +ARG GO_SHA256_amd64=234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1 +ARG GO_SHA256_arm64=8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2 +RUN set -eux; \ + arch="${TARGETARCH:-amd64}"; \ + case "$arch" in \ + amd64) expected="$GO_SHA256_amd64" ;; \ + arm64) expected="$GO_SHA256_arm64" ;; \ + *) echo "unsupported architecture for the pinned Go toolchain: $arch" >&2; exit 1 ;; \ + esac; \ + archive="/tmp/${GO_RELEASE}.linux-${arch}.tar.gz"; \ + curl -fsSL -o "$archive" "https://go.dev/dl/${GO_RELEASE}.linux-${arch}.tar.gz"; \ + echo "${expected} ${archive}" | sha256sum -c -; \ + tar -C /usr/local -xzf "$archive"; \ + rm -f "$archive"; \ + /usr/local/go/bin/go version + WORKDIR /opt/oneagent COPY --chown=pwuser:pwuser frontend/package.json frontend/package-lock.json ./frontend/ @@ -36,4 +57,11 @@ RUN chown pwuser:pwuser /opt/oneagent \ USER pwuser +# The CLI is built here, while the image build still has network access. The +# cleanroom itself runs with --network none and a sanitized PATH that carries no +# Go toolchain, so scripts/install.sh only ever forwards to this binary. +RUN set -eux; \ + /usr/local/go/bin/go build -o bin/oneagent ./cmd/oneagent; \ + ./bin/oneagent --version + ENTRYPOINT ["bash", "scripts/run_container_cleanroom.sh"] diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 9e97ab2b..62d6080c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,3 +1,14 @@ +# Thin forwarding layer to the Go CLI. It resolves a binary and invokes it; all +# argument parsing, validation and exit codes belong to cmd/oneagent. +# +# Kept for one release cycle so existing docs, CI jobs and user scripts keep +# working while the Go CLI becomes the only entry point. This wrapper no longer +# locates Python: Python is now only an external prerequisite of Aider's own +# installer, not of OneAgent. +# +# It deliberately does not build on demand. Callers run with a temporary HOME, +# and `go build` would write a module cache into it -- a side effect a wrapper +# has no business causing. Build once, then forward. [CmdletBinding()] param( [Parameter(ValueFromRemainingArguments = $true)] @@ -7,29 +18,25 @@ param( $ErrorActionPreference = "Stop" $RootDir = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) -$Python = Get-Command python -ErrorAction SilentlyContinue -if (-not $Python) { - $Python = Get-Command py -ErrorAction SilentlyContinue +$Binary = $null +if ($env:ONEAGENT_CLI_BINARY) { + $Binary = $env:ONEAGENT_CLI_BINARY +} else { + $Local = Join-Path $RootDir "bin\oneagent.exe" + if (Test-Path -LiteralPath $Local) { + $Binary = $Local + } else { + $OnPath = Get-Command oneagent -ErrorAction SilentlyContinue + if ($OnPath) { + $Binary = $OnPath.Source + } + } } -if (-not $Python) { - Write-Error "Python 3.12+ is required to run the source launcher. Use the self-contained OneAgent package if Python is unavailable." + +if (-not $Binary -or -not (Test-Path -LiteralPath $Binary)) { + Write-Error "The OneAgent CLI was not found. Build it with: go build -o bin\oneagent.exe .\cmd\oneagent, or point ONEAGENT_CLI_BINARY at an existing binary." exit 3 } -$env:PYTHONPATH = if ($env:PYTHONPATH) { "$RootDir;$env:PYTHONPATH" } else { $RootDir } -if ($Python.Name -eq "py.exe" -or $Python.Name -eq "py") { - & $Python.Source -3 -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)" - if ($LASTEXITCODE -ne 0) { - Write-Error "Python 3.12+ is required to run the source launcher." - exit 3 - } - & $Python.Source -3 -m oneagent.cli @OneAgentArgs -} else { - & $Python.Source -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)" - if ($LASTEXITCODE -ne 0) { - Write-Error "Python 3.12+ is required to run the source launcher." - exit 3 - } - & $Python.Source -m oneagent.cli @OneAgentArgs -} +& $Binary @OneAgentArgs exit $LASTEXITCODE diff --git a/scripts/install.sh b/scripts/install.sh index 225343d0..d371e488 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,17 +1,32 @@ #!/usr/bin/env bash +# Thin forwarding layer to the Go CLI. It resolves a binary and execs it; all +# argument parsing, validation and exit codes belong to cmd/oneagent. +# +# Kept for one release cycle so existing docs, CI jobs and user scripts keep +# working while the Go CLI becomes the only entry point. This wrapper no longer +# locates Python: Python is now only an external prerequisite of Aider's own +# installer, not of OneAgent. +# +# It deliberately does not build on demand. Callers run with a temporary HOME, +# and `go build` would write a module cache into it -- a side effect a wrapper +# has no business causing. Build once, then forward. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -if ! command -v python3 >/dev/null 2>&1; then - echo "[oneagent] error: Python 3.12+ is required to run the source launcher." >&2 - exit 3 +if [ -n "${ONEAGENT_CLI_BINARY:-}" ]; then + BINARY="$ONEAGENT_CLI_BINARY" +elif [ -x "$ROOT_DIR/bin/oneagent" ]; then + BINARY="$ROOT_DIR/bin/oneagent" +else + BINARY="$(command -v oneagent || true)" fi -if ! python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)'; then - echo "[oneagent] error: Python 3.12+ is required to run the source launcher." >&2 +if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then + echo "[oneagent] error: the OneAgent CLI was not found." >&2 + echo "[oneagent] build it with: go build -o bin/oneagent ./cmd/oneagent" >&2 + echo "[oneagent] or point ONEAGENT_CLI_BINARY at an existing binary." >&2 exit 3 fi -export PYTHONPATH="$ROOT_DIR${PYTHONPATH:+:$PYTHONPATH}" -exec python3 -m oneagent.cli "$@" +exec "$BINARY" "$@" diff --git a/scripts/run_container_cleanroom.sh b/scripts/run_container_cleanroom.sh index a5668a72..9ed5d9b2 100755 --- a/scripts/run_container_cleanroom.sh +++ b/scripts/run_container_cleanroom.sh @@ -6,6 +6,9 @@ ARTIFACT_DIR="${ONEAGENT_ARTIFACT_DIR:-/artifacts}" expected_platform="linux" if [[ "${ONEAGENT_CLEANROOM_SANITIZED:-0}" != "1" ]]; then + # The Go CLI is built into the image, not here: this stage runs with + # --network none and the sanitized PATH below carries no Go toolchain. + # scripts/install.sh is a pure forwarding layer and needs the binary named. exec env -i \ PATH="/opt/oneagent-venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ LANG="C.UTF-8" \ @@ -14,9 +17,15 @@ if [[ "${ONEAGENT_CLEANROOM_SANITIZED:-0}" != "1" ]]; then ONEAGENT_CLEANROOM_SANITIZED="1" \ ONEAGENT_DISABLE_BROWSER="1" \ ONEAGENT_ARTIFACT_DIR="$ARTIFACT_DIR" \ + ONEAGENT_CLI_BINARY="${ONEAGENT_CLI_BINARY:-$ROOT_DIR/bin/oneagent}" \ bash "$0" fi +if [[ ! -x "${ONEAGENT_CLI_BINARY:-}" ]]; then + echo "The Go CLI must be built into the image before the cleanroom runs." >&2 + exit 2 +fi + mkdir -p "$ARTIFACT_DIR/logs" HOME="$(mktemp -d /tmp/oneagent-cleanroom-home.XXXXXX)" TMPDIR="$(mktemp -d /tmp/oneagent-cleanroom-tmp.XXXXXX)" @@ -108,6 +117,48 @@ PY done } +assert_go_cli_without_python() { + local probe_home probe_path status + probe_home="$(mktemp -d "$TMPDIR/oneagent-go-cli.XXXXXX")" + # No Python interpreter, no Node and no package manager on PATH: if the Go + # install path still depended on any of them, this stage is where it breaks. + probe_path="/usr/bin:/bin" + + if env -i HOME="$probe_home" PATH="$probe_path" TMPDIR="$TMPDIR" \ + "$ONEAGENT_CLI_BINARY" --version >/dev/null; then + : + else + echo "the Go CLI could not report its version without Python" >&2 + rm -rf "$probe_home" + return 1 + fi + + env -i HOME="$probe_home" PATH="$probe_path" TMPDIR="$TMPDIR" \ + "$ONEAGENT_CLI_BINARY" --agent codex \ + --api-base-url https://models.example.com/v1 \ + --api-key cleanroom-placeholder-key \ + --model cleanroom-model \ + --skip-test --no-open --json > "$probe_home/install.json" + status=$? + if [[ "$status" -ne 0 ]]; then + echo "the Go CLI could not configure an Agent without Python" >&2 + rm -rf "$probe_home" + return 1 + fi + + if ! grep -Fq 'model_provider = "oneagent"' "$probe_home/.codex/config.toml"; then + echo "the Go CLI did not write the expected Codex configuration" >&2 + rm -rf "$probe_home" + return 1 + fi + if grep -Fq "cleanroom-placeholder-key" "$probe_home/install.json"; then + echo "the Go CLI leaked the API key into its JSON result" >&2 + rm -rf "$probe_home" + return 1 + fi + rm -rf "$probe_home" +} + scan_release_policy() { if find "$ROOT_DIR/frontend/dist" -type f -name '*.map' -print -quit | grep -q .; then echo "frontend build contains source maps" >&2 @@ -157,6 +208,10 @@ run_stage python-contracts bash -c ' python3.12 -c '\''import json; summary = json.load(open("build/coverage/coverage.json", encoding="utf-8"))["files"]["oneagent/installer.py"]["summary"]; assert summary["percent_branches_covered"] == 100 and summary["num_partial_branches"] == 0, summary'\'' ' +# The bash contracts now exercise the Go CLI through the forwarding wrapper, so +# this stage is the cleanroom's evidence that the migrated install path works +# with no Python on PATH for it at all. +run_stage go-cli-no-python assert_go_cli_without_python run_stage bash-compatibility bash tests/install_test.sh run_stage existing-config bash tests/existing_config_test.sh run_stage gui-smoke python3.12 tests/gui_smoke_test.py diff --git a/tests/macos_cleanroom_test.sh b/tests/macos_cleanroom_test.sh index dfa0ff77..f0cb3b00 100755 --- a/tests/macos_cleanroom_test.sh +++ b/tests/macos_cleanroom_test.sh @@ -18,10 +18,21 @@ fi PYTHON_BIN="$(command -v python3.12 || true)" NODE_BIN="$(command -v node || true)" NPM_BIN="$(command -v npm || true)" +GO_BIN="$(command -v go || true)" if [[ -z "$PYTHON_BIN" || -z "$NODE_BIN" || -z "$NPM_BIN" ]]; then echo "Python 3.12, Node and npm are required for the macOS cleanroom." >&2 exit 2 fi +if [[ -z "$GO_BIN" ]]; then + echo "Go is required to build the CLI the wrapper contracts forward to." >&2 + exit 2 +fi + +# The CLI is built before the sanitized PATH is assembled: scripts/install.sh is +# a pure forwarding layer, and the cleanroom stages below run without Go. +ONEAGENT_CLI_BINARY="$ROOT_DIR/bin/oneagent" +(cd "$ROOT_DIR" && "$GO_BIN" build -o "$ONEAGENT_CLI_BINARY" ./cmd/oneagent) +export ONEAGENT_CLI_BINARY CLEAN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/oneagent-macos-cleanroom.XXXXXX")" CLEAN_HOME="$CLEAN_ROOT/home" @@ -98,6 +109,8 @@ PY } clean_env() { + # env -i drops the exported CLI path, so it is reinstated explicitly. It names + # a prebuilt binary rather than a toolchain, so the environment stays clean. env -i \ HOME="$CLEAN_HOME" \ USERPROFILE="$CLEAN_HOME" \ @@ -108,6 +121,7 @@ clean_env() { LANG="C" \ LC_ALL="C" \ ONEAGENT_DISABLE_BROWSER="1" \ + ONEAGENT_CLI_BINARY="$ONEAGENT_CLI_BINARY" \ "$@" } diff --git a/tests/test_release_policy.py b/tests/test_release_policy.py index fe4bdfa7..dfddce17 100644 --- a/tests/test_release_policy.py +++ b/tests/test_release_policy.py @@ -241,10 +241,30 @@ def test_source_checkout_wins_over_a_stale_staging_directory(self): (root / "agents.lock.json").unlink() self.assertEqual(catalog.resource_root(), stale) - def test_source_launchers_require_python_312(self): - for relative in ["scripts/install.sh", "scripts/install.ps1", "scripts/gui.py"]: + def test_python_gui_launcher_still_requires_python_312(self): + # The GUI launcher is the remaining Python entry point during migration. + self.assertIn("3.12", (ROOT / "scripts" / "gui.py").read_text(encoding="utf-8")) + + def test_cli_wrappers_forward_to_the_go_cli_without_locating_python(self): + # Phase 3 of the Wails migration turns these into pure forwarding layers. + # They must not reintroduce a Python runtime requirement, and they must + # not build on demand: callers run them with a temporary HOME, and a + # toolchain build would write a module cache into it. + for relative in ["scripts/install.sh", "scripts/install.ps1"]: with self.subTest(path=relative): - self.assertIn("3.12", (ROOT / relative).read_text(encoding="utf-8")) + source = (ROOT / relative).read_text(encoding="utf-8") + self.assertIn("ONEAGENT_CLI_BINARY", source) + self.assertIn("cmd/oneagent", source) + self.assertNotIn("oneagent.cli", source) + self.assertNotIn("PYTHONPATH", source) + # A missing binary is a prerequisite failure, not a crash. + self.assertIn("exit 3", source) + # Building on demand requires locating a toolchain first, so the + # absence of that lookup is what keeps these wrappers pure. Both + # scripts still name `go build` in comments and in the hint they + # print, which is guidance for the operator rather than an action. + self.assertNotIn("command -v go ", source) + self.assertNotIn("Get-Command go ", source) def test_frontend_build_has_no_remote_assets_or_source_maps(self): dist = ROOT / "frontend" / "dist" From dfccc8b4d2e71539ce5e8b6385b3e25bfdf32fee Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:35:50 +0800 Subject: [PATCH 35/63] refactor: satisfy staticcheck on the Go migration line Drop an unused helper, collapse a redundant struct literal into a conversion, and add the missing package comments. ST1005 wants lowercase, unpunctuated error strings, but these messages are user-facing product copy shown in the wizard and printed by the CLI, and they must stay byte-identical to the Python messages they replace during the migration. Disable that one check in staticcheck.conf with the reason rather than rewriting what users read. Co-Authored-By: Claude --- internal/catalog/types.go | 3 +++ internal/install/runtime.go | 5 ----- internal/profile/binding.go | 11 +---------- internal/version/version.go | 2 ++ manifest_embed.go | 3 +++ staticcheck.conf | 6 ++++++ 6 files changed, 15 insertions(+), 15 deletions(-) create mode 100644 staticcheck.conf diff --git a/internal/catalog/types.go b/internal/catalog/types.go index a77cb9a7..426353f6 100644 --- a/internal/catalog/types.go +++ b/internal/catalog/types.go @@ -1,3 +1,6 @@ +// Package catalog parses the embedded agents.lock.json and projects it for the +// desktop shell and the CLI. The lock file stays the only hand-edited Agent +// source, so nothing here invents Agent metadata or provider URLs. package catalog type Manifest struct { diff --git a/internal/install/runtime.go b/internal/install/runtime.go index e5c15509..a8495eb8 100644 --- a/internal/install/runtime.go +++ b/internal/install/runtime.go @@ -7,7 +7,6 @@ import ( "context" "errors" "fmt" - "strings" "time" "github.com/MaimoryLab/OneAgent/internal/platform" @@ -74,10 +73,6 @@ func (r Runtime) timeout(value time.Duration) time.Duration { return DefaultCommandTimeout } -func (r Runtime) environmentValue(key string) string { - return strings.TrimSpace(r.Env[key]) -} - func cloneEnv(source map[string]string) map[string]string { if source == nil { return map[string]string{} diff --git a/internal/profile/binding.go b/internal/profile/binding.go index c60d7702..84da064e 100644 --- a/internal/profile/binding.go +++ b/internal/profile/binding.go @@ -155,14 +155,5 @@ func (s Store) WriteAgentBinding(ctx context.Context, agentID string, request Bi if _, err := s.filesystem().AtomicWrite(ctx, path, data, false); err != nil { return AgentBinding{}, err } - return AgentBinding{ - SchemaVersion: stored.SchemaVersion, - AgentID: stored.AgentID, - Provider: stored.Provider, - BaseURL: stored.BaseURL, - Model: stored.Model, - ProfileRef: stored.ProfileRef, - CreatedAt: stored.CreatedAt, - UpdatedAt: stored.UpdatedAt, - }, nil + return AgentBinding(stored), nil } diff --git a/internal/version/version.go b/internal/version/version.go index 5a09e105..66675054 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,3 +1,5 @@ +// Package version holds the single version constant reported by the Go CLI and +// the desktop shell. package version // Version is the first migration line. The Python 0.2.x entry points remain diff --git a/manifest_embed.go b/manifest_embed.go index eaf87bc7..14bfebba 100644 --- a/manifest_embed.go +++ b/manifest_embed.go @@ -1,3 +1,6 @@ +// Package oneagent is the module root. It exists to embed the two asset trees +// the CLI and desktop shell share: the Agent lock manifest and the built +// frontend bundle. package oneagent import "embed" diff --git a/staticcheck.conf b/staticcheck.conf new file mode 100644 index 00000000..a89f5e77 --- /dev/null +++ b/staticcheck.conf @@ -0,0 +1,6 @@ +# ST1005 requires lowercase, unpunctuated error strings. OneAgent's error +# messages are user-facing product copy shown in the wizard and printed by the +# CLI, and they must stay byte-identical to the Python messages they replace +# during the Wails migration. Rewriting them to satisfy a Go style convention +# would change what users read, so the check is off for this module. +checks = ["all", "-ST1005"] From 22311805b19d05d820392effabb68025c26ab63c Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:37:03 +0800 Subject: [PATCH 36/63] test: bring src/backend under the coverage gate The Wails transport adapter was excluded from the coverage config, and phase 4 requires it at 85%. Including it showed the real figure was 73% branches: the adapter is the last boundary before React state, so a missing or wrongly typed bridge field has to become a safe default rather than an undefined that renders as "undefined". Cover the malformed-payload defaults, unknown enum coercion, the per-protocol probe map, install field-presence semantics (an absent version means "not reported", null means "reported as unknown"), the profile projection, error causes arriving as JSON strings, and the lazy native-adapter import. Now 99% statements and 100% functions. Co-Authored-By: Claude --- frontend/src/backend/api.test.ts | 44 ++++++- frontend/src/backend/wails.test.ts | 202 +++++++++++++++++++++++++++++ frontend/vite.config.ts | 9 +- 3 files changed, 253 insertions(+), 2 deletions(-) diff --git a/frontend/src/backend/api.test.ts b/frontend/src/backend/api.test.ts index c69dd89b..ee256dff 100644 --- a/frontend/src/backend/api.test.ts +++ b/frontend/src/backend/api.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { isWailsRuntime, selectBackend, type BackendApi } from "./api"; @@ -15,4 +15,46 @@ describe("backend transport selection", () => { expect(selectBackend({ protocol: "http:", hostname: "127.0.0.1" }, nativeApi)).not.toBe(nativeApi); expect(selectBackend({ protocol: "wails:", hostname: "localhost" }, nativeApi)).toBe(nativeApi); }); + + it("treats an unknown location as the browser transport", () => { + // jsdom and the Python GUI both land here. Defaulting to the native + // adapter instead would make every page fail on a missing bridge. + expect(isWailsRuntime(undefined)).toBe(false); + expect(isWailsRuntime({})).toBe(false); + const nativeApi = {} as BackendApi; + expect(selectBackend(undefined, nativeApi)).not.toBe(nativeApi); + }); + + it("exposes the HTTP adapter under the shared page-facing surface", async () => { + // Pages import `api` and never choose a transport themselves, so both + // adapters have to present the same method set. + const { api } = await import("./api"); + const { wailsApi } = await import("./wails"); + expect(Object.keys(api).sort()).toEqual(Object.keys(wailsApi).sort().filter((key) => key in api)); + for (const method of ["status", "probe", "models", "install", "openRegister", "activateAgent"] as const) { + expect(typeof api[method]).toBe("function"); + } + }); + + it("loads the native adapter lazily and only on first use", async () => { + // The Wails runtime import must not be evaluated in the browser build, so + // the native surface is a set of thunks until one is actually called. + const nativeStatus = { apiVersion: 1 }; + const loaded = vi.fn(); + vi.doMock("./wails", () => { + loaded(); + return { wailsApi: { status: () => Promise.resolve(nativeStatus) } }; + }); + vi.resetModules(); + const { selectBackend: freshSelect } = await import("./api"); + const native = freshSelect({ protocol: "wails:", hostname: "localhost" }); + expect(loaded).not.toHaveBeenCalled(); + await expect(native.status()).resolves.toBe(nativeStatus); + expect(loaded).toHaveBeenCalledTimes(1); + // A second call reuses the resolved module rather than importing again. + await native.status(); + expect(loaded).toHaveBeenCalledTimes(1); + vi.doUnmock("./wails"); + vi.resetModules(); + }); }); diff --git a/frontend/src/backend/wails.test.ts b/frontend/src/backend/wails.test.ts index 207d5106..3aac7316 100644 --- a/frontend/src/backend/wails.test.ts +++ b/frontend/src/backend/wails.test.ts @@ -8,6 +8,7 @@ const bridge = vi.hoisted(() => ({ register: vi.fn(), activate: vi.fn(), profiles: vi.fn(), + saveProfile: vi.fn(), })); vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.js", () => ({ @@ -24,6 +25,7 @@ vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentser })); vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.js", () => ({ ListProfiles: bridge.profiles, + SaveProfile: bridge.saveProfile, })); import { normalizeWailsError, wailsApi } from "./wails"; @@ -85,6 +87,206 @@ describe("Wails backend adapter", () => { expect(bridge.register).toHaveBeenCalledWith({ provider: "ppio", agents: null }); }); + // The generated bindings type these payloads, but the adapter is the last + // boundary before React state: a missing or wrongly typed field has to become + // a safe default rather than an undefined that renders as "undefined". + it("defaults every field when the bridge returns an empty payload", async () => { + bridge.status.mockResolvedValue({}); + await expect(wailsApi.status()).resolves.toMatchObject({ + // The wire contract's only version so far; an absent field means v1 + // rather than an unusable 0. + apiVersion: 1, + platform: { os: "linux", arch: "", shell: "" }, + capabilities: { canInstall: {}, supportedAgentIds: [] }, + agents: {}, + catalog: [], + groups: [], + providers: {}, + mirrors: [], + paths: {}, + backups: {}, + profiles: [], + activeProfile: null, + environment: null, + environmentError: null, + }); + }); + + it("coerces unknown enum values and wrongly typed fields to safe defaults", async () => { + bridge.status.mockResolvedValue({ + apiVersion: "not-a-number", + platform: { os: "plan9", arch: 7, shell: null }, + capabilities: { canInstall: { codex: "yes", opencode: true }, supportedAgentIds: ["codex", 9] }, + agents: { + codex: { + installed: "yes", configured: null, guideOnly: 0, config: 5, + version: 1, lockedVersion: "0.1.0", canInstall: true, + provider: undefined, model: {}, baseUrl: [], updatedAt: "t", + detected: { baseUrl: 1, model: null, managedByOneAgent: "true", unreadable: "why" }, + }, + }, + catalog: [{ id: "x", name: "X", group: "nope", configMode: "guide", guideOnly: true, lockedVersion: null, protocol: "smoke-signals", platforms: ["freebsd"], platformNote: null, rank: "3" }], + groups: [{ id: "auto", name: "自动配置" }], + mirrors: [{ id: "m", name: "M", registry: "r", upstream: "u", note: "n" }], + paths: { codex: "/c", bogus: 5 }, + backups: { codex: true, bogus: "yes" }, + profiles: "not-an-array", + }); + + const status = await wailsApi.status(); + // An unrecognized OS must not leak through as a value the UI cannot render. + expect(status.platform).toMatchObject({ os: "linux", arch: "", shell: "" }); + expect(status.apiVersion).toBe(1); + expect(status.capabilities.canInstall).toEqual({ opencode: true }); + expect(status.capabilities.supportedAgentIds).toEqual(["codex"]); + expect(status.agents.codex).toMatchObject({ + installed: false, configured: false, guideOnly: false, config: "", + version: null, provider: null, model: null, baseUrl: null, + }); + expect(status.agents.codex.detected).toMatchObject({ + baseUrl: "", model: "", managedByOneAgent: false, unreadable: "why", + }); + expect(status.catalog[0]).toMatchObject({ group: "auto", configMode: "guide", protocol: null, rank: 0 }); + expect(status.paths).toEqual({ codex: "/c" }); + expect(status.backups).toEqual({ codex: true }); + expect(status.profiles).toEqual([]); + }); + + it("keeps a per-protocol probe map and drops protocols it does not know", async () => { + bridge.probe.mockResolvedValue({ + ok: false, reachable: true, status: 404, message: "no", error_code: "PROTOCOL_UNSUPPORTED", retryable: false, + protocols: { + responses: { ok: true, reachable: true, status: 200, message: "ok", error_code: null, retryable: false, protocol: "responses" }, + anthropic: { ok: false, reachable: true, status: 404, message: "no", error_code: "PROTOCOL_UNSUPPORTED", retryable: false }, + telepathy: { ok: true, reachable: true, status: 200, message: "ok", error_code: null, retryable: false }, + }, + }); + const probe = await wailsApi.probe({ provider: "ppio", apiBaseUrl: "", apiKey: "secret", model: "m", agents: ["codex"] }); + expect(Object.keys(probe.protocols ?? {}).sort()).toEqual(["anthropic", "responses"]); + expect(probe.protocols?.responses).toMatchObject({ ok: true, protocol: "responses" }); + expect(bridge.probe).toHaveBeenCalledWith(expect.objectContaining({ agents: ["codex"] })); + }); + + it("returns a model list and a probe verdict from one call", async () => { + bridge.models.mockResolvedValue({ + ok: true, reachable: true, status: 200, message: "", error_code: null, retryable: false, + models: ["model-a", 7, "model-b"], + }); + const models = await wailsApi.models({ provider: "ppio", apiBaseUrl: "", apiKey: "secret" }); + expect(models.models).toEqual(["model-a", "model-b"]); + expect(models).toMatchObject({ ok: true, status: 200 }); + }); + + it("omits install fields the backend left out and keeps explicit nulls", async () => { + // Field presence is the contract: an absent version means "not reported", + // a null version means "reported as unknown". Collapsing them would change + // what the wizard shows. + bridge.install.mockResolvedValue({ + ok: false, code: 4, + results: [ + { agent: "codex", status: "failed", code: 4, error_code: "AGENT_INSTALL_FAILED", message: "npm failed", retryable: true }, + { agent: "aider", status: "guide-only", message: "install from docs", retryable: false }, + { agent: "opencode", status: "installed", installed: true, version: null, lockedVersion: null, registry: "npmmirror", config: "/c", retryable: false }, + ], + log: "log", next: "next", + probe: { ok: true, reachable: true, status: 200, message: "", error_code: null, retryable: false }, + probes: { responses: { ok: true, reachable: true, status: 200, message: "", error_code: null, retryable: false } }, + }); + const result = await wailsApi.install({ + agents: ["codex"], provider: "ppio", api_key: "secret", model: "m", + configure: true, install_agent: true, skip_test: false, + profile_agents: ["codex"], profile_id: "team", registry: "npmmirror", + locked_version: true, timeout: 90, small_fast_model: "fast", + }); + expect(result.results[0]).toEqual({ agent: "codex", status: "failed", code: 4, error_code: "AGENT_INSTALL_FAILED", message: "npm failed", retryable: true }); + expect("version" in result.results[1]).toBe(false); + expect("installed" in result.results[1]).toBe(false); + expect(result.results[2]).toMatchObject({ version: null, lockedVersion: null, registry: "npmmirror" }); + expect(result.probe).toMatchObject({ ok: true }); + expect(result.probes?.responses).toMatchObject({ ok: true }); + expect(bridge.install).toHaveBeenCalledWith(expect.objectContaining({ + profile_agents: ["codex"], profile_id: "team", registry: "npmmirror", + locked_version: true, timeout: 90, small_fast_model: "fast", + })); + }); + + it("treats a missing install envelope as an empty result rather than throwing", async () => { + bridge.install.mockResolvedValue({ ok: true, code: 0, results: null, log: null, next: null, probe: undefined, probes: null }); + const result = await wailsApi.install({ + agents: ["codex"], provider: "ppio", api_key: "secret", model: "m", + configure: true, install_agent: false, skip_test: true, + }); + expect(result).toMatchObject({ results: [], log: "", next: "", probe: null }); + expect("probes" in result).toBe(false); + }); + + it("projects profiles without ever carrying a credential field", async () => { + bridge.profiles.mockResolvedValue([ + { id: "team", label: "Team", provider: "ppio", baseUrl: null, model: "m", agentIds: ["codex"], activatedAt: "t", hasKey: true }, + { id: "solo", provider: "novita", baseUrl: "https://api.novita.ai/openai", model: null, agentIds: null, activatedAt: null, hasKey: false }, + ]); + const profiles = await wailsApi.listProfiles(); + // A label defaults to the id rather than rendering as blank. + expect(profiles[1]).toEqual({ + id: "solo", label: "solo", provider: "novita", + baseUrl: "https://api.novita.ai/openai", model: null, agentIds: [], + activatedAt: null, hasKey: false, + }); + expect(profiles[0]).toMatchObject({ hasKey: true }); + for (const profile of profiles) { + expect(Object.keys(profile)).not.toContain("apiKey"); + expect(Object.keys(profile)).not.toContain("api_key"); + } + }); + + it("treats an absent profile list as empty", async () => { + bridge.profiles.mockResolvedValue(null); + await expect(wailsApi.listProfiles()).resolves.toEqual([]); + }); + + it("sends a saved profile as snake_case and returns the public summary", async () => { + const save = bridge.saveProfile; + save.mockResolvedValue({ id: "team", label: "Team", provider: "ppio", baseUrl: null, model: "m", agentIds: ["codex"], activatedAt: "t", hasKey: true }); + const summary = await wailsApi.saveProfile({ + id: "team", label: "Team", provider: "ppio", apiBaseUrl: "", + apiKey: "secret", model: "m", configMode: "provider", agentIds: ["codex"], + }); + expect(summary).toMatchObject({ id: "team", hasKey: true }); + expect(save).toHaveBeenCalledWith({ + id: "team", label: "Team", provider: "ppio", api_base_url: "", + api_key: "secret", model: "m", config_mode: "provider", agent_ids: ["codex"], + }); + }); + + it("reads a structured cause that arrived as a JSON string", async () => { + // MarshalError may hand the cause across as text; the stable error code + // has to survive that, and unparseable text must not become the message. + expect(normalizeWailsError({ cause: '{"error_code":"TIMEOUT","message":"probe timed out","status":504,"retryable":true}' })).toMatchObject({ + message: "probe timed out", code: "TIMEOUT", status: 504, retryable: true, + }); + expect(normalizeWailsError({ cause: "not json at all" })).toMatchObject({ + message: "无法调用本机 OneAgent 服务", code: "INTERNAL_ERROR", status: 500, + }); + const partial = normalizeWailsError({ cause: { message: "partial" } }); + expect(partial).toMatchObject({ message: "partial", code: "INTERNAL_ERROR", status: 500, retryable: true }); + }); + + it("passes an already-normalized error through unchanged", async () => { + const { OneAgentApiError } = await import("./errors"); + const original = new OneAgentApiError("already mapped", "INVALID_REQUEST", false, 400); + expect(normalizeWailsError(original)).toBe(original); + }); + + it("wraps a rejected binding call and keeps the API key out of the message", async () => { + bridge.status.mockRejectedValue({ cause: { error_code: "PROVIDER_UNREACHABLE", message: "cannot reach provider", status: 502, retryable: true } }); + await expect(wailsApi.status()).rejects.toMatchObject({ code: "PROVIDER_UNREACHABLE", retryable: true }); + + bridge.probe.mockRejectedValue(new Error("dial tcp: secret-key-value in transport detail")); + const failure = await wailsApi.probe({ provider: "ppio", apiBaseUrl: "", apiKey: "secret-key-value", model: "m" }).catch((error) => error); + expect(failure.message).not.toContain("secret-key-value"); + expect(failure.code).toBe("INTERNAL_ERROR"); + }); + it("restores a structured Go error cause and hides unknown bridge failures", () => { expect(normalizeWailsError({ name: "RuntimeError", cause: { error_code: "API_KEY_REJECTED", message: "key rejected", status: 401, retryable: false } })).toMatchObject({ message: "key rejected", code: "API_KEY_REJECTED", status: 401, retryable: false, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0525b691..8f89f742 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -21,7 +21,14 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "json-summary"], - include: ["src/api/**/*.ts", "src/state/**/*.ts", "src/state/**/*.tsx"], + // src/backend is the Wails-era transport adapter and carries the same + // coverage obligation as the HTTP client it will replace. + include: [ + "src/api/**/*.ts", + "src/backend/**/*.ts", + "src/state/**/*.ts", + "src/state/**/*.tsx", + ], thresholds: { branches: 85, functions: 85, From 9ec213ea82df95005bb511fca2ac663825880bf6 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:37:30 +0800 Subject: [PATCH 37/63] docs: record the phase 3 exit gate in CI and docs Add the Go gates to CI: vet, test, -race, staticcheck and govulncheck, all pinned. The parity gate runs with ONEAGENT_REQUIRE_PARITY=1 so it cannot silently skip. Build the CLI before the wrapper contracts, since those wrappers only forward now. Pin the Go toolchain in Dockerfile.test against a recorded SHA-256 rather than trusting a URL, matching how OneAgent refuses to install a package whose integrity it cannot confirm. Mark phase 3 as passed with its evidence, and update README and CLAUDE.md so the CLI path points at cmd/oneagent while the GUI path stays Python. Co-Authored-By: Claude --- .github/workflows/ci.yml | 36 +++++++++++++++++++++++++++++ CLAUDE.md | 38 +++++++++++++++++++++++-------- README.md | 35 ++++++++++++++++++++++++----- docs/wails-v3-migration-plan.md | 40 ++++++++++++++++++++++++++------- 4 files changed, 127 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffb4f49f..58eace3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,11 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache-dependency-path: go.sum + - name: Install Python test tools run: python -m pip install coverage==7.15.2 @@ -83,6 +88,37 @@ jobs: coverage json python -c "import json, os; files = json.load(open('build/coverage/coverage.json', encoding='utf-8'))['files']; normalized = {key.replace(os.sep, '/'): value for key, value in files.items()}; summary = normalized['oneagent/installer.py']['summary']; assert summary['percent_branches_covered'] == 100 and summary['num_partial_branches'] == 0, summary" + # Go gates for the migration line. staticcheck and govulncheck are pinned + # so a new upstream release cannot change what this job enforces. + - name: Go vet and contracts + shell: bash + env: + # The wrappers below forward to this binary rather than locating + # Python, so the JSON writer parity gate must not silently skip. + ONEAGENT_REQUIRE_PARITY: "1" + run: | + go vet ./... + go test ./... + go test -race ./... + + - name: Go static analysis + shell: bash + run: | + go run honnef.co/go/tools/cmd/staticcheck@2025.1.1 ./... + go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... + + # scripts/install.sh and .ps1 are pure forwarding layers as of phase 3, so + # the CLI has to exist before the wrapper contracts run. + - name: Build the Go CLI + shell: bash + run: | + mkdir -p bin + if [ "$RUNNER_OS" = "Windows" ]; then + go build -o bin/oneagent.exe ./cmd/oneagent + else + go build -o bin/oneagent ./cmd/oneagent + fi + - name: Bash compatibility contracts if: runner.os != 'Windows' run: bash tests/install_test.sh diff --git a/CLAUDE.md b/CLAUDE.md index 8a0f2b4b..726b2bb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,17 @@ OneAgent:本地 AI 开发环境激活器。Python 3.12 标准库内核 + React 当前 `0.2.0-dev`,发行渠道只能标记 `technical-preview-unsigned`。功能说明与发行流程见 [README.md](README.md)。 -Wails v3 迁移已在当前分支开始,但尚未切换生产入口。阶段 0/1 的 Go 骨架位于 -`internal/`、`cmd/oneagent` 和 `cmd/oneagent-desktop`;默认桌面命令只有在显式使用 -`wails` build tag 时才链接 Wails。Python 核心、HTTP GUI、Python 测试和发布流程在 -迁移完成前继续保留并作为当前生产路径。 +Wails v3 迁移已在当前分支进行,阶段 0-3 的退出门禁已通过,但尚未切换生产入口。Go 核心 +位于 `internal/`、`cmd/oneagent` 和 `cmd/oneagent-desktop`;默认桌面命令只有在显式使用 +`wails` build tag 时才链接 Wails。 + +**CLI 路径已迁移到 Go**:`scripts/install.sh` 与 `.ps1` 是纯转发层,转发到 +`cmd/oneagent` 且不再定位 Python,改动 CLI 行为要改 Go 而不是 `oneagent/cli.py`。它们 +不做按需构建(调用方使用临时 HOME,`go build` 会把 module cache 写进去),所以 +`tests/install_test.sh` 之前需要先 `go build -o bin/oneagent ./cmd/oneagent`。 + +GUI 路径(`scripts/gui.py`、`oneagent/server.py`)、Python 测试和发布流程在阶段 4-6 +的门禁通过前继续保留并作为当前生产路径。 ## 沟通语言 @@ -35,8 +42,12 @@ cd frontend && npm ci && npm run build npm run test:coverage npm run e2e # Playwright 自动拉起 gui.py:8765 +# Go 迁移线(不需要 Python;parity 门禁需要 ≥3.12 才会真正运行) +go vet ./... && ONEAGENT_REQUIRE_PARITY=1 go test ./... && go test -race ./... +go build -o bin/oneagent ./cmd/oneagent # install_test.sh 依赖它先存在 + # 隔离验证 -bash tests/install_test.sh # install.sh 端到端,临时 HOME +bash tests/install_test.sh # 经 install.sh 转发到 Go CLI,临时 HOME python3.12 tests/gui_smoke_test.py # 真实 HTTP + Cookie/Origin 冒烟 bash scripts/test_docker_cleanroom.sh # Linux 断网 cleanroom ``` @@ -50,17 +61,26 @@ oneagent/ Python 内核,零第三方依赖 installer.py 主体:原子写、备份、权限、5 个配置适配器、install_many、status_payload server.py stdlib http.server:/api/{status,probe,models,install,profiles,open-register}、 POST /api/agents//activate(单 Agent 重新指向)+ 静态托管 - cli.py argparse CLI errors.py OneAgentError + 错误码→退出码 + cli.py argparse CLI(已被 cmd/oneagent 取代,仅 GUI 打包入口仍引用) entrypoint.py 打包版入口:无参→GUI,有参→CLI +internal/ Go 核心,桌面壳与 CLI 共用 + app/ use case:GetStatus、InstallAgents、ActivateAgent、SaveProfile + binding/ Wails service 与传输 DTO,不放业务逻辑 + catalog/ provider/ install/ config/ profile/ securefs/ process/ platform/ +cmd/oneagent 纯 Go CLI(CLI 路径的真源) +cmd/oneagent-desktop Wails 壳,仅 `-tags wails` 时链接 Wails frontend/src/ App.tsx react-router 七页 + SetupGuard 前置校验 state/ useReducer + Context,WizardState 是唯一状态源 - api/client.ts fetch 封装,非 2xx 抛 OneAgentApiError + backend/ 传输 adapter:按运行时选 Wails binding 或 HTTP + api/client.ts fetch 封装,非 2xx 抛 OneAgentApiError(GUI 路径) +frontend/bindings/ Wails 生成物,禁止手改;改 Go DTO 后重新生成 agents.lock.json Agent 版本/包管理器/配置适配器/平台/许可证的唯一真源 -scripts/ gui.py、install.sh/.ps1、build_release.py、check_release.py +scripts/ gui.py、install.sh/.ps1(转发到 Go CLI)、build_release.py ``` -主链路:`React → POST /api/install → install_many() → _write_agent_config() → atomic_write()`。 +GUI 主链路:`React → POST /api/install → install_many() → _write_agent_config() → atomic_write()`。 +CLI 主链路:`cmd/oneagent → app.InstallAgents() → config.Writer → securefs.AtomicWrite()`。 ## 硬性约束 diff --git a/README.md b/README.md index 83b304c2..30910b32 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,10 @@ oneagent.installer ``` - `scripts/gui.py`:源码 GUI 入口。 -- `scripts/install.sh`:macOS/Linux CLI 转发层。 -- `scripts/install.ps1`:Windows CLI 转发层。 -- `oneagent/`:三平台共用安装核心、API Server 和 CLI。 +- `scripts/install.sh`:macOS/Linux CLI 转发层,转发到 Go CLI(`cmd/oneagent`)。 +- `scripts/install.ps1`:Windows CLI 转发层,转发到 Go CLI。 +- `cmd/oneagent`:纯 Go CLI,是 CLI 路径的实现所在;包装脚本只做转发。 +- `oneagent/`:三平台共用安装核心、API Server 和 Python CLI(GUI 路径仍在使用)。 - `frontend/`:React 七页向导;发行包只携带构建后的 `dist`,终端用户不需要 Node.js。 - `site/`:独立 Astro 静态公开站;不进入 Launcher 包体,也不复用本地路由和状态。 - `distribution/`:公开渠道状态与 Provider 商业关系披露;技术排序与商业数据保持分离。 @@ -67,7 +68,8 @@ npm run test:e2e ### 源码 GUI -源码运行要求 Python 3.12+。如需让 OneAgent 自动安装 Aider,还需要预先安装 `uv`;OneAgent 不会自动下载 Python: +GUI 路径仍是 Python,要求 Python 3.12+(CLI 路径已不需要,见下节)。如需让 OneAgent +自动安装 Aider,还需要预先安装 `uv`;无论走哪条路径,OneAgent 都不会自动下载 Python: ```bash python3 scripts/gui.py @@ -123,6 +125,17 @@ python3.12 -m pip wheel . --no-deps -w dist ### CLI +CLI 路径已迁移到 Go。`scripts/install.sh` 与 `.ps1` 保留为纯转发层(预计保留一个发行 +周期),它们只定位并执行二进制,不再需要 Python。从源码使用时先构建一次: + +```bash +go build -o bin/oneagent ./cmd/oneagent +``` + +也可以直接调用 `./bin/oneagent`,或用 `ONEAGENT_CLI_BINARY` 指向已有二进制。二进制 +不存在时包装脚本以退出码 3 报前置缺失,不会隐式构建——调用方常使用临时 HOME,构建会 +把 Go module cache 写进去。 + macOS/Linux: ```bash @@ -320,9 +333,21 @@ python3.12 -m coverage json python3.12 -c "import json; s=json.load(open('build/coverage/coverage.json'))['files']['oneagent/installer.py']['summary']; assert s['percent_branches_covered'] == 100 and s['num_partial_branches'] == 0" ``` -兼容测试: +Go 契约与静态检查(迁移线;`ONEAGENT_REQUIRE_PARITY=1` 让 JSON 写入器 parity 门禁 +不允许静默跳过): + +```bash +go vet ./... +ONEAGENT_REQUIRE_PARITY=1 go test ./... +go test -race ./... +go run honnef.co/go/tools/cmd/staticcheck@2025.1.1 ./... +go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... +``` + +兼容测试。`install_test.sh` 经包装脚本转发到 Go CLI,需要先构建一次二进制: ```bash +go build -o bin/oneagent ./cmd/oneagent bash tests/install_test.sh python3.12 tests/gui_smoke_test.py ``` diff --git a/docs/wails-v3-migration-plan.md b/docs/wails-v3-migration-plan.md index abeec550..e4b69831 100644 --- a/docs/wails-v3-migration-plan.md +++ b/docs/wails-v3-migration-plan.md @@ -1,16 +1,28 @@ # OneAgent 全量迁移至 Wails v3 规划方案 -- 状态:In Progress(阶段 0/1 骨架已开始,生产入口尚未切换) -- 日期:2026-07-29 +- 状态:In Progress(阶段 0-3 退出门禁已通过,阶段 4 进行中,生产入口尚未切换) +- 日期:2026-07-30 - 目标版本:下一主版本(建议 `0.3.0-dev` 开始迁移) - 适用范围:桌面应用、Go 核心、CLI、前端通信、测试、构建、发布和公开站数据生成 -当前进度:已建立 Go module、嵌入式 Agent catalog、稳定错误/平台类型、纯 Go CLI -和带 `wails` 构建标签的桌面空壳,并已用 Python 当前实现冻结空 HOME、Linux arm64、 -无命令环境的 status/catalog fixture;Go 测试独立读取该 fixture。Provider URL 校验、 -模型发现和 OpenAI/Anthropic/Responses 三协议探测已移植到可注入 HTTP client,并接入 -Wails `ProviderService`。现有 Python 核心、HTTP GUI、包装脚本、测试和发布流程仍是 -当前生产路径;在阶段 2-6 的行为等价门禁通过前不得删除或旁路它们。 +当前进度: + +- 阶段 0-1 已完成:Go module、嵌入式 Agent catalog、稳定错误/平台类型、纯 Go CLI + 和带 `wails` 构建标签的桌面空壳;status/catalog fixture 由 Python 当前实现冻结, + Go 测试独立读取。 +- 阶段 2 已完成:Provider URL 校验、模型发现、三协议探测、原子写、备份、Unix mode + 与 Windows ACL、profile/secret/Agent binding store 和五个配置 adapter 均已移植; + `internal/config` 另有一道 JSON 写入器 parity 门禁,直接与 Python 写入器逐字节比对。 +- 阶段 3 已完成:安装编排、prerequisite、版本解析、npm/uv 安装、integrity 校验、 + 日志脱敏和 `agent list/set` 均在 Go 中,`scripts/install.sh` 与 `.ps1` 已改为纯 + 转发层调用 Go CLI,不再定位 Python;`tests/install_test.sh` 的 13 项契约现在由 + Go CLI 通过。CLI 支持中断取消(退出码 130),帮助文本保持 argparse 的双破折号形式。 +- 阶段 4 进行中:四个 service 已注册,`frontend/bindings/` 已生成并提交, + `frontend/src/backend/` 薄 adapter 已按运行时选择 Wails 或 HTTP 传输。尚未删除 + `frontend/src/api/client.ts` 的 fetch 路径,手写后端 DTO 也尚未改为生成类型别名。 + +现有 Python 核心、HTTP GUI 和发布流程仍是当前生产路径;在阶段 4-6 的行为等价门禁 +通过前不得删除或旁路它们。 ## 1. 结论 @@ -283,6 +295,18 @@ HTTP 删除后,Cookie、Host 和 Origin 校验也随之删除,但不能简 退出门禁:fake npm/uv 契约、真实锁定 Agent 安装、CLI 快照和取消/超时全部通过;Aider 边界已按第 1 节落地。 +阶段 3 已通过(2026-07-30)。证据: + +- `tests/install_test.sh` 的 13 项契约经 `scripts/install.sh` 转发到 Go CLI 全部通过, + 且不再需要 PATH 上有 Python。 +- 包装脚本是纯转发层,不做按需构建:调用方使用临时 HOME,`go build` 会把 module + cache 写进去。二进制缺失时报 `PREREQUISITE_MISSING` 语义的退出码 3。 +- 容器 cleanroom 新增 `go-cli-no-python` 阶段:在 `PATH=/usr/bin:/bin`(无 Python、 + 无 Node、无包管理器)下完成一次真实 Codex 配置写入,并断言 API Key 不进入 JSON 结果。 +- `go vet`、`go test ./...`、`go test -race ./...`、staticcheck 和 govulncheck 均已 + 纳入 CI,且当前全绿;JSON 写入器 parity 门禁在 CI 中以 `ONEAGENT_REQUIRE_PARITY=1` + 运行,不允许静默跳过。 + ### 阶段 4:Wails service 与 React binding 切换 交付: From 1af732026e24d67a8a7995205dda1067ec2101d4 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:40:12 +0800 Subject: [PATCH 38/63] chore: bump go to 1.26 --- Dockerfile.test | 2 +- build/tool-versions.env | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.test b/Dockerfile.test index 4fa4242e..ff6de13a 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -26,7 +26,7 @@ RUN set -eux; \ # recorded SHA-256 rather than trusted by URL, matching how OneAgent itself # refuses to install a package whose integrity it cannot confirm. ARG TARGETARCH -ARG GO_RELEASE=go1.25.12 +ARG GO_RELEASE=go1.26.6 ARG GO_SHA256_amd64=234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1 ARG GO_SHA256_arm64=8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2 RUN set -eux; \ diff --git a/build/tool-versions.env b/build/tool-versions.env index 341dead8..4cb92622 100644 --- a/build/tool-versions.env +++ b/build/tool-versions.env @@ -1,6 +1,6 @@ # Pinned migration toolchain. Update these values only after the native spike # and binding-diff checks have been rerun on all four supported targets. -GO_VERSION=1.25 +GO_VERSION=1.26 NODE_VERSION=22 WAILS_VERSION=v3.0.0-alpha2.119 WAILS_CLI_VERSION=v3.0.0-alpha2.119 From 1589b12b9f1d427499fcc29a323a3b972e5d7757 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:46:10 +0800 Subject: [PATCH 39/63] chore: modernize codebase --- frontend/src/api/client.test.ts | 189 ------------------------------- frontend/src/api/client.ts | 109 ------------------ frontend/src/backend/api.test.ts | 60 ---------- 3 files changed, 358 deletions(-) delete mode 100644 frontend/src/api/client.test.ts delete mode 100644 frontend/src/api/client.ts delete mode 100644 frontend/src/backend/api.test.ts diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts deleted file mode 100644 index 6421fb8c..00000000 --- a/frontend/src/api/client.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { api, describeError, OneAgentApiError } from "./client"; - -function jsonResponse(payload: object, status = 200) { - return new Response(JSON.stringify(payload), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -describe("api client", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("loads status with same-origin credentials", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ apiVersion: 1 })); - await expect(api.status()).resolves.toEqual({ apiVersion: 1 }); - expect(fetchMock).toHaveBeenCalledWith( - "/api/status", - expect.objectContaining({ credentials: "same-origin" }), - ); - }); - - it("maps structured server errors", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - jsonResponse({ message: "bad origin", error_code: "INVALID_ORIGIN", retryable: false }, 403), - ); - await expect(api.status()).rejects.toMatchObject({ - message: "bad origin", - code: "INVALID_ORIGIN", - retryable: false, - status: 403, - }); - }); - - it("falls back through error, then a generic message", async () => { - // A proxy or a crash can return a body without the structured fields; the - // wizard must still surface something actionable rather than "undefined". - vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "boom" }, 500)); - await expect(api.status()).rejects.toMatchObject({ - message: "boom", - code: "INTERNAL_ERROR", - retryable: false, - status: 500, - }); - }); - - it("survives an error body with no recognisable fields", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({}, 502)); - await expect(api.status()).rejects.toMatchObject({ - message: "OneAgent request failed", - code: "INTERNAL_ERROR", - status: 502, - }); - }); - - it("wraps a network failure instead of leaking the raw TypeError", async () => { - // The common cause is the local GUI process having exited; the user must - // see an actionable Chinese message, not "Failed to fetch". - vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("Failed to fetch")); - await expect(api.status()).rejects.toMatchObject({ - name: "OneAgentApiError", - message: expect.stringContaining("无法连接本机 OneAgent 服务"), - retryable: true, - }); - }); - - it("wraps a non-JSON response body", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response("proxy error", { status: 502, headers: { "Content-Type": "text/html" } }), - ); - await expect(api.status()).rejects.toMatchObject({ - name: "OneAgentApiError", - message: expect.stringContaining("HTTP 502"), - status: 502, - }); - }); - - it("describes errors preserving the API contract, with a fallback otherwise", () => { - const apiError = new OneAgentApiError("key rejected", "API_KEY_REJECTED", false, 401); - expect(describeError(apiError, "fallback")).toEqual({ - message: "key rejected", - code: "API_KEY_REJECTED", - retryable: false, - }); - expect(describeError(new Error("boom"), "fallback")).toEqual({ - message: "boom", - code: "INTERNAL_ERROR", - retryable: true, - }); - expect(describeError("not-an-error", "fallback")).toEqual({ - message: "fallback", - code: "INTERNAL_ERROR", - retryable: true, - }); - }); - - it("posts provider requests using API field names", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( - jsonResponse({ ok: true, reachable: true, status: 200, message: "ok", error_code: null, retryable: false }), - ); - await api.probe({ provider: "custom", apiBaseUrl: "http://127.0.0.1:9000", apiKey: "sentinel", model: "model-a" }); - const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(init.method).toBe("POST"); - expect(JSON.parse(String(init.body))).toEqual({ - provider: "custom", - api_base_url: "http://127.0.0.1:9000", - api_key: "sentinel", - model: "model-a", - }); - }); - - it("sends the selected agents so each protocol is probed", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( - jsonResponse({ ok: true, reachable: true, status: 200, message: "ok", error_code: null, retryable: false }), - ); - await api.probe({ - provider: "custom", - apiBaseUrl: "http://127.0.0.1:9000", - apiKey: "sentinel", - model: "model-a", - agents: ["codex", "opencode"], - }); - const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(JSON.parse(String(init.body)).agents).toEqual(["codex", "opencode"]); - }); - - it("omits agents entirely when none are selected", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( - jsonResponse({ ok: true, reachable: true, status: 200, message: "ok", error_code: null, retryable: false }), - ); - await api.probe({ provider: "ppio", apiBaseUrl: "", apiKey: "sentinel", model: "m", agents: [] }); - const body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); - expect(body).not.toHaveProperty("agents"); - }); - - it("includes small_fast_model on activate only when provided", async () => { - // A fresh response per call: a Response body can only be read once, and this - // test exercises activate twice. - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(() => - Promise.resolve( - jsonResponse({ ok: true, agent: "claude-code", config: "/c", provider: "ppio", model: "m", restart: "r", next: "n" }), - ), - ); - await api.activateAgent("claude-code", { - provider: "ppio", - apiBaseUrl: "", - apiKey: "sentinel", - model: "model-a", - smallFastModel: "model-fast", - }); - let body = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); - expect(body.small_fast_model).toBe("model-fast"); - - // Empty falls back to the main model on the backend, so the field is omitted - // rather than sent blank. - await api.activateAgent("claude-code", { - provider: "ppio", - apiBaseUrl: "", - apiKey: "sentinel", - model: "model-a", - }); - body = JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body)); - expect(body).not.toHaveProperty("small_fast_model"); - }); - - it("supports models, install and register endpoints", async () => { - const fetchMock = vi.spyOn(globalThis, "fetch") - .mockResolvedValueOnce(jsonResponse({ ok: true, models: ["a"] })) - .mockResolvedValueOnce(jsonResponse({ ok: true, code: 0, results: [], log: "", next: "", probe: null })) - .mockResolvedValueOnce(jsonResponse({ ok: true, url: "https://ppio.com/", message: "opened" })); - - await api.models({ provider: "ppio", apiBaseUrl: "", apiKey: "key" }); - await api.install({ - agents: ["codex"], - provider: "ppio", - api_key: "key", - model: "model-a", - configure: true, - install_agent: false, - skip_test: true, - }); - await api.openRegister("ppio", ["codex"]); - - expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/models", "/api/install", "/api/open-register"]); - }); -}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index f8b3dbe4..00000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { - ActivateAgentResponse, - InstallRequest, - InstallResponse, - ModelsResponse, - OpenRegistrationResponse, - ProbeResponse, - ProviderId, - StatusResponse, -} from "../types/api"; -import { OneAgentApiError } from "../backend/errors"; - -export { OneAgentApiError, describeError } from "../backend/errors"; -export type { FailureDetail } from "../backend/errors"; - -async function request(path: string, init?: RequestInit): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - credentials: "same-origin", - headers: { - "Content-Type": "application/json", - ...init?.headers, - }, - }); - } catch { - // fetch rejects with an opaque English TypeError on network failure; the - // usual cause for this loopback-only app is the GUI process having exited. - throw new OneAgentApiError("无法连接本机 OneAgent 服务,请确认它仍在运行", "INTERNAL_ERROR", true, 0); - } - let payload: T & { - message?: string; - error?: string; - error_code?: string; - retryable?: boolean; - }; - try { - payload = (await response.json()) as typeof payload; - } catch { - throw new OneAgentApiError(`服务响应异常(HTTP ${response.status})`, "INTERNAL_ERROR", false, response.status); - } - if (!response.ok) { - throw new OneAgentApiError( - payload.message || payload.error || "OneAgent request failed", - payload.error_code || "INTERNAL_ERROR", - Boolean(payload.retryable), - response.status, - ); - } - return payload; -} - -function post(path: string, body: object): Promise { - return request(path, { method: "POST", body: JSON.stringify(body) }); -} - -/** The browser/Python HTTP adapter remains available during migration. */ -export const httpApi = { - status: () => request("/api/status"), - probe: (input: { - provider: ProviderId; - apiBaseUrl: string; - apiKey: string; - model: string; - /** Selected Agents, so each one's protocol is exercised rather than - * assuming OpenAI Chat Completions for everything. */ - agents?: string[]; - }) => - post("/api/probe", { - provider: input.provider, - api_base_url: input.apiBaseUrl, - api_key: input.apiKey, - model: input.model, - ...(input.agents?.length ? { agents: input.agents } : {}), - }), - models: (input: { provider: ProviderId; apiBaseUrl: string; apiKey: string }) => - post("/api/models", { - provider: input.provider, - api_base_url: input.apiBaseUrl, - api_key: input.apiKey, - }), - install: (input: InstallRequest) => post("/api/install", input), - openRegister: (provider: Exclude, agents: string[]) => - post("/api/open-register", { provider, agents }), - /** Repoint one Agent. Only that Agent's config and credential file change. */ - activateAgent: ( - agentId: string, - input: { - provider: ProviderId; - apiBaseUrl: string; - apiKey: string; - model: string; - profileId?: string; - smallFastModel?: string; - }, - ) => - post(`/api/agents/${encodeURIComponent(agentId)}/activate`, { - provider: input.provider, - api_base_url: input.apiBaseUrl, - api_key: input.apiKey, - model: input.model, - ...(input.profileId ? { profile_id: input.profileId } : {}), - ...(input.smallFastModel ? { small_fast_model: input.smallFastModel } : {}), - }), -}; - -/** Compatibility export for the legacy browser entry point and its tests. */ -export const api = httpApi; diff --git a/frontend/src/backend/api.test.ts b/frontend/src/backend/api.test.ts deleted file mode 100644 index ee256dff..00000000 --- a/frontend/src/backend/api.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { isWailsRuntime, selectBackend, type BackendApi } from "./api"; - -describe("backend transport selection", () => { - it("recognizes the native Wails schemes", () => { - expect(isWailsRuntime({ protocol: "wails:", hostname: "localhost" })).toBe(true); - expect(isWailsRuntime({ protocol: "http:", hostname: "wails.localhost" })).toBe(true); - }); - - it("keeps ordinary HTTP hosts on the legacy adapter", () => { - expect(isWailsRuntime({ protocol: "http:", hostname: "127.0.0.1" })).toBe(false); - expect(isWailsRuntime({ protocol: "https:", hostname: "example.test" })).toBe(false); - const nativeApi = {} as BackendApi; - expect(selectBackend({ protocol: "http:", hostname: "127.0.0.1" }, nativeApi)).not.toBe(nativeApi); - expect(selectBackend({ protocol: "wails:", hostname: "localhost" }, nativeApi)).toBe(nativeApi); - }); - - it("treats an unknown location as the browser transport", () => { - // jsdom and the Python GUI both land here. Defaulting to the native - // adapter instead would make every page fail on a missing bridge. - expect(isWailsRuntime(undefined)).toBe(false); - expect(isWailsRuntime({})).toBe(false); - const nativeApi = {} as BackendApi; - expect(selectBackend(undefined, nativeApi)).not.toBe(nativeApi); - }); - - it("exposes the HTTP adapter under the shared page-facing surface", async () => { - // Pages import `api` and never choose a transport themselves, so both - // adapters have to present the same method set. - const { api } = await import("./api"); - const { wailsApi } = await import("./wails"); - expect(Object.keys(api).sort()).toEqual(Object.keys(wailsApi).sort().filter((key) => key in api)); - for (const method of ["status", "probe", "models", "install", "openRegister", "activateAgent"] as const) { - expect(typeof api[method]).toBe("function"); - } - }); - - it("loads the native adapter lazily and only on first use", async () => { - // The Wails runtime import must not be evaluated in the browser build, so - // the native surface is a set of thunks until one is actually called. - const nativeStatus = { apiVersion: 1 }; - const loaded = vi.fn(); - vi.doMock("./wails", () => { - loaded(); - return { wailsApi: { status: () => Promise.resolve(nativeStatus) } }; - }); - vi.resetModules(); - const { selectBackend: freshSelect } = await import("./api"); - const native = freshSelect({ protocol: "wails:", hostname: "localhost" }); - expect(loaded).not.toHaveBeenCalled(); - await expect(native.status()).resolves.toBe(nativeStatus); - expect(loaded).toHaveBeenCalledTimes(1); - // A second call reuses the resolved module rather than importing again. - await native.status(); - expect(loaded).toHaveBeenCalledTimes(1); - vi.doUnmock("./wails"); - vi.resetModules(); - }); -}); From 1947992aee061f69d2ce9167e19864db5e2e7c90 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:48:29 +0800 Subject: [PATCH 40/63] feat: use wails binding in frontend --- .github/workflows/ci.yml | 16 - cmd/oneagent/main.go | 2 +- docs/wails-v3-migration-plan.md | 12 +- frontend/bindings/README.md | 5 +- .../internal/binding/providerservice.ts | 8 - frontend/e2e/overview.spec.ts | 271 ---------- frontend/e2e/wizard.spec.ts | 318 ------------ frontend/package.json | 3 +- frontend/playwright.config.ts | 31 -- frontend/src/App.tsx | 2 +- frontend/src/backend/api.ts | 65 +-- frontend/src/backend/wails.test.ts | 351 +++---------- frontend/src/backend/wails.ts | 491 ++++-------------- frontend/src/pages/AgentDetailPage.test.tsx | 4 +- frontend/src/pages/ModelSelectionPage.tsx | 10 +- frontend/src/state/WizardContext.test.tsx | 2 +- frontend/src/types/api.ts | 204 ++------ frontend/vite.config.ts | 5 +- go.mod | 2 +- internal/app/install.go | 7 +- internal/app/install_test.go | 5 +- internal/app/install_wire.go | 5 +- internal/app/status.go | 10 +- internal/binding/services.go | 38 +- internal/binding/services_test.go | 8 +- internal/catalog/manifest.go | 5 +- internal/config/discovery.go | 11 +- internal/config/write.go | 2 +- internal/errors/errors.go | 5 +- internal/install/install.go | 2 +- internal/install/install_test.go | 8 +- internal/install/runtime.go | 9 +- internal/jsonorder/number.go | 6 +- internal/process/process.go | 13 +- internal/profile/write.go | 2 +- internal/provider/client.go | 38 +- internal/provider/provider.go | 8 +- 37 files changed, 327 insertions(+), 1657 deletions(-) delete mode 100644 frontend/e2e/overview.spec.ts delete mode 100644 frontend/e2e/wizard.spec.ts delete mode 100644 frontend/playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58eace3d..061ee04e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,14 +141,6 @@ jobs: working-directory: frontend run: npm run test:coverage - - name: Install Chromium - working-directory: frontend - run: npx playwright install chromium - - - name: Browser E2E - working-directory: frontend - run: npm run e2e - - name: Build native macOS onedir if: runner.os == 'macOS' run: python scripts/build_release.py --channel technical-preview-unsigned --skip-frontend @@ -171,14 +163,6 @@ jobs: path: release/* if-no-files-found: error - - name: Upload browser failures - if: failure() - uses: actions/upload-artifact@v4 - with: - name: browser-failures-${{ matrix.os }} - path: frontend/test-results - if-no-files-found: ignore - - name: Upload macOS cleanroom failures if: failure() && runner.os == 'macOS' uses: actions/upload-artifact@v4 diff --git a/cmd/oneagent/main.go b/cmd/oneagent/main.go index 76f94c2e..68f8cb68 100644 --- a/cmd/oneagent/main.go +++ b/cmd/oneagent/main.go @@ -230,7 +230,7 @@ func runInstall(args []string, stdout, stderr io.Writer) int { if result.Log != "" { _, _ = fmt.Fprintln(stdout, result.Log) } - for _, line := range strings.Split(result.Next, "\n") { + for line := range strings.SplitSeq(result.Next, "\n") { if strings.TrimSpace(line) != "" { _, _ = fmt.Fprintln(stdout, "[oneagent] next: "+line) } diff --git a/docs/wails-v3-migration-plan.md b/docs/wails-v3-migration-plan.md index e4b69831..1c14b9c3 100644 --- a/docs/wails-v3-migration-plan.md +++ b/docs/wails-v3-migration-plan.md @@ -1,6 +1,6 @@ # OneAgent 全量迁移至 Wails v3 规划方案 -- 状态:In Progress(阶段 0-3 退出门禁已通过,阶段 4 进行中,生产入口尚未切换) +- 状态:In Progress(阶段 0-4 退出门禁已通过,阶段 5 尚未开始,桌面发布入口尚未切换) - 日期:2026-07-30 - 目标版本:下一主版本(建议 `0.3.0-dev` 开始迁移) - 适用范围:桌面应用、Go 核心、CLI、前端通信、测试、构建、发布和公开站数据生成 @@ -17,12 +17,12 @@ 日志脱敏和 `agent list/set` 均在 Go 中,`scripts/install.sh` 与 `.ps1` 已改为纯 转发层调用 Go CLI,不再定位 Python;`tests/install_test.sh` 的 13 项契约现在由 Go CLI 通过。CLI 支持中断取消(退出码 130),帮助文本保持 argparse 的双破折号形式。 -- 阶段 4 进行中:四个 service 已注册,`frontend/bindings/` 已生成并提交, - `frontend/src/backend/` 薄 adapter 已按运行时选择 Wails 或 HTTP 传输。尚未删除 - `frontend/src/api/client.ts` 的 fetch 路径,手写后端 DTO 也尚未改为生成类型别名。 +- 阶段 4 已完成:四个 service 通过生成的 Wails binding 暴露;前端只经 + `frontend/src/backend/wails.ts` 调用,已删除 fetch、HTTP fallback 和手写后端 DTO。 + 旧 API/fetch 单测与路由 mock E2E 已删除,Wails `server` build 的浏览器链路留待阶段 5。 -现有 Python 核心、HTTP GUI 和发布流程仍是当前生产路径;在阶段 4-6 的行为等价门禁 -通过前不得删除或旁路它们。 +Python HTTP GUI 和发布流程仍是遗留路径;Wails 前端不再回退到它们。阶段 5-6 的行为 +等价门禁通过前不得删除现有 Python 核心或发布实现。 ## 1. 结论 diff --git a/frontend/bindings/README.md b/frontend/bindings/README.md index 5881026b..dd412a43 100644 --- a/frontend/bindings/README.md +++ b/frontend/bindings/README.md @@ -9,6 +9,5 @@ Regenerate with the pinned CLI and build tag: wails3 generate bindings -f "-tags wails" -ts -i -d frontend/bindings ./cmd/oneagent-desktop ``` -The current production frontend still uses `frontend/src/api/client.ts`; these -bindings are staged for the later service-switch phase. Until that phase is -complete, the Python HTTP path remains authoritative. +`frontend/src/backend/wails.ts` is the only page-facing transport adapter. +React calls these generated bindings directly; there is no HTTP fallback. diff --git a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.ts b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.ts index f0baf1f2..072896bc 100644 --- a/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.ts +++ b/frontend/bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.ts @@ -5,10 +5,6 @@ // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime"; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore: Unused imports -import * as catalog$0 from "../catalog/models.js"; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import * as $models from "./models.js"; @@ -17,10 +13,6 @@ export function ListModels(request: $models.ModelsRequest): $CancellablePromise< return $Call.ByID(1201530915, request); } -export function ListProviders(): $CancellablePromise<{ [_ in string]?: catalog$0.Provider } | null> { - return $Call.ByID(3942954229); -} - export function OpenRegistration(request: $models.OpenRegistrationRequest): $CancellablePromise<$models.OpenRegistrationResponse> { return $Call.ByID(745368128, request); } diff --git a/frontend/e2e/overview.spec.ts b/frontend/e2e/overview.spec.ts deleted file mode 100644 index fd1bdb13..00000000 --- a/frontend/e2e/overview.spec.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { expect, test } from "@playwright/test"; -import type { Page, Route } from "@playwright/test"; - -import type { AgentStatus, StatusResponse } from "../src/types/api"; - -const AUTO_AGENTS = ["codex", "claude-code", "opencode", "kilo-cli", "aider"] as const; - -function agentStatus(over: Partial = {}): AgentStatus { - return { - installed: true, - configured: true, - guideOnly: false, - config: "/tmp/home/.config", - version: "1.0.0", - lockedVersion: "1.0.0", - canInstall: true, - provider: null, - model: null, - baseUrl: null, - updatedAt: null, - detected: null, - ...over, - }; -} - -/** Two Agents on different providers — the state per-Agent config exists for. */ -function divergentStatus(): StatusResponse { - return { - apiVersion: 1, - platform: { os: "macos", arch: "arm64", shell: "bash" }, - capabilities: { canInstall: {}, supportedAgentIds: [...AUTO_AGENTS] }, - agents: { - codex: agentStatus({ - provider: "ppio", - model: "deepseek/deepseek-v3", - baseUrl: "https://api.ppio.com/openai", - version: "0.144.4", - lockedVersion: "0.145.0", - }), - "claude-code": agentStatus({ provider: "novita", model: "qwen/qwen3-max" }), - opencode: agentStatus({ configured: false }), - "kilo-cli": agentStatus({ installed: false, configured: false }), - aider: agentStatus({ installed: false, configured: false }), - cursor: agentStatus({ guideOnly: true, configured: false }), - }, - catalog: [ - ...AUTO_AGENTS.map((id) => ({ - id, - name: id === "claude-code" ? "Claude Code" : id === "kilo-cli" ? "Kilo CLI" : id[0].toUpperCase() + id.slice(1), - group: "auto" as const, - configMode: "auto" as const, - guideOnly: false, - lockedVersion: id === "codex" ? "0.145.0" : "1.0.0", - protocol: id === "codex" ? ("responses" as const) : ("openai" as const), - platforms: ["macos" as const, "linux" as const, "windows" as const], - platformNote: "", - rank: { codex: 1, "claude-code": 2, opencode: 4, "kilo-cli": 8, aider: 9 }[id] ?? 99, - })), - { - id: "cursor", - name: "Cursor", - group: "ide" as const, - configMode: "guide" as const, - guideOnly: true, - lockedVersion: null, - protocol: null, - platforms: ["macos" as const], - platformNote: "按官方文档配置", - rank: 3, - }, - ], - groups: [ - { id: "auto", name: "One-click configurable" }, - { id: "gateway", name: "Gateway agents" }, - { id: "platform", name: "Official account agents" }, - { id: "ide", name: "IDE extensions" }, - ], - providers: { - ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai" }, - novita: { name: "Novita", home: "https://novita.ai/", base_url: "https://api.novita.ai/openai" }, - }, - mirrors: [], - paths: { profile: "/tmp/home/.oneagent/profile.json" }, - backups: {}, - environment: null, - environmentError: null, - profiles: [], - activeProfile: null, - }; -} - -async function fulfillJson(route: Route, body: object, status = 200) { - await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); -} - -async function mockOverview(page: Page) { - const activateBodies: Record[] = []; - const activatePaths: string[] = []; - - await page.route("**/api/status", (route) => fulfillJson(route, divergentStatus())); - await page.route("**/api/probe", (route) => - fulfillJson(route, { - ok: true, - reachable: true, - status: 200, - message: "连接测试通过", - error_code: null, - retryable: false, - }), - ); - await page.route("**/api/agents/*/activate", (route) => { - activatePaths.push(new URL(route.request().url()).pathname); - activateBodies.push(route.request().postDataJSON() as Record); - return fulfillJson(route, { - ok: true, - agent: "codex", - config: "/tmp/home/.codex/config.toml", - provider: "novita", - model: "qwen/qwen3-max", - restart: "Quit any running codex process, then start it again", - next: "source ~/.oneagent/agents/codex.env && codex", - }); - }); - return { activateBodies, activatePaths }; -} - -test("总览按 Agent 分别显示各自的 Provider 与模型", async ({ page }, testInfo) => { - await mockOverview(page); - await page.goto("/#/overview"); - - // The whole point of per-Agent config: two Agents, two providers, at once. - await expect(page.getByText("PPIO", { exact: false }).first()).toBeVisible(); - await expect(page.getByText("qwen/qwen3-max")).toBeVisible(); - await expect(page.getByText("deepseek/deepseek-v3")).toBeVisible(); - - // Version drift reads inline on the Agent's own row: it is deferrable - // maintenance, not an alert that earns a banner at the top of the page. - await expect(page.getByText(/0\.144\.4 → 0\.145\.0/)).toBeVisible(); - await expect(page.getByText("未配置").first()).toBeVisible(); - - await page.screenshot({ path: testInfo.outputPath("overview-per-agent.png"), fullPage: true }); -}); - -test("配置在 Agent 独立页面完成,成功后给出重启指引", async ({ page }, testInfo) => { - const { activateBodies, activatePaths } = await mockOverview(page); - await page.goto("/#/overview"); - - // Configuring moved off the list: a form inside a row grew the list by its - // own height and allowed several rows to sit half-configured at once. - const codexRow = page.locator(".agent-manage-row").first(); - await expect(codexRow.getByLabel("API Key")).toHaveCount(0); - await codexRow.click(); - - await expect(page).toHaveURL(/#\/agents\/codex$/); - await expect(page.getByRole("heading", { name: "Codex" })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath("detail-open.png"), fullPage: true }); - - const apply = page.getByRole("button", { name: /^应用/ }); - await expect(apply).toBeDisabled(); - - await page.getByLabel("API Key").fill("sentinel-detail-secret"); - await page.getByRole("button", { name: "测试连接" }).click(); - await expect(apply).toBeEnabled(); - - await apply.click(); - // An Agent reads its config at startup, so the switch is invisible until the - // process restarts; reporting success without that reads as a failure. - await expect(page.getByText(/Quit any running codex/)).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath("detail-applied.png"), fullPage: true }); - - expect(activatePaths).toEqual(["/api/agents/codex/activate"]); - expect(activateBodies).toHaveLength(1); - - await expect(page.getByLabel("API Key")).toHaveValue(""); - expect(await page.evaluate(() => ({ local: localStorage.length, session: sessionStorage.length }))).toEqual({ - local: 0, - session: 0, - }); -}); - -test("首屏用于 Agent 列表,而不是横幅与提醒", async ({ page }, testInfo) => { - await mockOverview(page); - await page.setViewportSize({ width: 1280, height: 860 }); - await page.goto("/#/overview"); - await page.waitForSelector(".agent-manage-row"); - - // The overview is opened every day, so its first screen belongs to the - // Agents. It used to spend 55% of the viewport on a one-off "ready" banner - // and a version reminder before the list even started. - const list = await page.locator(".agent-manage-list").boundingBox(); - expect(list).not.toBeNull(); - expect(list!.y).toBeLessThan(220); - - // The prominent Agents fit without scrolling. Kilo and Aider now sit behind - // the disclosure, so the count is the rank<=6 set rather than all five - // installable ones. - const rows = page.locator(".agent-manage-row"); - await expect(rows).toHaveCount(4); - const last = await rows.last().boundingBox(); - expect(last!.y + last!.height).toBeLessThan(860); - - // The dismissed banners are gone, not merely moved. - await expect(page.getByText("开发环境已就绪")).toHaveCount(0); - await expect(page.locator(".overview-notice")).toHaveCount(0); - - await page.screenshot({ path: testInfo.outputPath("overview-first-screen.png") }); -}); - -test("侧边栏每一项都能真的打开对应页面", async ({ page }, testInfo) => { - await mockOverview(page); - await page.goto("/#/overview"); - await page.waitForSelector(".agent-manage-row"); - - // Provider and 配置模板 used to point at wizard steps behind SetupGuard, so - // clicking them bounced back to step one and looked like a dead link. - for (const [label, hash, heading] of [ - ["Provider", "#/providers", "Provider"], - ["配置模板", "#/profiles", "配置模板"], - ["环境总览", "#/overview", "环境总览"], - ] as const) { - await page.getByRole("link", { name: label }).click(); - await expect(page).toHaveURL(new RegExp(hash.replace("/", "\\/"))); - await expect(page.getByRole("heading", { name: heading })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`nav-${heading}.png`), fullPage: true }); - } -}); - -test("Provider 页反查出每个服务正在被哪些 Agent 使用", async ({ page }) => { - await mockOverview(page); - await page.goto("/#/providers"); - const ppio = page.getByTestId("provider-ppio"); - await expect(ppio).toContainText("Codex"); - const novita = page.getByTestId("provider-novita"); - await expect(novita).toContainText("Claude Code"); -}); - -test("首屏按流行度排序,Cursor 在 Kilo 与 Aider 之前", async ({ page }, testInfo) => { - await mockOverview(page); - await page.goto("/#/overview"); - await page.waitForSelector(".agent-manage-row"); - - // The list is ranked by how widely an Agent is used, not by whether OneAgent - // can configure it. Cursor used to be hidden in a footnote while Kilo and - // Aider held the top of the page, which misrepresented what people run. - const names = await page.locator(".agent-manage-row strong").allTextContents(); - const order = names.map((n) => n.trim()); - expect(order.slice(0, 3)).toEqual(["Codex", "Claude Code", "Cursor"]); - // Kilo and Aider are not on the first screen at all now; they are behind the - // disclosure, which is the whole point of the ranking. - expect(order).not.toContain("Kilo CLI"); - expect(order).not.toContain("Aider"); - await page.getByRole("button", { name: /其他 Agent/ }).click(); - const all = (await page.locator(".agent-manage-row strong").allTextContents()).map((n) => n.trim()); - expect(all.indexOf("Cursor")).toBeLessThan(all.indexOf("Kilo CLI")); - expect(all.indexOf("Cursor")).toBeLessThan(all.indexOf("Aider")); - - // A guide-only Agent states how it is obtained rather than offering a form. - const cursorRow = page.locator(".agent-manage-row.is-guide").first(); - await expect(cursorRow).toContainText("按官方文档配置"); - await expect(cursorRow.getByLabel("API Key")).toHaveCount(0); - - await page.screenshot({ path: testInfo.outputPath("overview-ranked.png"), fullPage: true }); -}); - -test("已配置的环境直接进入总览而不是向导", async ({ page }) => { - await mockOverview(page); - await page.goto("/"); - // A returning user should not be sent back through first-run setup. - await expect(page).toHaveURL(/#\/overview$/); - await expect(page.getByRole("heading", { name: "环境总览" })).toBeVisible(); -}); diff --git a/frontend/e2e/wizard.spec.ts b/frontend/e2e/wizard.spec.ts deleted file mode 100644 index d82a0256..00000000 --- a/frontend/e2e/wizard.spec.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { expect, test, type Page, type Route } from "@playwright/test"; - -import type { - AgentCatalogItem, - InstallResponse, - ModelsResponse, - ProbeResponse, - StatusResponse, -} from "../src/types/api"; - -// Typed so tsc flags drift between these mocks and the real API contract; the -// protocol values mirror ADAPTER_PROTOCOLS in oneagent/catalog.py. -const catalog: AgentCatalogItem[] = [ - { rank: 1, id: "codex", name: "Codex", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "0.145.0", protocol: "responses", platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 2, id: "claude-code", name: "Claude Code", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "2.1.217", protocol: "anthropic", platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 4, id: "opencode", name: "OpenCode", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "1.18.4", protocol: "openai", platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 8, id: "kilo-cli", name: "Kilo CLI", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "7.4.11", protocol: "openai", platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 9, id: "aider", name: "Aider", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "0.86.2", protocol: "openai", platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 5, id: "openclaw", name: "OpenClaw", group: "gateway", configMode: "guide", guideOnly: true, lockedVersion: null, protocol: null, platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 3, id: "cursor", name: "Cursor", group: "platform", configMode: "guide", guideOnly: true, lockedVersion: null, protocol: null, platforms: ["macos", "linux", "windows"], platformNote: "" }, - { rank: 11, id: "cline", name: "Cline", group: "ide", configMode: "guide", guideOnly: true, lockedVersion: null, protocol: null, platforms: ["macos", "linux", "windows"], platformNote: "" }, -]; - -const agentStatuses = Object.fromEntries( - catalog.map((agent, index) => [ - agent.id, - { - installed: index === 0 || index === 2, - configured: false, - guideOnly: agent.guideOnly, - config: agent.guideOnly ? "" : `/tmp/home/.config/${agent.id}`, - version: index === 0 || index === 2 ? agent.lockedVersion : null, - lockedVersion: agent.lockedVersion, - canInstall: !agent.guideOnly, - provider: null, - model: null, - baseUrl: null, - updatedAt: null, - detected: null, - }, - ]), -); - -function statusPayload(activated: boolean): StatusResponse { - return { - apiVersion: 1, - platform: { os: "macos", arch: "arm64", shell: "bash" }, - capabilities: { canInstall: {}, supportedAgentIds: catalog.map((agent) => agent.id) }, - agents: agentStatuses, - catalog, - groups: [ - { id: "auto", name: "One-click configurable" }, - { id: "gateway", name: "Gateway agents" }, - { id: "platform", name: "Official account agents" }, - { id: "ide", name: "IDE extensions" }, - ], - providers: { - ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai" }, - novita: { name: "Novita", home: "https://novita.ai/", base_url: "https://api.novita.ai/openai" }, - }, - mirrors: [], - paths: { - profile: "/tmp/home/.oneagent/profile.json", - codex_config: "/tmp/home/.codex/config.toml", - "claude-code_config": "/tmp/home/.claude/settings.json", - opencode_config: "/tmp/home/.config/opencode/opencode.jsonc", - }, - backups: {}, - environment: activated - ? { - schema_version: 1, - provider: "ppio", - base_url: "https://api.ppio.com/openai", - model: "deepseek-v3", - config_mode: "provider", - agent_ids: ["codex", "claude-code", "opencode"], - activated_at: "2026-07-22T00:00:00Z", - } - : null, - environmentError: null, - profiles: [], - activeProfile: null, - }; -} - -async function fulfillJson(route: Route, body: object, status = 200) { - await route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); -} - -async function mockApi(page: Page, options: { failFirstInstall?: boolean } = {}) { - let activated = false; - let installCalls = 0; - const installBodies: Record[] = []; - const probeBodies: Record[] = []; - const modelBodies: Record[] = []; - - await page.route("**/api/status", (route) => fulfillJson(route, statusPayload(activated))); - await page.route("**/api/probe", (route) => { - probeBodies.push(route.request().postDataJSON() as Record); - return fulfillJson(route, { ok: true, reachable: true, status: 200, message: "连接测试通过", error_code: null, retryable: false } satisfies ProbeResponse); - }); - await page.route("**/api/models", (route) => { - modelBodies.push(route.request().postDataJSON() as Record); - return fulfillJson(route, { ok: true, reachable: true, status: 200, message: "Found 2 models.", error_code: null, retryable: false, models: ["deepseek-v3", "qwen3-coder"] } satisfies ModelsResponse); - }); - await page.route("**/api/open-register", (route) => fulfillJson(route, { ok: true, url: "https://ppio.com/", message: "opened" })); - await page.route("**/api/install", async (route) => { - installCalls += 1; - const body = route.request().postDataJSON() as Record; - installBodies.push(body); - await new Promise((resolve) => setTimeout(resolve, 120)); - const agents = body.agents as string[]; - if (options.failFirstInstall && installCalls === 1) { - await fulfillJson(route, { - ok: false, - code: 3, - results: agents.map((agent, index) => - index === 0 - ? { agent, status: "failed" as const, error_code: "PREREQUISITE_MISSING", message: "npm is required", retryable: true } - : { agent, status: "configured" as const, retryable: false }, - ), - log: "redacted install log", - next: "", - probe: null, - } satisfies InstallResponse); - return; - } - activated = true; - await fulfillJson(route, { - ok: true, - code: 0, - results: agents.map((agent) => ({ agent, status: body.configure ? ("configured" as const) : ("skipped" as const), retryable: false })), - log: "redacted install log", - next: "source ~/.oneagent/agents/codex.env && codex", - probe: body.configure - ? { ok: true, reachable: true, status: 200, message: "Connection test passed.", error_code: null, retryable: false } - : null, - } satisfies InstallResponse); - }); - - return { installBodies, modelBodies, probeBodies }; -} - -async function expectNoHorizontalOverflow(page: Page) { - const sizes = await page.evaluate(() => ({ body: document.body.scrollWidth, viewport: window.innerWidth })); - expect(sizes.body).toBeLessThanOrEqual(sizes.viewport); -} - -for (const viewport of [ - { width: 1440, height: 900, label: "1440x900" }, - { width: 1280, height: 800, label: "1280x800" }, - { width: 1024, height: 720, label: "1024x720" }, -]) { - test(`完整七页流程 ${viewport.label}`, async ({ page }, testInfo) => { - await page.setViewportSize(viewport); - await mockApi(page); - await page.goto("/#/setup/agents"); - await expect(page.getByRole("heading", { name: "选择 Agent" })).toBeVisible(); - // Ranked, not grouped: leading with the "auto" group used to put Kilo and - // Aider here and fold Cursor and OpenClaw out of sight. - await expect(page.locator(".agent-row input[type=checkbox]").first()).toHaveAttribute( - "aria-label", - "选择 Codex", - ); - const firstScreen = await page.locator(".content-section .agent-row").allTextContents(); - expect(firstScreen.join(" ")).toContain("Cursor"); - expect(firstScreen.join(" ")).not.toContain("Aider"); - // Guide-only rows stay selectable; install_many answers them with a guide. - await expect(page.getByRole("checkbox", { name: "选择 Cursor" })).toBeEnabled(); - await page.screenshot({ path: testInfo.outputPath(`01-agents-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - - await page.getByRole("checkbox", { name: "选择 Codex" }).check(); - await page.getByRole("checkbox", { name: "选择 Claude Code" }).check(); - await page.getByRole("checkbox", { name: "选择 OpenCode" }).check(); - await page.getByRole("button", { name: "继续" }).click(); - await expect(page.getByRole("heading", { name: "配置方式" })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`02-mode-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - - await page.getByRole("button", { name: /配置模型服务/ }).click(); - await page.getByRole("button", { name: "继续" }).click(); - await expect(page.getByRole("heading", { name: "连接模型服务" })).toBeVisible(); - // The endpoint note must aggregate the protocols of the selected Agents. - await expect(page.getByText(/Anthropic Messages \+ OpenAI Chat Completions \+ OpenAI Responses/)).toBeVisible(); - await page.getByLabel("API Key").fill("sentinel-browser-secret"); - await page.getByRole("button", { name: "测试连接" }).click(); - await expect(page.getByText("连接测试通过")).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`03-provider-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - - await page.getByRole("button", { name: "继续选择模型" }).click(); - await expect(page.getByRole("heading", { name: "选择模型" })).toBeVisible(); - await expect(page.getByRole("radio", { name: /deepseek-v3/ })).toBeChecked(); - await page.screenshot({ path: testInfo.outputPath(`04-model-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - - await page.getByRole("button", { name: "继续" }).click(); - await expect(page.getByRole("heading", { name: "确认激活" })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`05-review-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - - await page.getByRole("button", { name: "开始激活" }).click(); - await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`06-result-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - expect(await page.evaluate(() => ({ local: localStorage.length, session: sessionStorage.length }))).toEqual({ local: 0, session: 0 }); - expect(await page.content()).not.toContain("sentinel-browser-secret"); - - await page.getByRole("button", { name: "进入总览" }).click(); - // Arriving at the overview is what matters here. The old assertion looked - // for a "ready" banner, which was a one-off wizard confirmation occupying - // the top of a page the user opens every day; the Agent list is the page. - await expect(page.getByRole("heading", { name: "环境总览" })).toBeVisible(); - await expect(page.locator(".agent-manage-row").first()).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath(`07-overview-${viewport.label}.png`) }); - await expectNoHorizontalOverflow(page); - }); -} - -test("浏览器后退到激活页不重放安装", async ({ page }) => { - const mock = await mockApi(page); - await page.goto("/#/setup/agents"); - await page.getByRole("checkbox", { name: "选择 Codex" }).check(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: /配置模型服务/ }).click(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByLabel("API Key").fill("backtrack-secret"); - await page.getByRole("button", { name: "测试连接" }).click(); - await expect(page.getByText("连接测试通过")).toBeVisible(); - await page.getByRole("button", { name: "继续选择模型" }).click(); - await expect(page.getByRole("heading", { name: "选择模型" })).toBeVisible(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: "开始激活" }).click(); - await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); - await page.getByRole("button", { name: "进入总览" }).click(); - await expect(page.getByRole("heading", { name: "环境总览" })).toBeVisible(); - - await page.goBack(); - // The outcome page must come back as a static summary: same heading, no - // second /api/install fired with the (now cleared) key. - await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); - await page.waitForTimeout(300); - expect(mock.installBodies).toHaveLength(1); -}); - -test("已有账号路径跳过 Provider 和模型", async ({ page }) => { - const mock = await mockApi(page); - await page.goto("/#/setup/agents"); - await page.getByRole("checkbox", { name: "选择 Codex" }).check(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: /使用已有账号或配置/ }).click(); - await page.getByRole("button", { name: "继续" }).click(); - await expect(page.getByRole("heading", { name: "确认激活" })).toBeVisible(); - await expect(page.getByText("已跳过")).toHaveCount(2); - await page.getByRole("button", { name: "开始激活" }).click(); - await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); - expect(mock.installBodies[0]).toMatchObject({ configure: false, api_key: "", agents: ["codex"] }); -}); - -test("切回内置 Provider 后不提交残留 Custom URL", async ({ page }) => { - const mock = await mockApi(page); - await page.goto("/#/setup/agents"); - await page.getByRole("checkbox", { name: "选择 Codex" }).check(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: /配置模型服务/ }).click(); - await page.getByRole("button", { name: "继续" }).click(); - await expect(page.getByRole("heading", { name: "连接模型服务" })).toBeVisible(); - await page.getByRole("radio", { name: "自定义", exact: true }).click(); - await page.getByLabel("Base URL").fill("http://127.0.0.1:9900/openai"); - await page.getByRole("radio", { name: "PPIO", exact: true }).click(); - await page.getByLabel("API Key").fill("provider-switch-secret"); - await page.getByRole("button", { name: "测试连接" }).click(); - await expect(page.getByText("连接测试通过")).toBeVisible(); - await page.getByRole("button", { name: "继续选择模型" }).click(); - await expect(page.getByRole("heading", { name: "选择模型" })).toBeVisible(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: "开始激活" }).click(); - await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); - expect(mock.probeBodies[0]).toMatchObject({ provider: "ppio", api_base_url: "" }); - expect(mock.modelBodies[0]).toMatchObject({ provider: "ppio", api_base_url: "" }); - expect(mock.installBodies[0]).toMatchObject({ provider: "ppio", api_base_url: "" }); -}); - -test("失败 Agent 可以单独重试且不重复成功项", async ({ page }) => { - const mock = await mockApi(page, { failFirstInstall: true }); - await page.goto("/#/setup/agents"); - await page.getByRole("checkbox", { name: "选择 Codex" }).check(); - await page.getByRole("checkbox", { name: "选择 OpenCode" }).check(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: /配置模型服务/ }).click(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByLabel("API Key").fill("retry-secret"); - await page.getByRole("button", { name: "测试连接" }).click(); - await expect(page.getByText("连接测试通过")).toBeVisible(); - await page.getByRole("button", { name: "继续选择模型" }).click(); - await expect(page.getByRole("heading", { name: "选择模型" })).toBeVisible(); - await page.getByRole("button", { name: "继续" }).click(); - await page.getByRole("button", { name: "开始激活" }).click(); - await expect(page.getByRole("heading", { name: "需要处理部分问题" })).toBeVisible(); - await page.getByRole("button", { name: "重试" }).click(); - await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); - expect(mock.installBodies).toHaveLength(2); - expect(mock.installBodies[1]).toMatchObject({ agents: ["codex"], profile_agents: ["codex", "opencode"] }); -}); - -test("未配置时根路径展示着陆页,且入口通向向导", async ({ page }) => { - // The landing page exists for someone who has not configured anything. A - // returning user is sent to their own overview instead (overview.spec.ts - // covers that half); this is the other side of the same decision. - await mockApi(page); - await page.goto("/"); - await expect(page.getByRole("link", { name: /Open OneAgent/ }).first()).toBeVisible(); - // It renders as its own document, not inside the app's window chrome. - await expect(page.locator(".app-window")).toHaveCount(0); - await page.getByRole("link", { name: /Open workspace/ }).first().click(); - await expect(page.getByRole("heading", { name: "选择 Agent" })).toBeVisible(); -}); diff --git a/frontend/package.json b/frontend/package.json index bd9ce6bf..9efeb31b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,8 +9,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:coverage": "vitest run --coverage", - "test:watch": "vitest", - "e2e": "playwright test" + "test:watch": "vitest" }, "dependencies": { "@wailsio/runtime": "3.0.0-alpha2.117", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts deleted file mode 100644 index f46b8cdd..00000000 --- a/frontend/playwright.config.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { defineConfig } from "@playwright/test"; - -const pythonCommand = process.platform === "win32" ? "python ..\\scripts\\gui.py --port 8765 --no-open" : "python3 ../scripts/gui.py --port 8765 --no-open"; - -export default defineConfig({ - testDir: "./e2e", - fullyParallel: false, - timeout: 30_000, - expect: { timeout: 5_000 }, - reporter: [["list"]], - use: { - baseURL: "http://127.0.0.1:8765", - browserName: "chromium", - trace: "retain-on-failure", - screenshot: "only-on-failure", - }, - webServer: { - command: pythonCommand, - cwd: process.cwd(), - url: "http://127.0.0.1:8765/", - reuseExistingServer: true, - timeout: 30_000, - // Surface the Python GUI's own output. Without this a webServer startup - // failure reports only "Timed out waiting 30000ms" with no cause. - stdout: "pipe", - stderr: "pipe", - env: { - ONEAGENT_DISABLE_BROWSER: "1", - }, - }, -}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d3ca026f..198f34bc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,7 +41,7 @@ function SetupGuard({ stage, children }: { stage: "mode" | "provider" | "model" function LandingRoute() { const { state } = useWizard(); - // Wait for the first status read before choosing. The fetch starts in an + // Wait for the first status read before choosing. The binding call starts in an // effect, so the initial render has no status and a state of "idle" rather // than "loading" — treating that as "nothing configured" would show the // landing page to a returning user before their Agents ever loaded. diff --git a/frontend/src/backend/api.ts b/frontend/src/backend/api.ts index bb91c6c2..862a638a 100644 --- a/frontend/src/backend/api.ts +++ b/frontend/src/backend/api.ts @@ -1,63 +1,16 @@ -import { httpApi } from "../api/client"; +import { wailsApi } from "./wails"; import { OneAgentApiError, describeError } from "./errors"; -/** The two frontend transports intentionally share one page-facing surface. */ -export type BackendApi = typeof httpApi; - -export interface BackendLocation { - protocol?: string; - hostname?: string; -} - /** - * Wails v3 uses a custom `wails:` scheme on macOS/Linux and the - * `wails.localhost` virtual host on Windows. The regular browser GUI keeps - * using its HTTP adapter; there is no error-triggered fallback from Wails. + * The single page-facing backend surface. + * + * Every call goes through the generated Wails bindings to a registered Go + * service. There is no HTTP transport and no runtime transport selection: the + * desktop app does not open a business port, so a bridge failure is a real + * failure rather than something to retry over a second channel. */ -export function isWailsRuntime(location: BackendLocation | undefined): boolean { - return location?.protocol === "wails:" || location?.hostname === "wails.localhost"; -} - -export function selectBackend(location: BackendLocation | undefined, nativeApi: BackendApi = lazyWailsApi): BackendApi { - return isWailsRuntime(location) ? nativeApi : httpApi; -} - -type WailsModule = typeof import("./wails"); -let wailsModule: Promise | undefined; - -function loadWails(): Promise { - return (wailsModule ??= import("./wails")); -} +export type BackendApi = typeof wailsApi; -function lazyWailsMethod(key: Key): BackendApi[Key] { - return ((...args: unknown[]) => - loadWails().then(({ wailsApi }) => { - const method = wailsApi[key] as (...values: unknown[]) => unknown; - return method(...args); - })) as BackendApi[Key]; -} - -const lazyWailsApi: BackendApi = { - status: lazyWailsMethod("status"), - probe: lazyWailsMethod("probe"), - models: lazyWailsMethod("models"), - install: lazyWailsMethod("install"), - openRegister: lazyWailsMethod("openRegister"), - activateAgent: lazyWailsMethod("activateAgent"), -}; - -function currentLocation(): BackendLocation | undefined { - if (typeof globalThis === "undefined" || !("location" in globalThis)) { - return undefined; - } - return globalThis.location; -} - -/** - * Single page-facing backend. In jsdom and the Python GUI this resolves to - * `httpApi`; a packaged Wails window resolves to the generated service - * adapter above. - */ -export const api = selectBackend(currentLocation()); +export const api: BackendApi = wailsApi; export { OneAgentApiError, describeError }; diff --git a/frontend/src/backend/wails.test.ts b/frontend/src/backend/wails.test.ts index 3aac7316..b3a6fa73 100644 --- a/frontend/src/backend/wails.test.ts +++ b/frontend/src/backend/wails.test.ts @@ -1,298 +1,85 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { InstallResponse, ModelsResponse, ProbeResponse, ProfileSummary, StatusResponse } from "../types/api"; + const bridge = vi.hoisted(() => ({ - status: vi.fn(), - probe: vi.fn(), - models: vi.fn(), - install: vi.fn(), - register: vi.fn(), - activate: vi.fn(), - profiles: vi.fn(), - saveProfile: vi.fn(), + status: vi.fn(), + probe: vi.fn(), + models: vi.fn(), + install: vi.fn(), + register: vi.fn(), + activate: vi.fn(), + profiles: vi.fn(), + saveProfile: vi.fn(), })); -vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.js", () => ({ - GetStatus: bridge.status, -})); +vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.js", () => ({ GetStatus: bridge.status })); vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.js", () => ({ - Probe: bridge.probe, - ListModels: bridge.models, - OpenRegistration: bridge.register, + Probe: bridge.probe, + ListModels: bridge.models, + OpenRegistration: bridge.register, })); vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.js", () => ({ - Install: bridge.install, - Activate: bridge.activate, + Install: bridge.install, + Activate: bridge.activate, })); vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.js", () => ({ - ListProfiles: bridge.profiles, - SaveProfile: bridge.saveProfile, + ListProfiles: bridge.profiles, + SaveProfile: bridge.saveProfile, })); import { normalizeWailsError, wailsApi } from "./wails"; describe("Wails backend adapter", () => { - afterEach(() => { - vi.resetAllMocks(); - }); - - it("normalizes a generated status payload without exposing bridge-specific nulls", async () => { - bridge.status.mockResolvedValue({ - apiVersion: 1, - platform: { os: "linux", arch: "arm64", shell: "bash" }, - capabilities: { canInstall: { codex: true }, supportedAgentIds: ["codex"] }, - agents: { codex: { installed: true, configured: false, guideOnly: false, config: "", version: null, lockedVersion: "0.1.0", canInstall: true, provider: null, model: null, baseUrl: null, updatedAt: null, detected: null } }, - catalog: [{ id: "codex", name: "Codex", group: "auto", configMode: "auto", guideOnly: false, lockedVersion: "0.1.0", protocol: "responses", platforms: ["linux"], platformNote: "", rank: 1 }], - groups: [{ id: "auto", name: "自动配置" }], - providers: { ppio: { name: "PPIO", home: "https://ppio.com/", base_url: "https://api.ppio.com/openai" } }, - mirrors: [], paths: {}, backups: {}, profiles: null, activeProfile: null, environment: null, environmentError: null, - }); - - await expect(wailsApi.status()).resolves.toMatchObject({ - platform: { os: "linux", arch: "arm64" }, - agents: { codex: { installed: true, detected: null } }, - profiles: [], - }); - }); - - it("maps provider requests to generated snake_case bindings", async () => { - bridge.probe.mockResolvedValue({ ok: true, reachable: true, status: 204, message: "ok", error_code: null, retryable: false, protocol: "responses" }); - await expect(wailsApi.probe({ provider: "custom", apiBaseUrl: "https://proxy.test/v1", apiKey: "secret", model: "m", agents: [] })).resolves.toMatchObject({ ok: true, protocol: "responses" }); - expect(bridge.probe).toHaveBeenCalledWith({ - provider: "custom", api_base_url: "https://proxy.test/v1", api_key: "secret", model: "m", agents: null, - }); - }); - - it("fills binding defaults and preserves install null fields", async () => { - bridge.install.mockResolvedValue({ - ok: true, code: 0, - results: [{ agent: "codex", status: "configured", config: "", installed: false, version: null, lockedVersion: "0.145.0", retryable: false }], - log: "", next: "", probe: null, probes: null, - }); - const result = await wailsApi.install({ - agents: ["codex"], provider: "ppio", api_base_url: "", api_key: "secret", model: "m", - configure: true, install_agent: false, skip_test: true, - }); - expect(result.results[0]).toMatchObject({ installed: false, version: null, lockedVersion: "0.145.0" }); - expect(bridge.install).toHaveBeenCalledWith(expect.objectContaining({ - agents: ["codex"], profile_agents: null, profile_id: "", timeout: 180, locked_version: false, latest: false, - })); - }); - - it("maps activation and registration calls", async () => { - bridge.activate.mockResolvedValue({ ok: true, agent: "codex", config: "/c", provider: "ppio", model: "m", restart: "r", next: "n" }); - bridge.register.mockResolvedValue({ ok: true, url: "https://ppio.com/", message: "opened" }); - await wailsApi.activateAgent("codex", { provider: "ppio", apiBaseUrl: "", apiKey: "secret", model: "m" }); - await wailsApi.openRegister("ppio", []); - expect(bridge.activate).toHaveBeenCalledWith(expect.objectContaining({ agent_id: "codex", profile_id: "", small_fast_model: "" })); - expect(bridge.register).toHaveBeenCalledWith({ provider: "ppio", agents: null }); - }); - - // The generated bindings type these payloads, but the adapter is the last - // boundary before React state: a missing or wrongly typed field has to become - // a safe default rather than an undefined that renders as "undefined". - it("defaults every field when the bridge returns an empty payload", async () => { - bridge.status.mockResolvedValue({}); - await expect(wailsApi.status()).resolves.toMatchObject({ - // The wire contract's only version so far; an absent field means v1 - // rather than an unusable 0. - apiVersion: 1, - platform: { os: "linux", arch: "", shell: "" }, - capabilities: { canInstall: {}, supportedAgentIds: [] }, - agents: {}, - catalog: [], - groups: [], - providers: {}, - mirrors: [], - paths: {}, - backups: {}, - profiles: [], - activeProfile: null, - environment: null, - environmentError: null, - }); - }); - - it("coerces unknown enum values and wrongly typed fields to safe defaults", async () => { - bridge.status.mockResolvedValue({ - apiVersion: "not-a-number", - platform: { os: "plan9", arch: 7, shell: null }, - capabilities: { canInstall: { codex: "yes", opencode: true }, supportedAgentIds: ["codex", 9] }, - agents: { - codex: { - installed: "yes", configured: null, guideOnly: 0, config: 5, - version: 1, lockedVersion: "0.1.0", canInstall: true, - provider: undefined, model: {}, baseUrl: [], updatedAt: "t", - detected: { baseUrl: 1, model: null, managedByOneAgent: "true", unreadable: "why" }, - }, - }, - catalog: [{ id: "x", name: "X", group: "nope", configMode: "guide", guideOnly: true, lockedVersion: null, protocol: "smoke-signals", platforms: ["freebsd"], platformNote: null, rank: "3" }], - groups: [{ id: "auto", name: "自动配置" }], - mirrors: [{ id: "m", name: "M", registry: "r", upstream: "u", note: "n" }], - paths: { codex: "/c", bogus: 5 }, - backups: { codex: true, bogus: "yes" }, - profiles: "not-an-array", - }); - - const status = await wailsApi.status(); - // An unrecognized OS must not leak through as a value the UI cannot render. - expect(status.platform).toMatchObject({ os: "linux", arch: "", shell: "" }); - expect(status.apiVersion).toBe(1); - expect(status.capabilities.canInstall).toEqual({ opencode: true }); - expect(status.capabilities.supportedAgentIds).toEqual(["codex"]); - expect(status.agents.codex).toMatchObject({ - installed: false, configured: false, guideOnly: false, config: "", - version: null, provider: null, model: null, baseUrl: null, - }); - expect(status.agents.codex.detected).toMatchObject({ - baseUrl: "", model: "", managedByOneAgent: false, unreadable: "why", - }); - expect(status.catalog[0]).toMatchObject({ group: "auto", configMode: "guide", protocol: null, rank: 0 }); - expect(status.paths).toEqual({ codex: "/c" }); - expect(status.backups).toEqual({ codex: true }); - expect(status.profiles).toEqual([]); - }); - - it("keeps a per-protocol probe map and drops protocols it does not know", async () => { - bridge.probe.mockResolvedValue({ - ok: false, reachable: true, status: 404, message: "no", error_code: "PROTOCOL_UNSUPPORTED", retryable: false, - protocols: { - responses: { ok: true, reachable: true, status: 200, message: "ok", error_code: null, retryable: false, protocol: "responses" }, - anthropic: { ok: false, reachable: true, status: 404, message: "no", error_code: "PROTOCOL_UNSUPPORTED", retryable: false }, - telepathy: { ok: true, reachable: true, status: 200, message: "ok", error_code: null, retryable: false }, - }, - }); - const probe = await wailsApi.probe({ provider: "ppio", apiBaseUrl: "", apiKey: "secret", model: "m", agents: ["codex"] }); - expect(Object.keys(probe.protocols ?? {}).sort()).toEqual(["anthropic", "responses"]); - expect(probe.protocols?.responses).toMatchObject({ ok: true, protocol: "responses" }); - expect(bridge.probe).toHaveBeenCalledWith(expect.objectContaining({ agents: ["codex"] })); - }); - - it("returns a model list and a probe verdict from one call", async () => { - bridge.models.mockResolvedValue({ - ok: true, reachable: true, status: 200, message: "", error_code: null, retryable: false, - models: ["model-a", 7, "model-b"], - }); - const models = await wailsApi.models({ provider: "ppio", apiBaseUrl: "", apiKey: "secret" }); - expect(models.models).toEqual(["model-a", "model-b"]); - expect(models).toMatchObject({ ok: true, status: 200 }); - }); - - it("omits install fields the backend left out and keeps explicit nulls", async () => { - // Field presence is the contract: an absent version means "not reported", - // a null version means "reported as unknown". Collapsing them would change - // what the wizard shows. - bridge.install.mockResolvedValue({ - ok: false, code: 4, - results: [ - { agent: "codex", status: "failed", code: 4, error_code: "AGENT_INSTALL_FAILED", message: "npm failed", retryable: true }, - { agent: "aider", status: "guide-only", message: "install from docs", retryable: false }, - { agent: "opencode", status: "installed", installed: true, version: null, lockedVersion: null, registry: "npmmirror", config: "/c", retryable: false }, - ], - log: "log", next: "next", - probe: { ok: true, reachable: true, status: 200, message: "", error_code: null, retryable: false }, - probes: { responses: { ok: true, reachable: true, status: 200, message: "", error_code: null, retryable: false } }, - }); - const result = await wailsApi.install({ - agents: ["codex"], provider: "ppio", api_key: "secret", model: "m", - configure: true, install_agent: true, skip_test: false, - profile_agents: ["codex"], profile_id: "team", registry: "npmmirror", - locked_version: true, timeout: 90, small_fast_model: "fast", - }); - expect(result.results[0]).toEqual({ agent: "codex", status: "failed", code: 4, error_code: "AGENT_INSTALL_FAILED", message: "npm failed", retryable: true }); - expect("version" in result.results[1]).toBe(false); - expect("installed" in result.results[1]).toBe(false); - expect(result.results[2]).toMatchObject({ version: null, lockedVersion: null, registry: "npmmirror" }); - expect(result.probe).toMatchObject({ ok: true }); - expect(result.probes?.responses).toMatchObject({ ok: true }); - expect(bridge.install).toHaveBeenCalledWith(expect.objectContaining({ - profile_agents: ["codex"], profile_id: "team", registry: "npmmirror", - locked_version: true, timeout: 90, small_fast_model: "fast", - })); - }); - - it("treats a missing install envelope as an empty result rather than throwing", async () => { - bridge.install.mockResolvedValue({ ok: true, code: 0, results: null, log: null, next: null, probe: undefined, probes: null }); - const result = await wailsApi.install({ - agents: ["codex"], provider: "ppio", api_key: "secret", model: "m", - configure: true, install_agent: false, skip_test: true, - }); - expect(result).toMatchObject({ results: [], log: "", next: "", probe: null }); - expect("probes" in result).toBe(false); - }); - - it("projects profiles without ever carrying a credential field", async () => { - bridge.profiles.mockResolvedValue([ - { id: "team", label: "Team", provider: "ppio", baseUrl: null, model: "m", agentIds: ["codex"], activatedAt: "t", hasKey: true }, - { id: "solo", provider: "novita", baseUrl: "https://api.novita.ai/openai", model: null, agentIds: null, activatedAt: null, hasKey: false }, - ]); - const profiles = await wailsApi.listProfiles(); - // A label defaults to the id rather than rendering as blank. - expect(profiles[1]).toEqual({ - id: "solo", label: "solo", provider: "novita", - baseUrl: "https://api.novita.ai/openai", model: null, agentIds: [], - activatedAt: null, hasKey: false, - }); - expect(profiles[0]).toMatchObject({ hasKey: true }); - for (const profile of profiles) { - expect(Object.keys(profile)).not.toContain("apiKey"); - expect(Object.keys(profile)).not.toContain("api_key"); - } - }); - - it("treats an absent profile list as empty", async () => { - bridge.profiles.mockResolvedValue(null); - await expect(wailsApi.listProfiles()).resolves.toEqual([]); - }); - - it("sends a saved profile as snake_case and returns the public summary", async () => { - const save = bridge.saveProfile; - save.mockResolvedValue({ id: "team", label: "Team", provider: "ppio", baseUrl: null, model: "m", agentIds: ["codex"], activatedAt: "t", hasKey: true }); - const summary = await wailsApi.saveProfile({ - id: "team", label: "Team", provider: "ppio", apiBaseUrl: "", - apiKey: "secret", model: "m", configMode: "provider", agentIds: ["codex"], - }); - expect(summary).toMatchObject({ id: "team", hasKey: true }); - expect(save).toHaveBeenCalledWith({ - id: "team", label: "Team", provider: "ppio", api_base_url: "", - api_key: "secret", model: "m", config_mode: "provider", agent_ids: ["codex"], - }); - }); - - it("reads a structured cause that arrived as a JSON string", async () => { - // MarshalError may hand the cause across as text; the stable error code - // has to survive that, and unparseable text must not become the message. - expect(normalizeWailsError({ cause: '{"error_code":"TIMEOUT","message":"probe timed out","status":504,"retryable":true}' })).toMatchObject({ - message: "probe timed out", code: "TIMEOUT", status: 504, retryable: true, - }); - expect(normalizeWailsError({ cause: "not json at all" })).toMatchObject({ - message: "无法调用本机 OneAgent 服务", code: "INTERNAL_ERROR", status: 500, - }); - const partial = normalizeWailsError({ cause: { message: "partial" } }); - expect(partial).toMatchObject({ message: "partial", code: "INTERNAL_ERROR", status: 500, retryable: true }); - }); - - it("passes an already-normalized error through unchanged", async () => { - const { OneAgentApiError } = await import("./errors"); - const original = new OneAgentApiError("already mapped", "INVALID_REQUEST", false, 400); - expect(normalizeWailsError(original)).toBe(original); - }); - - it("wraps a rejected binding call and keeps the API key out of the message", async () => { - bridge.status.mockRejectedValue({ cause: { error_code: "PROVIDER_UNREACHABLE", message: "cannot reach provider", status: 502, retryable: true } }); - await expect(wailsApi.status()).rejects.toMatchObject({ code: "PROVIDER_UNREACHABLE", retryable: true }); - - bridge.probe.mockRejectedValue(new Error("dial tcp: secret-key-value in transport detail")); - const failure = await wailsApi.probe({ provider: "ppio", apiBaseUrl: "", apiKey: "secret-key-value", model: "m" }).catch((error) => error); - expect(failure.message).not.toContain("secret-key-value"); - expect(failure.code).toBe("INTERNAL_ERROR"); - }); - - it("restores a structured Go error cause and hides unknown bridge failures", () => { - expect(normalizeWailsError({ name: "RuntimeError", cause: { error_code: "API_KEY_REJECTED", message: "key rejected", status: 401, retryable: false } })).toMatchObject({ - message: "key rejected", code: "API_KEY_REJECTED", status: 401, retryable: false, - }); - expect(normalizeWailsError(new Error("internal implementation detail"))).toMatchObject({ - message: "无法调用本机 OneAgent 服务", code: "INTERNAL_ERROR", retryable: true, - }); - }); + afterEach(() => vi.resetAllMocks()); + + it("forwards the page-facing calls to generated bindings", async () => { + const status = { + apiVersion: 1, + platform: { os: "linux", arch: "arm64", shell: "bash" }, + capabilities: { canInstall: {}, supportedAgentIds: [] }, + agents: {}, catalog: [], groups: [], providers: {}, mirrors: [], paths: {}, backups: {}, + profiles: [], activeProfile: null, environment: null, environmentError: null, + } satisfies StatusResponse; + const probe = { ok: true, reachable: true, status: 204, message: "ok", error_code: null, retryable: false } satisfies ProbeResponse; + const models = { ...probe, models: ["model-a"] } satisfies ModelsResponse; + const install = { ok: true, code: 0, results: [], log: "", next: "", probe: null } satisfies InstallResponse; + const profile = { id: "team", label: "Team", provider: "ppio", baseUrl: null, model: "m", agentIds: ["codex"], activatedAt: null, hasKey: true } satisfies ProfileSummary; + + bridge.status.mockResolvedValue(status); + bridge.probe.mockResolvedValue(probe); + bridge.models.mockResolvedValue(models); + bridge.install.mockResolvedValue(install); + bridge.register.mockResolvedValue({ ok: true, url: "https://ppio.com/", message: "opened" }); + bridge.activate.mockResolvedValue({ ok: true, agent: "codex", config: "/c", provider: "ppio", model: "m", restart: "restart", next: "next" }); + bridge.profiles.mockResolvedValue([profile]); + bridge.saveProfile.mockResolvedValue(profile); + + await expect(wailsApi.status()).resolves.toBe(status); + await expect(wailsApi.probe({ provider: "custom", apiBaseUrl: "https://proxy.test/v1", apiKey: "secret", model: "m", agents: [] })).resolves.toBe(probe); + await expect(wailsApi.models({ provider: "ppio", apiBaseUrl: "", apiKey: "secret" })).resolves.toBe(models); + await expect(wailsApi.install({ agents: ["codex"], provider: "ppio", api_key: "secret", model: "m", configure: true, install_agent: false, skip_test: true })).resolves.toBe(install); + await wailsApi.openRegister("ppio", []); + await wailsApi.activateAgent("codex", { provider: "ppio", apiBaseUrl: "", apiKey: "secret", model: "m" }); + await expect(wailsApi.listProfiles()).resolves.toEqual([profile]); + await expect(wailsApi.saveProfile({ id: "team", label: "Team", provider: "ppio", apiBaseUrl: "", apiKey: "secret", model: "m", configMode: "provider", agentIds: ["codex"] })).resolves.toBe(profile); + + expect(bridge.probe).toHaveBeenCalledWith({ provider: "custom", api_base_url: "https://proxy.test/v1", api_key: "secret", model: "m", agents: null }); + expect(bridge.install).toHaveBeenCalledWith(expect.objectContaining({ agents: ["codex"], profile_agents: null, timeout: 180, latest: false })); + expect(bridge.register).toHaveBeenCalledWith({ provider: "ppio", agents: null }); + expect(bridge.activate).toHaveBeenCalledWith(expect.objectContaining({ agent_id: "codex", profile_id: "", small_fast_model: "" })); + expect(bridge.saveProfile).toHaveBeenCalledWith(expect.objectContaining({ api_base_url: "", api_key: "secret", agent_ids: ["codex"] })); + }); + + it("restores structured Wails errors without exposing raw bridge details", async () => { + expect(normalizeWailsError({ cause: { error_code: "API_KEY_REJECTED", message: "key rejected", status: 401, retryable: false } })).toMatchObject({ + message: "key rejected", code: "API_KEY_REJECTED", status: 401, retryable: false, + }); + expect(normalizeWailsError({ cause: '{"error_code":"TIMEOUT","message":"probe timed out","status":504,"retryable":true}' })).toMatchObject({ + message: "probe timed out", code: "TIMEOUT", status: 504, retryable: true, + }); + expect(normalizeWailsError(new Error("secret-key-value"))).toMatchObject({ + message: "无法调用本机 OneAgent 服务", code: "INTERNAL_ERROR", status: 500, retryable: true, + }); + }); }); diff --git a/frontend/src/backend/wails.ts b/frontend/src/backend/wails.ts index f5c6490d..ff676c42 100644 --- a/frontend/src/backend/wails.ts +++ b/frontend/src/backend/wails.ts @@ -2,408 +2,135 @@ import * as AgentService from "../../bindings/github.com/MaimoryLab/OneAgent/int import * as ProfileService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.js"; import * as ProviderService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.js"; import * as StatusService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.js"; -import { OneAgentApiError } from "./errors"; import type { - ActivateAgentResponse, - AgentCatalogItem, - AgentInstallResult, - AgentStatus, - DetectedConfig, - EnvironmentProfile, - InstallRequest, - InstallResponse, - ModelsResponse, - PlatformId, - AgentGroupId, - ProbeResponse, - ProfileSummary, - ProviderId, - StatusResponse, + ActivateAgentResponse, + InstallRequest, + InstallResponse, + ModelsResponse, + OpenRegistrationResponse, + ProbeResponse, + ProfileSummary, + ProviderId, + StatusResponse, } from "../types/api"; +import { OneAgentApiError } from "./errors"; export { OneAgentApiError, describeError } from "./errors"; -type RecordValue = Record; - -function record(value: unknown): RecordValue { - return value !== null && typeof value === "object" ? (value as RecordValue) : {}; -} - -function stringValue(value: unknown, fallback = ""): string { - return typeof value === "string" ? value : fallback; -} - -function nullableString(value: unknown): string | null { - return typeof value === "string" ? value : null; -} - -function optionalNullableString(source: RecordValue, key: string): string | null | undefined { - if (!(key in source)) { - return undefined; - } - return nullableString(source[key]); -} - -function booleanValue(value: unknown, fallback = false): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function numberValue(value: unknown, fallback = 0): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -function stringArray(value: unknown): string[] { - return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; -} - -function stringMap(value: unknown): Record { - const source = record(value); - return Object.fromEntries( - Object.entries(source).filter((entry): entry is [string, string] => typeof entry[1] === "string"), - ); -} - -function booleanMap(value: unknown): Record { - const source = record(value); - return Object.fromEntries( - Object.entries(source).filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean"), - ); -} - -function protocolId(value: unknown): ProbeResponse["protocol"] { - return value === "openai" || value === "anthropic" || value === "responses" ? value : undefined; -} - -function platformId(value: unknown): PlatformId { - return value === "macos" || value === "windows" || value === "linux" ? value : "linux"; -} - -function groupId(value: unknown): AgentGroupId { - return value === "auto" || value === "gateway" || value === "platform" || value === "ide" ? value : "auto"; -} - -function normalizeDetected(value: unknown): DetectedConfig | null { - if (value === null || value === undefined) { - return null; - } - const source = record(value); - return { - baseUrl: stringValue(source.baseUrl), - model: stringValue(source.model), - managedByOneAgent: booleanValue(source.managedByOneAgent), - unreadable: nullableString(source.unreadable), - }; -} - -function normalizeAgentStatus(value: unknown): AgentStatus { - const source = record(value); - return { - installed: booleanValue(source.installed), - configured: booleanValue(source.configured), - guideOnly: booleanValue(source.guideOnly), - config: stringValue(source.config), - version: nullableString(source.version), - lockedVersion: nullableString(source.lockedVersion), - canInstall: booleanValue(source.canInstall), - provider: nullableString(source.provider), - model: nullableString(source.model), - baseUrl: nullableString(source.baseUrl), - updatedAt: nullableString(source.updatedAt), - detected: normalizeDetected(source.detected), - }; -} - -function normalizeCatalogItem(value: unknown): AgentCatalogItem { - const source = record(value); - const configMode = source.configMode; - return { - id: stringValue(source.id), - name: stringValue(source.name), - group: groupId(source.group), - configMode: configMode === "guide" ? "guide" : "auto", - guideOnly: booleanValue(source.guideOnly), - lockedVersion: nullableString(source.lockedVersion), - protocol: protocolId(source.protocol) ?? null, - platforms: stringArray(source.platforms) as AgentCatalogItem["platforms"], - platformNote: stringValue(source.platformNote), - rank: numberValue(source.rank), - }; -} - -function normalizeStatus(value: unknown): StatusResponse { - const source = record(value); - const rawAgents = record(source.agents); - const agents: Record = {}; - for (const [id, item] of Object.entries(rawAgents)) { - agents[id] = normalizeAgentStatus(item); - } - const rawProviders = record(source.providers); - const providers: StatusResponse["providers"] = {}; - for (const [id, item] of Object.entries(rawProviders)) { - const provider = record(item); - providers[id] = { - name: stringValue(provider.name), - home: stringValue(provider.home), - base_url: stringValue(provider.base_url), - ...(typeof provider.anthropic_base_url === "string" ? { anthropic_base_url: provider.anthropic_base_url } : {}), - }; - } - const rawMirrors = Array.isArray(source.mirrors) ? source.mirrors : []; - const mirrors = rawMirrors.map((item) => { - const mirror = record(item); - return { - id: stringValue(mirror.id), - name: stringValue(mirror.name), - registry: stringValue(mirror.registry), - upstream: stringValue(mirror.upstream), - note: stringValue(mirror.note), - }; - }); - const rawGroups = Array.isArray(source.groups) ? source.groups : []; - const groups = rawGroups.map((item) => { - const group = record(item); - return { - id: groupId(group.id), - name: stringValue(group.name), - }; - }); - const environment = source.environment && typeof source.environment === "object" - ? (source.environment as EnvironmentProfile) - : null; - return { - apiVersion: numberValue(source.apiVersion, 1), - platform: { - os: platformId(record(source.platform).os), - arch: stringValue(record(source.platform).arch), - shell: stringValue(record(source.platform).shell), - }, - capabilities: { - canInstall: booleanMap(record(source.capabilities).canInstall), - supportedAgentIds: stringArray(record(source.capabilities).supportedAgentIds), - }, - agents, - catalog: (Array.isArray(source.catalog) ? source.catalog : []).map(normalizeCatalogItem), - groups, - providers, - mirrors, - paths: stringMap(source.paths), - backups: booleanMap(source.backups), - environment, - environmentError: nullableString(source.environmentError), - profiles: Array.isArray(source.profiles) ? source.profiles.map(normalizeProfile) : [], - activeProfile: nullableString(source.activeProfile), - }; -} - -function normalizeProfile(value: unknown): ProfileSummary { - const source = record(value); - return { - id: stringValue(source.id), - label: stringValue(source.label, stringValue(source.id)), - provider: stringValue(source.provider), - baseUrl: nullableString(source.baseUrl), - model: nullableString(source.model), - agentIds: stringArray(source.agentIds), - activatedAt: nullableString(source.activatedAt), - hasKey: booleanValue(source.hasKey), - }; -} - -function normalizeProbe(value: unknown): ProbeResponse { - const source = record(value); - const protocols: Partial, ProbeResponse>> = {}; - const rawProtocols = record(source.protocols); - for (const [key, item] of Object.entries(rawProtocols)) { - if (key === "openai" || key === "anthropic" || key === "responses") { - protocols[key] = normalizeProbe(item); - } - } - return { - ok: booleanValue(source.ok), - reachable: booleanValue(source.reachable), - status: numberValue(source.status), - message: stringValue(source.message), - error_code: nullableString(source.error_code), - retryable: booleanValue(source.retryable), - ...(protocolId(source.protocol) ? { protocol: protocolId(source.protocol) } : {}), - ...(Object.keys(protocols).length ? { protocols } : {}), - }; -} - -function normalizeInstallResult(value: unknown): AgentInstallResult { - const source = record(value); - return { - agent: stringValue(source.agent), - status: stringValue(source.status) as AgentInstallResult["status"], - ...(typeof source.installed === "boolean" ? { installed: source.installed } : {}), - ...("version" in source ? { version: optionalNullableString(source, "version") } : {}), - ...("lockedVersion" in source ? { lockedVersion: optionalNullableString(source, "lockedVersion") } : {}), - ...(typeof source.registry === "string" ? { registry: source.registry } : {}), - ...(typeof source.config === "string" ? { config: source.config } : {}), - ...(typeof source.code === "number" ? { code: source.code } : {}), - ...(typeof source.error_code === "string" ? { error_code: source.error_code } : {}), - ...(typeof source.message === "string" ? { message: source.message } : {}), - retryable: booleanValue(source.retryable), - }; -} +type ErrorCause = Record; -function normalizeInstall(value: unknown): InstallResponse { - const source = record(value); - const rawProbes = record(source.probes); - const probes: Partial, ProbeResponse>> = {}; - for (const [key, item] of Object.entries(rawProbes)) { - if (key === "openai" || key === "anthropic" || key === "responses") { - probes[key] = normalizeProbe(item); - } - } - return { - ok: booleanValue(source.ok), - code: numberValue(source.code), - results: Array.isArray(source.results) ? source.results.map(normalizeInstallResult) : [], - log: stringValue(source.log), - next: stringValue(source.next), - probe: source.probe === null || source.probe === undefined ? null : normalizeProbe(source.probe), - ...(Object.keys(probes).length ? { probes } : {}), - }; +function causeOf(error: unknown): ErrorCause { + const cause = error && typeof error === "object" ? (error as { cause?: unknown }).cause : undefined; + if (typeof cause === "string") { + try { + const parsed: unknown = JSON.parse(cause); + return parsed && typeof parsed === "object" ? (parsed as ErrorCause) : {}; + } catch { + return {}; + } + } + return cause && typeof cause === "object" ? (cause as ErrorCause) : {}; } -function normalizeModels(value: unknown): ModelsResponse { - const source = record(value); - return { - ...normalizeProbe(source), - models: stringArray(source.models), - }; +function stringValue(value: unknown, fallback: string): string { + return typeof value === "string" ? value : fallback; } -function parseCause(value: unknown): RecordValue { - if (typeof value === "string") { - try { - return record(JSON.parse(value)); - } catch { - return {}; - } - } - return record(value); +function numberValue(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; } /** Convert a Wails bridge rejection into the stable frontend error contract. */ export function normalizeWailsError(error: unknown): OneAgentApiError { - if (error instanceof OneAgentApiError) { - return error; - } - const source = record(error); - const cause = parseCause(source.cause); - const hasCause = Object.keys(cause).length > 0; - const message = hasCause - ? stringValue(cause.message, "OneAgent request failed") - : "无法调用本机 OneAgent 服务"; - const code = hasCause ? stringValue(cause.error_code, "INTERNAL_ERROR") : "INTERNAL_ERROR"; - const status = hasCause ? numberValue(cause.status, 500) : 500; - const retryable = hasCause ? booleanValue(cause.retryable, true) : true; - return new OneAgentApiError(message, code, retryable, status); + if (error instanceof OneAgentApiError) return error; + const cause = causeOf(error); + const known = Object.keys(cause).length > 0; + return new OneAgentApiError( + known ? stringValue(cause.message, "OneAgent request failed") : "无法调用本机 OneAgent 服务", + known ? stringValue(cause.error_code, "INTERNAL_ERROR") : "INTERNAL_ERROR", + known ? cause.retryable === true : true, + known ? numberValue(cause.status, 500) : 500, + ); } async function call(operation: () => PromiseLike): Promise { - try { - return await operation(); - } catch (error) { - throw normalizeWailsError(error); - } + try { + return await operation(); + } catch (error) { + throw normalizeWailsError(error); + } } export const wailsApi = { - status: () => call(() => StatusService.GetStatus()).then(normalizeStatus), - probe: (input: { - provider: ProviderId; - apiBaseUrl: string; - apiKey: string; - model: string; - agents?: string[]; - }) => - call(() => - ProviderService.Probe({ - provider: input.provider, - api_base_url: input.apiBaseUrl, - api_key: input.apiKey, - model: input.model, - agents: input.agents?.length ? input.agents : null, - }), - ).then(normalizeProbe), - models: (input: { provider: ProviderId; apiBaseUrl: string; apiKey: string }) => - call(() => - ProviderService.ListModels({ - provider: input.provider, - api_base_url: input.apiBaseUrl, - api_key: input.apiKey, - }), - ).then(normalizeModels), - install: (input: InstallRequest) => - call(() => - AgentService.Install({ - agents: input.agents ?? null, - profile_agents: input.profile_agents ?? null, - provider: input.provider, - api_base_url: input.api_base_url ?? "", - api_key: input.api_key, - model: input.model, - small_fast_model: input.small_fast_model ?? "", - profile_id: input.profile_id ?? "", - configure: input.configure, - install_agent: input.install_agent, - locked_version: input.locked_version ?? false, - latest: input.latest ?? false, - skip_test: input.skip_test, - registry: input.registry ?? "", - timeout: input.timeout ?? 180, - }), - ).then(normalizeInstall), - openRegister: (provider: Exclude, agents: string[]) => - call(() => ProviderService.OpenRegistration({ provider, agents: agents.length ? agents : null })), - activateAgent: ( - agentId: string, - input: { - provider: ProviderId; - apiBaseUrl: string; - apiKey: string; - model: string; - profileId?: string; - smallFastModel?: string; - }, - ) => - call(() => - AgentService.Activate({ - agent_id: agentId, - provider: input.provider, - api_base_url: input.apiBaseUrl, - api_key: input.apiKey, - model: input.model, - profile_id: input.profileId ?? "", - small_fast_model: input.smallFastModel ?? "", - }), - ) as Promise, - listProfiles: () => call(() => ProfileService.ListProfiles()).then((value) => (value ?? []).map(normalizeProfile)), - saveProfile: (input: { - id: string; - label: string; - provider: ProviderId; - apiBaseUrl: string; - apiKey: string; - model: string; - configMode: string; - agentIds: string[]; - }) => - call(() => - ProfileService.SaveProfile({ - id: input.id, - label: input.label, - provider: input.provider, - api_base_url: input.apiBaseUrl, - api_key: input.apiKey, - model: input.model, - config_mode: input.configMode, - agent_ids: input.agentIds, - }), - ).then(normalizeProfile), + status: (): Promise => call(() => StatusService.GetStatus()) as Promise, + probe: (input: { provider: ProviderId; apiBaseUrl: string; apiKey: string; model: string; agents?: string[] }): Promise => + call(() => ProviderService.Probe({ + provider: input.provider, + api_base_url: input.apiBaseUrl, + api_key: input.apiKey, + model: input.model, + agents: input.agents?.length ? input.agents : null, + })) as Promise, + models: (input: { provider: ProviderId; apiBaseUrl: string; apiKey: string }): Promise => + call(() => ProviderService.ListModels({ + provider: input.provider, + api_base_url: input.apiBaseUrl, + api_key: input.apiKey, + })) as Promise, + install: (input: InstallRequest): Promise => + call(() => AgentService.Install({ + agents: input.agents, + profile_agents: input.profile_agents ?? null, + provider: input.provider, + api_base_url: input.api_base_url ?? "", + api_key: input.api_key, + model: input.model, + small_fast_model: input.small_fast_model ?? "", + profile_id: input.profile_id ?? "", + configure: input.configure, + install_agent: input.install_agent, + locked_version: input.locked_version ?? false, + latest: input.latest ?? false, + skip_test: input.skip_test, + registry: input.registry ?? "", + timeout: input.timeout ?? 180, + })) as Promise, + openRegister: (provider: Exclude, agents: string[]): Promise => + call(() => ProviderService.OpenRegistration({ provider, agents: agents.length ? agents : null })) as Promise, + activateAgent: ( + agentId: string, + input: { provider: ProviderId; apiBaseUrl: string; apiKey: string; model: string; profileId?: string; smallFastModel?: string }, + ): Promise => + call(() => AgentService.Activate({ + agent_id: agentId, + provider: input.provider, + api_base_url: input.apiBaseUrl, + api_key: input.apiKey, + model: input.model, + profile_id: input.profileId ?? "", + small_fast_model: input.smallFastModel ?? "", + })) as Promise, + listProfiles: (): Promise => call(() => ProfileService.ListProfiles()) as Promise, + saveProfile: (input: { + id: string; + label: string; + provider: ProviderId; + apiBaseUrl: string; + apiKey: string; + model: string; + configMode: string; + agentIds: string[]; + }): Promise => + call(() => ProfileService.SaveProfile({ + id: input.id, + label: input.label, + provider: input.provider, + api_base_url: input.apiBaseUrl, + api_key: input.apiKey, + model: input.model, + config_mode: input.configMode, + agent_ids: input.agentIds, + })) as Promise, }; export type WailsApi = typeof wailsApi; diff --git a/frontend/src/pages/AgentDetailPage.test.tsx b/frontend/src/pages/AgentDetailPage.test.tsx index 9dcc14e0..eb04d64b 100644 --- a/frontend/src/pages/AgentDetailPage.test.tsx +++ b/frontend/src/pages/AgentDetailPage.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; +import { api } from "../backend/api"; import type { StatusResponse } from "../types/api"; import { AgentDetailPage } from "./AgentDetailPage"; @@ -131,7 +132,6 @@ describe("AgentDetailPage", () => { it("drops a passing verdict when the key is edited afterwards", async () => { // Constraint 2: otherwise a wrong key rides in on the previous verdict. - const { api } = await import("../api/client"); vi.spyOn(api, "probe").mockResolvedValue(passingProbe()); renderPage(); fireEvent.change(screen.getByLabelText(/API Key/i), { target: { value: "sk-good" } }); @@ -147,7 +147,6 @@ describe("AgentDetailPage", () => { it("reports the restart instruction and clears the key after applying", async () => { // Constraints 3 and 4: an Agent reads its config at startup, so silence // reads as failure; and a key left in a visible field outlives its request. - const { api } = await import("../api/client"); vi.spyOn(api, "probe").mockResolvedValue(passingProbe()); vi.spyOn(api, "activateAgent").mockResolvedValue({ ok: true, @@ -201,7 +200,6 @@ describe("AgentDetailPage", () => { it("offers Claude Code a fast small-model field and sends it on activate", async () => { // The one user-facing difference between adapters: Claude Code runs its // background work on a second, optionally cheaper model. - const { api } = await import("../api/client"); vi.spyOn(api, "probe").mockResolvedValue(passingProbe()); const activate = vi.spyOn(api, "activateAgent").mockResolvedValue({ ok: true, diff --git a/frontend/src/pages/ModelSelectionPage.tsx b/frontend/src/pages/ModelSelectionPage.tsx index b931ed53..c6d3ae22 100644 --- a/frontend/src/pages/ModelSelectionPage.tsx +++ b/frontend/src/pages/ModelSelectionPage.tsx @@ -12,7 +12,7 @@ export function ModelSelectionPage() { const { state, dispatch, secret } = useWizard(); const requested = useRef(false); - const fetchModels = useCallback(async () => { + const loadModels = useCallback(async () => { dispatch({ type: "MODELS_LOADING" }); try { const result = await api.models({ @@ -27,12 +27,12 @@ export function ModelSelectionPage() { }, [dispatch, secret.keyRef, state.customBaseUrl, state.provider]); useEffect(() => { - // SetupGuard has already verified the key; only the fetch lives here. + // SetupGuard has already verified the key; only model loading lives here. if (!requested.current) { requested.current = true; - void fetchModels(); + void loadModels(); } - }, [fetchModels]); + }, [loadModels]); return ( navigate("/setup/review")} primaryDisabled={!state.model.trim() || state.modelsState === "loading"} secondaryAction={ - diff --git a/frontend/src/state/WizardContext.test.tsx b/frontend/src/state/WizardContext.test.tsx index b632b1a2..ebd9d209 100644 --- a/frontend/src/state/WizardContext.test.tsx +++ b/frontend/src/state/WizardContext.test.tsx @@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import type { PropsWithChildren } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { api } from "../api/client"; +import { api } from "../backend/api"; import type { StatusResponse } from "../types/api"; import { useWizard, WizardProvider } from "./WizardContext"; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index dcc87ae6..f0a6a5be 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1,201 +1,97 @@ +import type * as AppModels from "../../bindings/github.com/MaimoryLab/OneAgent/internal/app/models.js"; +import type * as BindingModels from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/models.js"; +import type * as CatalogModels from "../../bindings/github.com/MaimoryLab/OneAgent/internal/catalog/models.js"; +import type * as PlatformModels from "../../bindings/github.com/MaimoryLab/OneAgent/internal/platform/models.js"; + export type PlatformId = "macos" | "windows" | "linux"; export type AgentGroupId = "auto" | "gateway" | "platform" | "ide"; export type ProviderId = "ppio" | "novita" | "custom"; +export type ProtocolId = "openai" | "anthropic" | "responses"; -export interface AgentCatalogItem { - id: string; - name: string; +export const PROTOCOL_LABELS: Record = { + openai: "OpenAI Chat Completions", + anthropic: "Anthropic Messages", + responses: "OpenAI Responses", +}; + +// The generated models are the backend DTO source of truth. These aliases only +// narrow catalog-controlled strings and non-null successful responses for UI use. +export type AgentCatalogItem = Omit & { group: AgentGroupId; configMode: "auto" | "guide"; - guideOnly: boolean; - lockedVersion: string | null; - /** Inference protocol this Agent speaks; null for guide-only Agents. */ protocol: ProtocolId | null; platforms: PlatformId[]; - platformNote: string; - /** Display prominence; lower sorts first. Independent of configMode. */ - rank: number; -} - -export interface AgentStatus { - installed: boolean; - configured: boolean; - guideOnly: boolean; - config: string; - version: string | null; - lockedVersion: string | null; - canInstall: boolean; - /** What this Agent is pointed at. Null until it has been configured once. */ - provider: string | null; - model: string | null; - baseUrl: string | null; - updatedAt: string | null; - /** - * What the Agent's own config file says, read from disk. - * - * Alongside the fields above rather than instead of them: those come from - * OneAgent's own record and only exist for configurations it wrote, so a - * disagreement between the two means the config changed outside OneAgent — - * which is itself worth showing. Null for a guide-only Agent, or when there is - * no config file yet. Carries no credential, by design. - */ - detected: DetectedConfig | null; -} - -export interface DetectedConfig { - baseUrl: string; - model: string; - /** Whether the file carries the markers OneAgent's own writes leave behind. */ - managedByOneAgent: boolean; - /** Why the file could not be read, when it could not be. */ - unreadable: string | null; -} - -export interface ActivateAgentResponse { - ok: boolean; - agent: string; - config: string; - provider: string; - model: string; - /** How to make the rewritten config take effect; Agents read it at startup. */ - restart: string; - next: string; -} - -export interface OpenRegistrationResponse { - ok: boolean; - url: string; - message: string; -} - -export interface EnvironmentProfile { - schema_version: number; - id?: string; - label?: string; - provider: string; - base_url: string | null; - model: string | null; - config_mode: "provider" | "existing-account"; - agent_ids: string[]; - activated_at: string; - created_at?: string; -} - -export interface ProfileSummary { - id: string; - label: string; - provider: string; - baseUrl: string | null; - model: string | null; - agentIds: string[]; - activatedAt: string | null; - hasKey: boolean; -} +}; -export interface StatusResponse { - apiVersion: number; - platform: { os: PlatformId; arch: string; shell: string }; - capabilities: { +export type AgentStatus = AppModels.AgentStatus; +export type DetectedConfig = AppModels.DetectedConfig; +export type ActivateAgentResponse = BindingModels.ActivateResponse; +export type OpenRegistrationResponse = BindingModels.OpenRegistrationResponse; +export type ProfileSummary = Omit & { agentIds: string[] }; + +export type StatusResponse = Omit< + AppModels.StatusResponse, + "platform" | "capabilities" | "agents" | "catalog" | "groups" | "providers" | "mirrors" | "paths" | "backups" | "profiles" +> & { + platform: Omit & { os: PlatformId }; + capabilities: Omit & { canInstall: Record; supportedAgentIds: string[]; }; agents: Record; catalog: AgentCatalogItem[]; - groups: Array<{ id: AgentGroupId; name: string }>; - providers: Record; - /** - * Package registries the user may install from. The official one is the - * default; a mirror is only ever an explicit choice, and `upstream` is carried - * so the UI can show where a package ultimately comes from. - */ - mirrors: Array<{ id: string; name: string; registry: string; upstream: string; note: string }>; + groups: Array & { id: AgentGroupId }>; + providers: Record; + mirrors: CatalogModels.Mirror[]; paths: Record; backups: Record; - environment: EnvironmentProfile | null; - environmentError: string | null; profiles: ProfileSummary[]; - activeProfile: string | null; -} - -export interface ApiErrorShape { - ok: false; - error: string; - message: string; - status: number; - error_code: string; - retryable: boolean; -} - -export type ProtocolId = "openai" | "anthropic" | "responses"; - -export const PROTOCOL_LABELS: Record = { - openai: "OpenAI Chat Completions", - anthropic: "Anthropic Messages", - responses: "OpenAI Responses", }; -export interface ProbeResponse { - ok: boolean; - reachable: boolean; - status: number; - message: string; - error_code: string | null; - retryable: boolean; - /** Which protocol this result describes. Absent on pre-protocol responses. */ +export type ProbeResponse = Omit & { protocol?: ProtocolId; - /** One entry per protocol the selected Agents speak. */ protocols?: Partial>; -} +}; -export interface ModelsResponse extends ProbeResponse { +export type ModelsResponse = Omit & ProbeResponse & { models: string[]; -} +}; export type AgentResultStatus = "configured" | "guide-only" | "installed" | "skipped" | "failed"; -export interface AgentInstallResult { - agent: string; +export type AgentInstallResult = Omit< + BindingModels.AgentInstallResult, + "status" | "config" | "installed" | "version" | "lockedVersion" | "registry" | "code" | "error_code" | "message" +> & { status: AgentResultStatus; + config?: string; installed?: boolean; version?: string | null; lockedVersion?: string | null; - config?: string; + registry?: string; code?: number; error_code?: string; message?: string; - retryable: boolean; -} +}; -export interface InstallResponse { - ok: boolean; - code: number; +export type InstallResponse = Omit & { results: AgentInstallResult[]; - log: string; - next: string; probe: ProbeResponse | null; probes?: Partial>; -} +}; -export interface ProviderInput { +export type InstallRequest = Pick< + BindingModels.InstallRequest, + "api_key" | "model" | "configure" | "install_agent" | "skip_test" +> & { + agents: string[]; provider: ProviderId; api_base_url?: string; - api_key: string; -} - -export interface InstallRequest extends ProviderInput { - agents: string[]; - model: string; - /** Claude Code only: a cheaper model for fast/background work. Empty follows - * `model`; the backend ignores it for every other adapter. */ small_fast_model?: string; - configure: boolean; - install_agent: boolean; - skip_test: boolean; locked_version?: boolean; latest?: boolean; profile_agents?: string[]; - /** Mirror id or https:// URL. Omit for the official registry. */ registry?: string; profile_id?: string; timeout?: number; -} +}; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8f89f742..c51da4a0 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -21,10 +21,9 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "json-summary"], - // src/backend is the Wails-era transport adapter and carries the same - // coverage obligation as the HTTP client it will replace. + // The maintained binding adapter and UI state stay covered; browser E2E + // moves to the Wails server-build phase. include: [ - "src/api/**/*.ts", "src/backend/**/*.ts", "src/state/**/*.ts", "src/state/**/*.tsx", diff --git a/go.mod b/go.mod index 5a44dada..be524704 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/MaimoryLab/OneAgent -go 1.25.0 +go 1.26.5 require ( github.com/pelletier/go-toml/v2 v2.2.3 diff --git a/internal/app/install.go b/internal/app/install.go index 3cbf4aca..adb05409 100644 --- a/internal/app/install.go +++ b/internal/app/install.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "slices" "sort" "strings" "time" @@ -277,10 +278,8 @@ func (u *UseCases) sharpenInstallModelDiagnosis(ctx context.Context, probes map[ if err != nil || !listing.OK || len(listing.Models) == 0 { return } - for _, model := range listing.Models { - if model == options.Model { - return - } + if slices.Contains(listing.Models, options.Model) { + return } sample := listing.Models if len(sample) > 5 { diff --git a/internal/app/install_test.go b/internal/app/install_test.go index c7005c75..976a2b9a 100644 --- a/internal/app/install_test.go +++ b/internal/app/install_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "io" + "maps" "net/http" "os" "path/filepath" @@ -60,9 +61,7 @@ func (r *installAppRunner) LookPath(command string) (string, bool) { func (r *installAppRunner) Run(_ context.Context, argv []string, env map[string]string, _ time.Duration) (process.Result, error) { r.calls = append(r.calls, append([]string(nil), argv...)) copyEnv := make(map[string]string, len(env)) - for key, value := range env { - copyEnv[key] = value - } + maps.Copy(copyEnv, env) r.envs = append(r.envs, copyEnv) if strings.Contains(strings.Join(argv, " "), "dist.integrity") { return process.Result{Args: argv, ExitCode: 0, Stdout: "sha512-test\n"}, nil diff --git a/internal/app/install_wire.go b/internal/app/install_wire.go index b7864a45..2681bb6e 100644 --- a/internal/app/install_wire.go +++ b/internal/app/install_wire.go @@ -39,11 +39,12 @@ func (r AgentInstallResult) MarshalJSON() ([]byte, error) { document.Set("registry", r.Registry) } } - if r.Status == "failed" { + switch r.Status { + case "failed": document.Set("code", r.Code) document.Set("error_code", r.ErrorCode) document.Set("message", r.Message) - } else if r.Status == "guide-only" { + case "guide-only": document.Set("message", r.Message) } document.Set("retryable", r.Retryable) diff --git a/internal/app/status.go b/internal/app/status.go index af05f9d5..88c95f6b 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -5,6 +5,7 @@ package app import ( "context" + "maps" "os" "os/exec" "path/filepath" @@ -112,9 +113,7 @@ func newUseCases(options StatusOptions, client *provider.Client, profiles profil func cloneEnvironment(source map[string]string) map[string]string { result := make(map[string]string, len(source)) - for key, value := range source { - result[key] = value - } + maps.Copy(result, source) return result } @@ -232,9 +231,10 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { canInstall := false if agent.Package != nil { _, canInstall = options.Lookup(agent.Package.Manager) - if agent.Package.Manager == "npm" { + switch agent.Package.Manager { + case "npm": _, canInstall = options.Lookup("npm") - } else if agent.Package.Manager == "uv" { + case "uv": _, canInstall = options.Lookup("uv") } } diff --git a/internal/binding/services.go b/internal/binding/services.go index cd261591..0c86db18 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -52,13 +52,6 @@ func NewProviderService(core *app.UseCases, opener BrowserOpener) *ProviderServi return &ProviderService{opener: opener, core: core} } -func (s *ProviderService) ListProviders(ctx context.Context) (map[string]catalog.Provider, error) { - if err := contextError(ctx); err != nil { - return nil, err - } - return catalog.PublicProviders(), nil -} - func (s *ProviderService) Probe(ctx context.Context, request ProbeRequest) (ProbeResponse, error) { if err := contextError(ctx); err != nil { return ProbeResponse{}, err @@ -414,37 +407,26 @@ func installResult(item app.AgentInstallResult) AgentInstallResult { } if item.Status == "configured" || item.Status == "skipped" || item.Status == "installed" { if !item.IsCheckOnly() { - result.Config = stringPointer(item.Config) + result.Config = new(item.Config) } - result.Installed = boolPointer(item.Installed) + result.Installed = new(item.Installed) result.Version = nullableStringPointer(item.Version) result.LockedVersion = nullableStringPointer(item.LockedVersion) if item.Registry != "" { - result.Registry = stringPointer(item.Registry) + result.Registry = new(item.Registry) } } - if item.Status == "failed" { - result.Code = intPointer(item.Code) - result.ErrorCode = stringPointer(item.ErrorCode) - result.Message = stringPointer(item.Message) - } else if item.Status == "guide-only" { - result.Message = stringPointer(item.Message) + switch item.Status { + case "failed": + result.Code = new(item.Code) + result.ErrorCode = new(item.ErrorCode) + result.Message = new(item.Message) + case "guide-only": + result.Message = new(item.Message) } return result } -func stringPointer(value string) *string { - return &value -} - -func boolPointer(value bool) *bool { - return &value -} - -func intPointer(value int) *int { - return &value -} - func nullableStringPointer(value string) **string { if value == "" { var nilValue *string diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index aaaaee63..b22807e1 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -43,15 +43,15 @@ func TestServiceMethodAllowlist(t *testing.T) { want []string }{ {&StatusService{}, []string{"GetStatus"}}, - {&ProviderService{}, []string{"ListModels", "ListProviders", "OpenRegistration", "Probe"}}, + {&ProviderService{}, []string{"ListModels", "OpenRegistration", "Probe"}}, {&AgentService{}, []string{"Activate", "Install"}}, {&ProfileService{}, []string{"ListProfiles", "SaveProfile"}}, } for _, test := range tests { typeOf := reflect.TypeOf(test.service) got := make([]string, 0, typeOf.NumMethod()) - for index := 0; index < typeOf.NumMethod(); index++ { - got = append(got, typeOf.Method(index).Name) + for method := range typeOf.Methods() { + got = append(got, method.Name) } sort.Strings(got) sort.Strings(test.want) @@ -84,7 +84,7 @@ func TestOpenRegistrationUsesCatalogURLOnly(t *testing.T) { func TestServiceCancellationUsesStableTimeoutCode(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - _, err := (&ProviderService{}).ListProviders(ctx) + _, err := (&ProviderService{}).Probe(ctx, ProbeRequest{}) if err == nil || oneerrors.As(err).Code != oneerrors.Timeout { t.Fatalf("cancellation error = %v", err) } diff --git a/internal/catalog/manifest.go b/internal/catalog/manifest.go index afda5cdc..4ede1741 100644 --- a/internal/catalog/manifest.go +++ b/internal/catalog/manifest.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "maps" "net/url" "os" "regexp" @@ -207,9 +208,7 @@ func cloneMap(source map[string]string) map[string]string { return nil } result := make(map[string]string, len(source)) - for key, value := range source { - result[key] = value - } + maps.Copy(result, source) return result } diff --git a/internal/config/discovery.go b/internal/config/discovery.go index fac010a7..7498ff04 100644 --- a/internal/config/discovery.go +++ b/internal/config/discovery.go @@ -96,7 +96,7 @@ func ReadOpenAICompatibleConfig(text string) Detected { func ReadAiderConfig(text string) Detected { baseURL := "" - for _, line := range strings.Split(text, "\n") { + for line := range strings.SplitSeq(text, "\n") { trimmed := strings.TrimSpace(line) for _, prefix := range []string{"export OPENAI_API_BASE=", "$env:OPENAI_API_BASE ="} { if !strings.HasPrefix(trimmed, prefix) { @@ -170,14 +170,7 @@ func safeRead(read reader, text string) (result Detected) { } func unreadable(message string) Detected { - return Detected{Unreadable: stringPointer(message)} -} - -func stringPointer(value string) *string { - if value == "" { - return nil - } - return &value + return Detected{Unreadable: new(message)} } func splitModel(value string) (string, string) { diff --git a/internal/config/write.go b/internal/config/write.go index f36d1522..5b4005f1 100644 --- a/internal/config/write.go +++ b/internal/config/write.go @@ -164,7 +164,7 @@ func mergeCodexTOML(existing, managed, path string) (string, error) { managedFound := false inTable := false skipManaged := false - for _, line := range strings.Split(existing, "\n") { + for line := range strings.SplitSeq(existing, "\n") { stripped := strings.TrimSpace(line) if strings.HasPrefix(stripped, "[") { header := strings.ReplaceAll(strings.TrimSpace(strings.SplitN(stripped, "#", 2)[0]), " ", "") diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 04a59fc0..0b44b9a4 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -139,8 +139,7 @@ func As(err error) *OneAgentError { if err == nil { return nil } - var oneErr *OneAgentError - if errors.As(err, &oneErr) { + if oneErr, ok := errors.AsType[*OneAgentError](err); ok { return oneErr } return New(InternalError, "Unexpected OneAgent failure", WithStatus(500), WithRetryable(true), WithCause(err)) @@ -151,7 +150,7 @@ func Marshal(err error) []byte { if marshalErr != nil { // The shape above only contains primitive values; this is a final guard // for a Wails callback, which cannot return an error itself. - return []byte(fmt.Sprintf(`{"error_code":%q,"message":%q,"status":500,"retryable":true,"exit_code":10}`, InternalError, "Unexpected OneAgent failure")) + return fmt.Appendf(nil, `{"error_code":%q,"message":%q,"status":500,"retryable":true,"exit_code":10}`, InternalError, "Unexpected OneAgent failure") } return payload } diff --git a/internal/install/install.go b/internal/install/install.go index 5387683b..f4150c13 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -278,7 +278,7 @@ func installerFailureDetail(result process.Result, environment map[string]string text := redact(result.Stderr+"\n"+result.Stdout, secretValues(environment)) text = ansiPattern.ReplaceAllString(text, "") lines := make([]string, 0) - for _, line := range strings.Split(text, "\n") { + for line := range strings.SplitSeq(text, "\n") { line = strings.TrimSpace(line) if line != "" { lines = append(lines, line) diff --git a/internal/install/install_test.go b/internal/install/install_test.go index 6351f59a..d1d011e9 100644 --- a/internal/install/install_test.go +++ b/internal/install/install_test.go @@ -3,6 +3,7 @@ package install import ( "context" "reflect" + "slices" "strings" "testing" "time" @@ -79,12 +80,7 @@ func mustManifest() catalog.Manifest { } func containsArg(argv []string, wanted string) bool { - for _, value := range argv { - if value == wanted { - return true - } - } - return false + return slices.Contains(argv, wanted) } func runtimeForInstall(runner process.Runner, osID string, env map[string]string) Runtime { diff --git a/internal/install/runtime.go b/internal/install/runtime.go index a8495eb8..4a0faa46 100644 --- a/internal/install/runtime.go +++ b/internal/install/runtime.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "maps" "time" "github.com/MaimoryLab/OneAgent/internal/platform" @@ -53,9 +54,7 @@ func (r Runtime) command(ctx context.Context, argv []string, env map[string]stri return process.Result{Args: append([]string(nil), argv...), ExitCode: -1}, fmt.Errorf("process runner is not configured") } overrides := cloneEnv(r.Env) - for key, value := range env { - overrides[key] = value - } + maps.Copy(overrides, env) result, err := r.Runner.Run(ctx, argv, overrides, timeout) if err == nil { return result, nil @@ -78,8 +77,6 @@ func cloneEnv(source map[string]string) map[string]string { return map[string]string{} } result := make(map[string]string, len(source)) - for key, value := range source { - result[key] = value - } + maps.Copy(result, source) return result } diff --git a/internal/jsonorder/number.go b/internal/jsonorder/number.go index da100298..d1ebc6b9 100644 --- a/internal/jsonorder/number.go +++ b/internal/jsonorder/number.go @@ -48,11 +48,11 @@ func decimalExponent(value float64) int { return 0 } text := strconv.FormatFloat(value, 'e', -1, 64) - index := strings.IndexByte(text, 'e') - if index < 0 { + _, after, ok := strings.Cut(text, "e") + if !ok { return 0 } - exponent, err := strconv.Atoi(text[index+1:]) + exponent, err := strconv.Atoi(after) if err != nil { return 0 } diff --git a/internal/process/process.go b/internal/process/process.go index fef9f08e..8f7c7145 100644 --- a/internal/process/process.go +++ b/internal/process/process.go @@ -8,6 +8,7 @@ import ( "context" "fmt" "io" + "maps" "os" "os/exec" "sort" @@ -42,9 +43,7 @@ func Current() OSRunner { func New(env map[string]string) OSRunner { values := make(map[string]string, len(env)) - for key, value := range env { - values[key] = value - } + maps.Copy(values, env) return OSRunner{Env: values} } @@ -132,12 +131,8 @@ func (b *boundedBuffer) String() string { func mergeEnvironment(base, overrides map[string]string) []string { values := make(map[string]string, len(base)+len(overrides)) - for key, value := range base { - values[key] = value - } - for key, value := range overrides { - values[key] = value - } + maps.Copy(values, base) + maps.Copy(values, overrides) keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) diff --git a/internal/profile/write.go b/internal/profile/write.go index 23206bc6..d607499d 100644 --- a/internal/profile/write.go +++ b/internal/profile/write.go @@ -362,7 +362,7 @@ func parseSecret(content, osID string) string { if osID == "windows" { prefix = "$env:ONEAGENT_API_KEY = " } - for _, line := range strings.Split(content, "\n") { + for line := range strings.SplitSeq(content, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, prefix) { continue diff --git a/internal/provider/client.go b/internal/provider/client.go index 8c165913..8eb09f06 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -96,12 +96,12 @@ func (c *Client) Probe(ctx context.Context, protocol, providerID, apiKey, model, _ = response.Body.Close() } result := transportResult(err) - result.Protocol = stringPointer(protocol) + result.Protocol = new(protocol) return result, nil } if response == nil { result := transportResult(errors.New("Provider transport returned no response")) - result.Protocol = stringPointer(protocol) + result.Protocol = new(protocol) return result, nil } defer response.Body.Close() @@ -115,7 +115,7 @@ func (c *Client) Probe(ctx context.Context, protocol, providerID, apiKey, model, Status: response.StatusCode, Message: fmt.Sprintf("%s connection test passed.", ProtocolLabel(protocol)), Retryable: false, - Protocol: stringPointer(protocol), + Protocol: new(protocol), }, nil } body, _, _ := c.readBody(response.Body) @@ -164,7 +164,7 @@ func (c *Client) ListModels(ctx context.Context, providerID, apiKey, customBase Reachable: true, Status: response.StatusCode, Message: fmt.Sprintf("Model list response is not valid JSON: %v", err), - ErrorCode: stringPointer(oneerrors.ModelsUnsupported), + ErrorCode: new(oneerrors.ModelsUnsupported), Models: []string{}, }, nil } @@ -175,7 +175,7 @@ func (c *Client) ListModels(ctx context.Context, providerID, apiKey, customBase result.Message = fmt.Sprintf("Found %d models.", len(models)) } else { result.Message = "No model IDs returned; enter model ID manually." - result.ErrorCode = stringPointer(oneerrors.ModelsUnsupported) + result.ErrorCode = new(oneerrors.ModelsUnsupported) } return result, nil } @@ -285,8 +285,8 @@ func classifyHTTPProbe(status int, body, protocol, model string) ProbeResult { Reachable: true, Status: status, Message: fmt.Sprintf("Model %q does not support %s. Choose a model that serves this protocol.", model, label), - ErrorCode: stringPointer(oneerrors.ProtocolUnsupported), - Protocol: stringPointer(protocol), + ErrorCode: new(oneerrors.ProtocolUnsupported), + Protocol: new(protocol), } } if status == http.StatusUnauthorized || status == http.StatusForbidden { @@ -294,18 +294,18 @@ func classifyHTTPProbe(status int, body, protocol, model string) ProbeResult { Reachable: true, Status: status, Message: fmt.Sprintf("API key was rejected (%d).", status), - ErrorCode: stringPointer(oneerrors.APIKeyRejected), + ErrorCode: new(oneerrors.APIKeyRejected), Retryable: true, - Protocol: stringPointer(protocol), + Protocol: new(protocol), } } return ProbeResult{ Reachable: true, Status: status, Message: fmt.Sprintf("Endpoint returned HTTP %d.", status), - ErrorCode: stringPointer(oneerrors.ProviderUnreachable), + ErrorCode: new(oneerrors.ProviderUnreachable), Retryable: status >= 500, - Protocol: stringPointer(protocol), + Protocol: new(protocol), } } @@ -315,7 +315,7 @@ func classifyHTTPModels(status int) ModelsResult { Reachable: true, Status: status, Message: fmt.Sprintf("API key was rejected (%d). Enter model ID manually.", status), - ErrorCode: stringPointer(oneerrors.APIKeyRejected), + ErrorCode: new(oneerrors.APIKeyRejected), Retryable: true, Models: []string{}, } @@ -325,7 +325,7 @@ func classifyHTTPModels(status int) ModelsResult { Reachable: true, Status: status, Message: fmt.Sprintf("This endpoint does not expose /v1/models (%d); enter model ID manually.", status), - ErrorCode: stringPointer(oneerrors.ModelsUnsupported), + ErrorCode: new(oneerrors.ModelsUnsupported), Models: []string{}, } } @@ -333,7 +333,7 @@ func classifyHTTPModels(status int) ModelsResult { Reachable: true, Status: status, Message: fmt.Sprintf("Endpoint returned HTTP %d.", status), - ErrorCode: stringPointer(oneerrors.ProviderUnreachable), + ErrorCode: new(oneerrors.ProviderUnreachable), Retryable: status >= 500, Models: []string{}, } @@ -344,7 +344,7 @@ func transportResult(err error) ProbeResult { return ProbeResult{ Status: unsupportedStatus, Message: fmt.Sprintf("Cannot reach endpoint: %s", err), - ErrorCode: stringPointer(code), + ErrorCode: new(code), Retryable: retryable, } } @@ -354,7 +354,7 @@ func transportModelsResult(err error) ModelsResult { return ModelsResult{ Status: unsupportedStatus, Message: fmt.Sprintf("Cannot reach endpoint: %s", err), - ErrorCode: stringPointer(code), + ErrorCode: new(code), Retryable: retryable, Models: []string{}, } @@ -379,7 +379,7 @@ func modelsFailure(message string) ModelsResult { Reachable: true, Status: http.StatusOK, Message: message, - ErrorCode: stringPointer(oneerrors.ModelsUnsupported), + ErrorCode: new(oneerrors.ModelsUnsupported), Models: []string{}, } } @@ -421,7 +421,3 @@ func unsupportedProtocol(status int, body string) bool { } return false } - -func stringPointer(value string) *string { - return &value -} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index d4d51135..59cb033c 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -107,8 +107,8 @@ func ProtocolLabel(protocol string) string { func OpenAIBaseURL(baseURL string) string { base := strings.TrimRight(baseURL, "/") for _, suffix := range []string{"/chat/completions", "/responses", "/models"} { - if strings.HasSuffix(base, suffix) { - base = strings.TrimRight(strings.TrimSuffix(base, suffix), "/") + if before, ok := strings.CutSuffix(base, suffix); ok { + base = strings.TrimRight(before, "/") break } } @@ -121,8 +121,8 @@ func OpenAIBaseURL(baseURL string) string { func AnthropicMessagesURL(baseURL string) string { base := strings.TrimRight(baseURL, "/") for _, suffix := range []string{"/v1/messages", "/messages"} { - if strings.HasSuffix(base, suffix) { - base = strings.TrimRight(strings.TrimSuffix(base, suffix), "/") + if before, ok := strings.CutSuffix(base, suffix); ok { + base = strings.TrimRight(before, "/") break } } From 0e9a05b27d025c2d691339099066d860c8a4a31e Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:51:39 +0800 Subject: [PATCH 41/63] chore: bump dependencies --- go.mod | 10 +++++----- go.sum | 21 ++++++++++----------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index be524704..9ed20807 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,17 @@ module github.com/MaimoryLab/OneAgent go 1.26.5 require ( - github.com/pelletier/go-toml/v2 v2.2.3 + github.com/pelletier/go-toml/v2 v2.4.3 github.com/wailsapp/wails/v3 v3.0.0-alpha2.119 ) require ( github.com/adrg/xdg v0.5.3 // indirect - github.com/coder/websocket v1.8.14 // indirect + github.com/coder/websocket v1.8.15 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.43.0 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + golang.org/x/sys v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index beb450ca..8c23d8cc 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= @@ -16,12 +16,12 @@ github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7 github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -30,8 +30,7 @@ github.com/wailsapp/wails/v3 v3.0.0-alpha2.119 h1:+y9H2QxqxSiaq3i4h3e77k1/nFW0uG github.com/wailsapp/wails/v3 v3.0.0-alpha2.119/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From f610aadd786b443fc6e49457a689fdd0f48636a9 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:25:16 +0800 Subject: [PATCH 42/63] fix: remove psudo control bar in frontend --- .github/workflows/ci.yml | 42 +- .github/workflows/release-candidate.yml | 4 +- .github/workflows/technical-preview.yml | 2 - CLAUDE.md | 41 +- README.md | 51 +- Taskfile.yml | 16 +- cmd/oneagent-desktop/core.go | 9 + cmd/oneagent-desktop/core_e2e.go | 119 ++ cmd/oneagent-desktop/main_wails.go | 42 +- cmd/oneagent-native-smoke/main.go | 67 + docs/wails-v3-migration-plan.md | 5 +- frontend/bindings/README.md | 13 - frontend/dist/.keep | 0 frontend/e2e/wails-server.mjs | 32 + frontend/e2e/wails.spec.ts | 36 + frontend/package.json | 3 +- frontend/playwright.config.ts | 22 + frontend/pnpm-lock.yaml | 1933 +++++++++++++++++++ frontend/src/components/AppWindow.tsx | 6 - frontend/src/pages/ProviderKeyPage.test.tsx | 45 + frontend/src/pages/ProviderKeyPage.tsx | 16 +- frontend/src/state/wizardReducer.test.ts | 5 +- frontend/src/state/wizardReducer.ts | 2 +- frontend/src/styles/app.css | 36 - frontend/src/styles/tokens.css | 1 - internal/binding/services.go | 19 +- internal/binding/services_test.go | 22 + scripts/check_wails_bindings.sh | 10 +- scripts/run_container_cleanroom.sh | 4 +- tests/gui_smoke_test.py | 318 --- tests/macos_cleanroom_test.sh | 93 +- tests/test_release_policy.py | 44 +- tests/test_server.py | 570 ------ 33 files changed, 2459 insertions(+), 1169 deletions(-) create mode 100644 cmd/oneagent-desktop/core.go create mode 100644 cmd/oneagent-desktop/core_e2e.go create mode 100644 cmd/oneagent-native-smoke/main.go delete mode 100644 frontend/bindings/README.md delete mode 100644 frontend/dist/.keep create mode 100644 frontend/e2e/wails-server.mjs create mode 100644 frontend/e2e/wails.spec.ts create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/src/pages/ProviderKeyPage.test.tsx delete mode 100755 tests/gui_smoke_test.py delete mode 100644 tests/test_server.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 061ee04e..fd97dd3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,9 +60,13 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: "1.25" + go-version: "1.26.5" cache-dependency-path: go.sum + - name: Verify generated Wails bindings + if: runner.os == 'Linux' + run: bash scripts/check_wails_bindings.sh + - name: Install Python test tools run: python -m pip install coverage==7.15.2 @@ -83,7 +87,7 @@ jobs: run: | mkdir -p build/coverage coverage erase - coverage run --branch -m unittest tests.test_core tests.test_cli tests.test_server tests.test_release_policy tests.test_distribution_data tests.test_edge_cases tests.test_rc_scripts tests.test_install_contract tests.test_config_discovery + coverage run --branch -m unittest tests.test_core tests.test_cli tests.test_release_policy tests.test_distribution_data tests.test_edge_cases tests.test_rc_scripts tests.test_install_contract tests.test_config_discovery coverage report --fail-under=85 coverage json python -c "import json, os; files = json.load(open('build/coverage/coverage.json', encoding='utf-8'))['files']; normalized = {key.replace(os.sep, '/'): value for key, value in files.items()}; summary = normalized['oneagent/installer.py']['summary']; assert summary['percent_branches_covered'] == 100 and summary['num_partial_branches'] == 0, summary" @@ -123,10 +127,6 @@ jobs: if: runner.os != 'Windows' run: bash tests/install_test.sh - - name: GUI compatibility smoke - if: runner.os != 'Windows' - run: python tests/gui_smoke_test.py - - name: PowerShell wrapper contract if: runner.os == 'Windows' shell: pwsh @@ -141,14 +141,40 @@ jobs: working-directory: frontend run: npm run test:coverage + - name: Install Chromium for Wails binding E2E + if: runner.os == 'Linux' + working-directory: frontend + run: npx playwright install --with-deps chromium + + - name: Install Linux native smoke dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev xvfb + + - name: Wails server binding E2E + if: runner.os == 'Linux' + working-directory: frontend + run: npm run test:e2e + + - name: Wails native smoke on Linux + if: runner.os == 'Linux' + run: | + go build -tags "wails,production,gtk3" -o bin/oneagent-desktop ./cmd/oneagent-desktop + xvfb-run --auto-servernum go run ./cmd/oneagent-native-smoke -binary ./bin/oneagent-desktop + + - name: Wails native smoke on macOS + if: runner.os == 'macOS' + run: | + go build -tags "wails,production" -o bin/oneagent-desktop ./cmd/oneagent-desktop + go run ./cmd/oneagent-native-smoke -binary ./bin/oneagent-desktop + - name: Build native macOS onedir if: runner.os == 'macOS' run: python scripts/build_release.py --channel technical-preview-unsigned --skip-frontend - name: Real macOS cleanroom if: runner.os == 'macOS' - env: - ONEAGENT_PACKAGED_BINARY: ${{ github.workspace }}/build/pyinstaller-dist/OneAgent/OneAgent run: bash tests/macos_cleanroom_test.sh - name: Validate macOS arm64 release for the public site diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 427a333c..fefb8325 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -55,7 +55,7 @@ jobs: npm run build - name: Run Python contracts - run: python -m unittest tests.test_core tests.test_cli tests.test_server tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts tests.test_install_contract tests.test_config_discovery + run: python -m unittest tests.test_core tests.test_cli tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts tests.test_install_contract tests.test_config_discovery - name: Build unsigned native package shell: bash @@ -70,8 +70,6 @@ jobs: - name: Real macOS cleanroom if: runner.os == 'macOS' - env: - ONEAGENT_PACKAGED_BINARY: ${{ github.workspace }}/build/pyinstaller-dist/OneAgent/OneAgent run: bash tests/macos_cleanroom_test.sh - name: Smoke packaged executable on Windows diff --git a/.github/workflows/technical-preview.yml b/.github/workflows/technical-preview.yml index f4e0f9ee..81b9e9c5 100644 --- a/.github/workflows/technical-preview.yml +++ b/.github/workflows/technical-preview.yml @@ -94,8 +94,6 @@ jobs: - name: Real macOS arm64 cleanroom if: matrix.os == 'macos-15' - env: - ONEAGENT_PACKAGED_BINARY: ${{ github.workspace }}/build/pyinstaller-dist/OneAgent/OneAgent run: bash tests/macos_cleanroom_test.sh - name: Validate release contents diff --git a/CLAUDE.md b/CLAUDE.md index 726b2bb2..60b435d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,20 +1,19 @@ # CLAUDE.md -OneAgent:本地 AI 开发环境激活器。Python 3.12 标准库内核 + React 七页向导,经 `127.0.0.1` HTTP 通信,负责检测、安装并配置 5 个 CLI Agent 指向 OpenAI- 或 Anthropic-compatible Provider。 +OneAgent:本地 AI 开发环境激活器。React 七页向导通过 Wails v3 binding 调用 Go 核心,负责检测、安装并配置 5 个 CLI Agent 指向 OpenAI- 或 Anthropic-compatible Provider。 当前 `0.2.0-dev`,发行渠道只能标记 `technical-preview-unsigned`。功能说明与发行流程见 [README.md](README.md)。 -Wails v3 迁移已在当前分支进行,阶段 0-3 的退出门禁已通过,但尚未切换生产入口。Go 核心 -位于 `internal/`、`cmd/oneagent` 和 `cmd/oneagent-desktop`;默认桌面命令只有在显式使用 -`wails` build tag 时才链接 Wails。 +Wails v3 迁移处于阶段 5。前端只调用 `frontend/src/backend/wails.ts`,它只转发生成的 +Wails binding;不要恢复 HTTP fallback、`fetch` 或手写后端 DTO。Go 核心位于 `internal/`、 +`cmd/oneagent` 和 `cmd/oneagent-desktop`;默认桌面命令只有在显式使用 `wails` build tag 时才链接 Wails。 **CLI 路径已迁移到 Go**:`scripts/install.sh` 与 `.ps1` 是纯转发层,转发到 `cmd/oneagent` 且不再定位 Python,改动 CLI 行为要改 Go 而不是 `oneagent/cli.py`。它们 不做按需构建(调用方使用临时 HOME,`go build` 会把 module cache 写进去),所以 `tests/install_test.sh` 之前需要先 `go build -o bin/oneagent ./cmd/oneagent`。 -GUI 路径(`scripts/gui.py`、`oneagent/server.py`)、Python 测试和发布流程在阶段 4-6 -的门禁通过前继续保留并作为当前生产路径。 +Python 发布链路在阶段 6-7 完成前仍保留;桌面前端的通信路径已切换为 Wails binding。 ## 沟通语言 @@ -22,25 +21,23 @@ GUI 路径(`scripts/gui.py`、`oneagent/server.py`)、Python 测试和发布 ## 常用命令 -本机 `python3` 是 3.14,测试与打包必须显式用 `python3.12`;`scripts/gui.py` 用任意 ≥3.12 均可。 +本机 `python3` 是 3.14,遗留 Python 测试与打包必须显式用 `python3.12`。 ```bash # Python 契约测试(74 用例,约 7s) -python3.12 -m unittest tests.test_core tests.test_cli tests.test_server \ +python3.12 -m unittest tests.test_core tests.test_cli \ tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts # 覆盖率门禁:整体 ≥85%,installer.py 必须 100% 分支且无 partial python3.12 -m coverage run --branch -m unittest tests.test_core tests.test_cli \ - tests.test_server tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts + tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts python3.12 -m coverage report --fail-under=85 -# 源码 GUI -python3 scripts/gui.py --port 8765 --no-open - # 前端(build 会先跑 tsc --noEmit) cd frontend && npm ci && npm run build npm run test:coverage -npm run e2e # Playwright 自动拉起 gui.py:8765 +npx playwright install chromium +npm run test:e2e # Playwright 自动拉起 Wails server,e2e # Go 迁移线(不需要 Python;parity 门禁需要 ≥3.12 才会真正运行) go vet ./... && ONEAGENT_REQUIRE_PARITY=1 go test ./... && go test -race ./... @@ -48,21 +45,18 @@ go build -o bin/oneagent ./cmd/oneagent # install_test.sh 依赖它先存在 # 隔离验证 bash tests/install_test.sh # 经 install.sh 转发到 Go CLI,临时 HOME -python3.12 tests/gui_smoke_test.py # 真实 HTTP + Cookie/Origin 冒烟 +task test:native # 原生 WebView 完成一次真实 GetStatus binding bash scripts/test_docker_cleanroom.sh # Linux 断网 cleanroom ``` ## 代码地图 ``` -oneagent/ Python 内核,零第三方依赖 +oneagent/ 遗留 Python 发布实现,零第三方依赖 catalog.py 读 agents.lock.json、平台/HOME 解析、PROVIDERS 常量 providers.py base URL 校验与推导、chat_probe、list_models installer.py 主体:原子写、备份、权限、5 个配置适配器、install_many、status_payload - server.py stdlib http.server:/api/{status,probe,models,install,profiles,open-register}、 - POST /api/agents//activate(单 Agent 重新指向)+ 静态托管 - cli.py argparse CLI(已被 cmd/oneagent 取代,仅 GUI 打包入口仍引用) - entrypoint.py 打包版入口:无参→GUI,有参→CLI + cli.py argparse CLI(已被 cmd/oneagent 取代) internal/ Go 核心,桌面壳与 CLI 共用 app/ use case:GetStatus、InstallAgents、ActivateAgent、SaveProfile binding/ Wails service 与传输 DTO,不放业务逻辑 @@ -72,14 +66,13 @@ cmd/oneagent-desktop Wails 壳,仅 `-tags wails` 时链接 Wails frontend/src/ App.tsx react-router 七页 + SetupGuard 前置校验 state/ useReducer + Context,WizardState 是唯一状态源 - backend/ 传输 adapter:按运行时选 Wails binding 或 HTTP - api/client.ts fetch 封装,非 2xx 抛 OneAgentApiError(GUI 路径) + backend/ 传输 adapter:只调用 Wails binding frontend/bindings/ Wails 生成物,禁止手改;改 Go DTO 后重新生成 agents.lock.json Agent 版本/包管理器/配置适配器/平台/许可证的唯一真源 -scripts/ gui.py、install.sh/.ps1(转发到 Go CLI)、build_release.py +scripts/ install.sh/.ps1(转发到 Go CLI)、build_release.py ``` -GUI 主链路:`React → POST /api/install → install_many() → _write_agent_config() → atomic_write()`。 +桌面主链路:`React → AgentService.Install binding → app.InstallAgents() → config.Writer → securefs.AtomicWrite()`。 CLI 主链路:`cmd/oneagent → app.InstallAgents() → config.Writer → securefs.AtomicWrite()`。 ## 硬性约束 @@ -88,7 +81,7 @@ CLI 主链路:`cmd/oneagent → app.InstallAgents() → config.Writer → secu - **零运行时依赖**:`oneagent/` 只用标准库,`pyproject.toml` 的 `dependencies` 保持为空。 - **禁止 `shell=True` 与 `curl | sh`**:子进程一律走 `runtime.runner([...])` 列表参数。 -- **只绑定 127.0.0.1**:`create_server` 拒绝其他 host;POST 同时校验 Origin 白名单与 HttpOnly/SameSite=Strict 会话 Cookie(`secrets.compare_digest`)。 +- **桌面通信边界**:生产桌面进程不监听业务 TCP 端口;只注册四个 Wails service,不配置 HTTP Route 或 Raw Message Handler。 - **API Key 不落地**:不进 `profile.json`、argv、URL、日志、React state、浏览器存储;日志一律过 `redact(text, [api_key])`。 - **写配置只走 `atomic_write`**:`ensure_private_dir`(0700) → 备份 `*.backup-` → 临时文件先 `secure_path`(0600 / Windows icacls 断继承) → `os.replace`。密钥备份无法加固时删除并报错。 - **保留用户字段**:Codex TOML 与 Claude/OpenCode/Kilo JSON 合并时不得丢弃非 OneAgent 管理的键;解析失败返回 `CONFIG_WRITE_FAILED`,绝不静默覆盖。 diff --git a/README.md b/README.md index 30910b32..aff8f125 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OneAgent -OneAgent 是一个本地 AI 开发环境激活器。它用 React 七页向导检测、安装并初始化常用 Agent,同时保留 Bash、PowerShell 和结构化 CLI。安装、备份、权限、Provider 探测和配置写入统一由 Python 3.12 核心完成。 +OneAgent 是一个本地 AI 开发环境激活器。它用 React 七页向导检测、安装并初始化常用 Agent,同时保留 Bash、PowerShell 和结构化 CLI。桌面应用通过 Wails v3 binding 调用 Go 核心完成安装、备份、权限、Provider 探测和配置写入。 OneAgent 不重新分发 Agent 二进制,不捆绑 Node.js、Python、Git Bash、VPN、代理、共享 API Key 或第三方配置工具。缺少前置工具时只返回明确错误和官方安装指引。 @@ -8,9 +8,9 @@ OneAgent 不重新分发 Agent 二进制,不捆绑 Node.js、Python、Git Bash 当前版本为 `0.2.0-dev`,当前发行目标是可直接下载运行的 `technical-preview-unsigned` 二进制包。不以四平台同时分发作为产品阶段门槛;每个实际发布的平台仍须在对应操作系统原生构建,并以 CI cleanroom 作业作为验收证据。各平台最低目标见 [ADR-003](docs/decisions/ADR-003-three-platform-python-core-and-release-policy.md)。 -Wails v3 迁移已在当前分支开始,当前仍处于阶段 0/1:Go catalog、错误/平台基础包、 -纯 Go CLI 和带 `wails` 标签的桌面空壳已建立,但 Python 核心和本地 HTTP GUI 仍是 -生产路径。迁移完成前不会删除或旁路任何 Python 源码、测试、脚本或打包流程。 +Wails v3 迁移现处于阶段 5:React 仅通过生成的 binding 与 Go service 通信,旧 HTTP API/fetch +测试已删除;Wails `server,e2e` 浏览器链路和原生 `GetStatus` smoke 已在本机 macOS arm64 验证。Python 发布链路 +保留到阶段 6-7 再替换,但不再是桌面前端的通信路径。 发行渠道不限定为 GitHub。官网、GitHub Release、网盘和企业云盘可以作为同一官方构建的镜像,但同一版本必须保持文件内容和 SHA-256 一致,渠道方不得重新打包或加入渠道专属内容。每个产物只声明实际构建和验证过的目标环境。 @@ -26,12 +26,12 @@ Wails v3 迁移已在当前分支开始,当前仍处于阶段 0/1:Go catalog ```text React + TypeScript + Vite | - | localhost JSON API + | generated Wails bindings v -Python 3.12 HTTP Server +Status / Provider / Agent / Profile services | v -oneagent.installer +Go application use cases - 平台路径和前置检测 - 锁定版本安装 - 配置合并和备份 @@ -39,11 +39,11 @@ oneagent.installer - Provider 探测和结构化错误 ``` -- `scripts/gui.py`:源码 GUI 入口。 - `scripts/install.sh`:macOS/Linux CLI 转发层,转发到 Go CLI(`cmd/oneagent`)。 - `scripts/install.ps1`:Windows CLI 转发层,转发到 Go CLI。 - `cmd/oneagent`:纯 Go CLI,是 CLI 路径的实现所在;包装脚本只做转发。 -- `oneagent/`:三平台共用安装核心、API Server 和 Python CLI(GUI 路径仍在使用)。 +- `internal/`:桌面 service 与 CLI 共用的 Go 核心。 +- `cmd/oneagent-desktop/`:Wails 桌面入口,仅在 `wails` build tag 下链接 Wails。 - `frontend/`:React 七页向导;发行包只携带构建后的 `dist`,终端用户不需要 Node.js。 - `site/`:独立 Astro 静态公开站;不进入 Launcher 包体,也不复用本地路由和状态。 - `distribution/`:公开渠道状态与 Provider 商业关系披露;技术排序与商业数据保持分离。 @@ -66,23 +66,16 @@ npm run test:e2e ## 快速启动 -### 源码 GUI +### 源码桌面应用 -GUI 路径仍是 Python,要求 Python 3.12+(CLI 路径已不需要,见下节)。如需让 OneAgent -自动安装 Aider,还需要预先安装 `uv`;无论走哪条路径,OneAgent 都不会自动下载 Python: +先构建前端,再启动 Wails 桌面壳。生产桌面进程不监听业务 TCP 端口: ```bash -python3 scripts/gui.py +cd frontend && npm ci && npm run build +cd .. +go run -tags wails ./cmd/oneagent-desktop ``` -固定端口且不自动打开浏览器: - -```bash -python3 scripts/gui.py --port 8765 --no-open -``` - -GUI 只监听 `127.0.0.1`。首页设置随机 HttpOnly、SameSite=Strict 会话 Cookie,所有 POST 同时校验 Cookie 和 localhost Origin。 - ### 打包版 解压对应平台的 onedir 压缩包后运行: @@ -291,13 +284,13 @@ oneagent agent set codex --provider ppio --model deepseek/deepseek-v3 --api-key oneagent agent set opencode --provider novita --model --profile team ``` -`--profile` 复用 `profiles/` 里已保存模板的 Key,无需重新粘贴。GUI 对应 `POST /api/agents//activate`。 +`--profile` 复用 `profiles/` 里已保存模板的 Key,无需重新粘贴。桌面应用通过 `AgentService.Activate` 完成同一操作。 Agent 在启动时读取配置,因此重新指向后必须重启该 Agent 进程才会生效;响应与 CLI 输出都会给出对应的重启指引。切换只影响单个 Agent,失败不会波及其他 Agent。 ## 错误契约 -CLI `--json` 和本地 API 使用稳定错误码: +CLI `--json` 和 Wails binding 使用稳定错误码: - `INVALID_REQUEST` - `INVALID_ORIGIN` @@ -326,7 +319,7 @@ Python 3.12 契约和覆盖率: ```bash python3.12 -m coverage run --branch -m unittest \ - tests.test_core tests.test_cli tests.test_server \ + tests.test_core tests.test_cli \ tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts python3.12 -m coverage report --fail-under=85 python3.12 -m coverage json @@ -349,7 +342,6 @@ go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... ```bash go build -o bin/oneagent ./cmd/oneagent bash tests/install_test.sh -python3.12 tests/gui_smoke_test.py ``` React 与浏览器: @@ -359,7 +351,9 @@ cd frontend npm run test:coverage npm run build npx playwright install chromium -npm run e2e +npm run test:e2e +cd .. +task test:native ``` ### Docker Linux Cleanroom @@ -396,14 +390,13 @@ ONEAGENT_API_KEY=... python3.12 scripts/agent_e2e_smoke.py --provider ppio ### 真实 macOS Cleanroom -真实 macOS cleanroom 是**已发布 macOS 产物**的验收依据,不是“必须先发布 macOS”的要求。当前架构的前端和 unsigned onedir 构建完成后,可以在真实 macOS 上运行: +真实 macOS cleanroom 隔离验证 Go CLI 的配置、权限和备份行为。当前 Python 发布链路的产物验收仍留待阶段 6,脚本可在真实 macOS 上直接运行: ```bash -ONEAGENT_PACKAGED_BINARY="$PWD/build/pyinstaller-dist/OneAgent/OneAgent" \ bash tests/macos_cleanroom_test.sh ``` -脚本要求真实 `uname -s == Darwin`,使用 `env -i`、临时 HOME/TMPDIR 和受控 PATH,验证源码 GUI、打包 GUI、随机本地端口、Cookie/Origin、五个配置适配器、备份以及目录 `0700`/文件 `0600`。执行前后会比对真实用户配置目标,发现污染立即失败。 +脚本要求真实 `uname -s == Darwin`,使用 `env -i`、临时 HOME/TMPDIR 和受控 PATH,验证五个配置适配器、备份以及目录 `0700`/文件 `0600`。执行前后会比对真实用户配置目标,发现污染立即失败。 GitHub Actions 的 `ci.yml` macOS 作业(`macos-15` arm64 与 `macos-15-intel` x64)和手动 Release Candidate 运行该脚本,`tests/test_release_policy.py` 断言其契约不被弱化。普通 PR 与常规 CI 只使用 fake npm/uv,不下载真实 Agent,也不访问 PPIO/Novita;只有手动 Release Candidate 才在隔离 prefix/tool 目录中安装五个锁定版本并执行真实 Provider 冒烟。 diff --git a/Taskfile.yml b/Taskfile.yml index f889c3ba..ac3b7e4a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -35,7 +35,21 @@ tasks: - generate:bindings cmds: - mkdir -p bin - - go run {{.WAILS_MODULE}}/cmd/wails3@{{.WAILS_VERSION}} build -tags wails + - go build -tags "wails,production" -o bin/oneagent-desktop ./cmd/oneagent-desktop + + test:e2e: + summary: Run the Wails server binding browser test + deps: + - build:frontend + cmds: + - cd frontend && npm run test:e2e + + test:native: + summary: Start the native Wails binary and require a GetStatus binding call + deps: + - build:desktop + cmds: + - go run ./cmd/oneagent-native-smoke -binary ./bin/oneagent-desktop dev:desktop: summary: Run the Wails development shell diff --git a/cmd/oneagent-desktop/core.go b/cmd/oneagent-desktop/core.go new file mode 100644 index 00000000..c382e397 --- /dev/null +++ b/cmd/oneagent-desktop/core.go @@ -0,0 +1,9 @@ +//go:build wails && !e2e + +package main + +import "github.com/MaimoryLab/OneAgent/internal/app" + +func newDesktopUseCases() *app.UseCases { + return app.NewUseCasesFromEnvironment() +} diff --git a/cmd/oneagent-desktop/core_e2e.go b/cmd/oneagent-desktop/core_e2e.go new file mode 100644 index 00000000..7fb77eb3 --- /dev/null +++ b/cmd/oneagent-desktop/core_e2e.go @@ -0,0 +1,119 @@ +//go:build wails && e2e + +package main + +import ( + "context" + "io" + "net/http" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/MaimoryLab/OneAgent/internal/app" + "github.com/MaimoryLab/OneAgent/internal/catalog" + "github.com/MaimoryLab/OneAgent/internal/platform" + "github.com/MaimoryLab/OneAgent/internal/process" + "github.com/MaimoryLab/OneAgent/internal/provider" +) + +func newDesktopUseCases() *app.UseCases { + info := platform.Current() + home := platform.ResolveHome(nil, info.OS) + return app.NewUseCasesWithProviderClient(app.StatusOptions{ + Home: home, + Platform: info, + Runner: newE2ERunner(), + Environment: map[string]string{"HOME": home}, + }, provider.NewClient(e2eProviderDoer{})) +} + +type e2eRunner struct { + mu sync.RWMutex + agents map[string]catalog.Agent + byPackage map[string]string + installed map[string]string +} + +func newE2ERunner() *e2eRunner { + runner := &e2eRunner{ + agents: map[string]catalog.Agent{}, + byPackage: map[string]string{}, + installed: map[string]string{}, + } + manifest, err := catalog.LoadEmbedded() + if err != nil { + return runner + } + runner.agents = manifest.Agents + for id, agent := range manifest.Agents { + if agent.Package != nil { + runner.byPackage[agent.Package.Name+"@"+agent.Package.Version] = id + } + } + return runner +} + +func (r *e2eRunner) LookPath(command string) (string, bool) { + if command == "npm" { + return "/oneagent-e2e/npm", true + } + r.mu.RLock() + _, ok := r.installed[command] + r.mu.RUnlock() + if !ok { + return "", false + } + return "/oneagent-e2e/" + command, true +} + +func (r *e2eRunner) Run(_ context.Context, argv []string, _ map[string]string, _ time.Duration) (process.Result, error) { + result := process.Result{Args: append([]string(nil), argv...), ExitCode: 0} + if len(argv) >= 4 && argv[1] == "view" && argv[3] == "dist.integrity" { + if id, ok := r.byPackage[argv[2]]; ok && r.agents[id].Package != nil && r.agents[id].Package.Integrity != nil { + result.Stdout = *r.agents[id].Package.Integrity + "\n" + return result, nil + } + result.ExitCode = 1 + return result, nil + } + if len(argv) >= 4 && argv[1] == "install" && argv[2] == "-g" { + if id, ok := r.byPackage[argv[3]]; ok { + agent := r.agents[id] + r.mu.Lock() + r.installed[agent.Command] = agent.Package.Version + r.mu.Unlock() + } + return result, nil + } + if len(argv) >= 2 && argv[len(argv)-1] == "--version" { + command := filepath.Base(argv[0]) + r.mu.RLock() + version := r.installed[command] + r.mu.RUnlock() + if version != "" { + result.Stdout = command + " " + version + "\n" + } + } + return result, nil +} + +type e2eProviderDoer struct{} + +func (e2eProviderDoer) Do(request *http.Request) (*http.Response, error) { + body := "" + if request.Method == http.MethodGet && strings.HasSuffix(request.URL.Path, "/models") { + body = `{"data":[{"id":"oneagent-e2e-model"}]}` + } + status := http.StatusNoContent + if body != "" { + status = http.StatusOK + } + return &http.Response{ + StatusCode: status, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + }, nil +} diff --git a/cmd/oneagent-desktop/main_wails.go b/cmd/oneagent-desktop/main_wails.go index 16b155c0..e3a0684e 100644 --- a/cmd/oneagent-desktop/main_wails.go +++ b/cmd/oneagent-desktop/main_wails.go @@ -5,27 +5,45 @@ package main import ( "log/slog" "os" + "sync" + "time" oneagent "github.com/MaimoryLab/OneAgent" - "github.com/MaimoryLab/OneAgent/internal/app" "github.com/MaimoryLab/OneAgent/internal/binding" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" "github.com/wailsapp/wails/v3/pkg/application" ) func main() { - core := app.NewUseCasesFromEnvironment() - services := binding.NewServices(core, func(url string) error { + core := newDesktopUseCases() + var appInstance *application.App + var nativeSmokeOnce sync.Once + var afterGetStatus func() + if os.Getenv("ONEAGENT_NATIVE_SMOKE") == "1" { + afterGetStatus = func() { + nativeSmokeOnce.Do(func() { + if result := os.Getenv("ONEAGENT_NATIVE_SMOKE_RESULT"); result != "" { + _ = os.WriteFile(result, []byte("ok\n"), 0o600) + } + time.AfterFunc(250*time.Millisecond, func() { + if appInstance != nil { + appInstance.Quit() + } + }) + }) + } + } + services := binding.NewServicesWithOptions(core, func(url string) error { current := application.Get() if current == nil || current.Browser == nil { return oneerrors.New(oneerrors.InternalError, "Desktop browser is not ready") } return current.Browser.OpenURL(url) - }) + }, binding.ServicesOptions{AfterGetStatus: afterGetStatus}) // No Route or RawMessageHandler is configured. The default Wails transport // is internal IPC; the production app does not expose a business HTTP port. - appInstance := application.New(application.Options{ + appInstance = application.New(application.Options{ Name: "OneAgent", Description: "Local AI development environment activator", LogLevel: slog.LevelInfo, @@ -42,12 +60,14 @@ func main() { }, Mac: application.MacOptions{ApplicationShouldTerminateAfterLastWindowClosed: true}, }) - appInstance.Window.NewWithOptions(application.WebviewWindowOptions{ - Title: "OneAgent", - Width: 1180, - Height: 760, - URL: "/", - }) + if !application.System.IsServer() { + appInstance.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "OneAgent", + Width: 1180, + Height: 760, + URL: "/", + }) + } if err := appInstance.Run(); err != nil { // Do not print an arbitrary Wails error containing binding arguments. _, _ = os.Stderr.WriteString("OneAgent desktop failed to start\n") diff --git a/cmd/oneagent-native-smoke/main.go b/cmd/oneagent-native-smoke/main.go new file mode 100644 index 00000000..94c9530f --- /dev/null +++ b/cmd/oneagent-native-smoke/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +func main() { + binary := flag.String("binary", "", "path to the native OneAgent desktop binary") + timeout := flag.Duration("timeout", 20*time.Second, "maximum time to wait for GetStatus") + flag.Parse() + if err := run(*binary, *timeout); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(binary string, timeout time.Duration) error { + if binary == "" { + return fmt.Errorf("-binary is required") + } + if runtime.GOOS == "windows" && filepath.Ext(binary) == "" { + if _, err := os.Stat(binary + ".exe"); err == nil { + binary += ".exe" + } + } + home, err := os.MkdirTemp("", "oneagent-native-smoke-") + if err != nil { + return fmt.Errorf("create temporary HOME: %w", err) + } + defer os.RemoveAll(home) + result := filepath.Join(home, "get-status") + command := exec.Command(binary) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + command.Env = append(os.Environ(), + "HOME="+home, + "USERPROFILE="+home, + "ONEAGENT_HOME="+home, + "ONEAGENT_NATIVE_SMOKE=1", + "ONEAGENT_NATIVE_SMOKE_RESULT="+result, + ) + if err := command.Start(); err != nil { + return fmt.Errorf("start desktop app: %w", err) + } + exited := make(chan error, 1) + go func() { exited <- command.Wait() }() + select { + case err := <-exited: + if err != nil { + return fmt.Errorf("desktop app exited before GetStatus: %w", err) + } + case <-time.After(timeout): + _ = command.Process.Kill() + <-exited + return fmt.Errorf("timed out waiting for the desktop GetStatus binding") + } + if content, err := os.ReadFile(result); err != nil || string(content) != "ok\n" { + return fmt.Errorf("desktop app exited without calling GetStatus through the binding") + } + return nil +} diff --git a/docs/wails-v3-migration-plan.md b/docs/wails-v3-migration-plan.md index 1c14b9c3..d2e1c83c 100644 --- a/docs/wails-v3-migration-plan.md +++ b/docs/wails-v3-migration-plan.md @@ -1,6 +1,6 @@ # OneAgent 全量迁移至 Wails v3 规划方案 -- 状态:In Progress(阶段 0-4 退出门禁已通过,阶段 5 尚未开始,桌面发布入口尚未切换) +- 状态:In Progress(阶段 0-4 退出门禁已通过;阶段 5 已开始,桌面发布入口尚未切换) - 日期:2026-07-30 - 目标版本:下一主版本(建议 `0.3.0-dev` 开始迁移) - 适用范围:桌面应用、Go 核心、CLI、前端通信、测试、构建、发布和公开站数据生成 @@ -20,6 +20,9 @@ - 阶段 4 已完成:四个 service 通过生成的 Wails binding 暴露;前端只经 `frontend/src/backend/wails.ts` 调用,已删除 fetch、HTTP fallback 和手写后端 DTO。 旧 API/fetch 单测与路由 mock E2E 已删除,Wails `server` build 的浏览器链路留待阶段 5。 +- 阶段 5 进行中:旧 HTTP 冒烟及其 CI/cleanroom 入口已删除;生成 binding diff、Wails + `server,e2e` 全向导 Playwright 流程和原生 `GetStatus` smoke 已接入,并已在本机 macOS + arm64 验证,Linux GTK3 smoke 已接入 CI;仍等待 Linux CI 首次证据和 Windows 原生证据。 Python HTTP GUI 和发布流程仍是遗留路径;Wails 前端不再回退到它们。阶段 5-6 的行为 等价门禁通过前不得删除现有 Python 核心或发布实现。 diff --git a/frontend/bindings/README.md b/frontend/bindings/README.md deleted file mode 100644 index dd412a43..00000000 --- a/frontend/bindings/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Generated Wails bindings - -This directory is generated from the Go services. Do not edit the generated -TypeScript files by hand. - -Regenerate with the pinned CLI and build tag: - -```text -wails3 generate bindings -f "-tags wails" -ts -i -d frontend/bindings ./cmd/oneagent-desktop -``` - -`frontend/src/backend/wails.ts` is the only page-facing transport adapter. -React calls these generated bindings directly; there is no HTTP fallback. diff --git a/frontend/dist/.keep b/frontend/dist/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/e2e/wails-server.mjs b/frontend/e2e/wails-server.mjs new file mode 100644 index 00000000..2f6e1965 --- /dev/null +++ b/frontend/e2e/wails-server.mjs @@ -0,0 +1,32 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const frontendDir = dirname(fileURLToPath(import.meta.url)); +const root = join(frontendDir, "..", ".."); +const home = await mkdtemp(join(tmpdir(), "oneagent-wails-e2e-")); +const child = spawn("go", ["run", "-tags", "wails,server,e2e", "./cmd/oneagent-desktop"], { + cwd: root, + env: { + ...process.env, + ONEAGENT_HOME: home, + }, + stdio: "inherit", +}); + +let stopping = false; +const stop = () => { + if (!stopping) { + stopping = true; + child.kill(); + } +}; + +process.once("SIGINT", stop); +process.once("SIGTERM", stop); +child.once("exit", async (code) => { + await rm(home, { recursive: true, force: true }); + process.exit(code ?? 1); +}); diff --git a/frontend/e2e/wails.spec.ts b/frontend/e2e/wails.spec.ts new file mode 100644 index 00000000..06dee9b1 --- /dev/null +++ b/frontend/e2e/wails.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; + +test("the onboarding flow uses generated Wails bindings", async ({ page }) => { + const bindingMethodIDs = new Set(); + page.on("request", (request) => { + const path = new URL(request.url()).pathname; + if (request.method() !== "POST" || path !== "/wails/runtime") return; + const call = request.postDataJSON() as { args?: { methodID?: unknown } }; + if (typeof call.args?.methodID === "number") bindingMethodIDs.add(call.args.methodID); + }); + + await page.goto("/"); + await expect(page.getByRole("link", { name: "Start with OneAgent" })).toBeVisible(); + await page.getByRole("link", { name: "Start with OneAgent" }).click(); + + await expect(page.getByRole("heading", { name: "选择 Agent" })).toBeVisible(); + await page.getByLabel("选择 Codex").check(); + await page.getByRole("button", { name: "继续" }).click(); + + await page.getByRole("button", { name: "配置模型服务" }).click(); + await page.getByRole("button", { name: "继续" }).click(); + + await page.getByLabel("API Key").fill("e2e-key"); + await page.getByRole("button", { name: "测试连接" }).click(); + await expect(page.getByRole("status")).toContainText("connection test passed"); + await page.getByRole("button", { name: "继续选择模型" }).click(); + + await expect(page.getByRole("radio", { name: "oneagent-e2e-model" })).toBeVisible(); + await page.getByRole("button", { name: "继续" }).click(); + await page.getByRole("button", { name: "开始激活" }).click(); + + await expect(page.getByRole("heading", { name: "激活完成" })).toBeVisible(); + await page.getByRole("button", { name: "进入总览" }).click(); + await expect(page.getByRole("heading", { name: "环境总览" })).toBeVisible(); + expect(bindingMethodIDs.size).toBeGreaterThanOrEqual(4); +}); diff --git a/frontend/package.json b/frontend/package.json index 9efeb31b..13d7b653 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:coverage": "vitest run --coverage", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test" }, "dependencies": { "@wailsio/runtime": "3.0.0-alpha2.117", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 00000000..fb7aa0c7 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "@playwright/test"; + +const port = 34123; + +export default defineConfig({ + testDir: "./e2e", + workers: 1, + use: { + baseURL: `http://127.0.0.1:${port}`, + trace: "retain-on-failure", + }, + webServer: { + command: "npm run build && node ./e2e/wails-server.mjs", + url: `http://127.0.0.1:${port}/health`, + timeout: 120_000, + reuseExistingServer: false, + env: { + WAILS_SERVER_HOST: "127.0.0.1", + WAILS_SERVER_PORT: String(port), + }, + }, +}); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..84156855 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1933 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@wailsio/runtime': + specifier: 3.0.0-alpha2.117 + version: 3.0.0-alpha2.117 + lucide-react: + specifier: 1.25.0 + version: 1.25.0(react@19.2.8) + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + react-router-dom: + specifier: 7.18.1 + version: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + devDependencies: + '@playwright/test': + specifier: 1.61.1 + version: 1.61.1 + '@testing-library/jest-dom': + specifier: 7.0.0 + version: 7.0.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: 14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + '@types/react': + specifier: 19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: 6.0.3 + version: 6.0.3(vite@8.1.5(@types/node@26.1.1)) + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) + jsdom: + specifier: 29.1.1 + version: 29.1.1 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@26.1.1) + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@jest/types@27.0.2': + resolution: {integrity: sha512-XpjCtJ/99HB4PmyJ2vgmN7vT+JLP7RW1FBT9RgnMFS4Dt7cvIyBee8O3/j98aUZ34ZpenPZFqmaaObWSeL65dg==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.4.14': + resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.2.0': + resolution: {integrity: sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^2.0.0-alpha.3 + '@emnapi/runtime': ^2.0.0-alpha.3 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.0': + resolution: {integrity: sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@16.0.11': + resolution: {integrity: sha512-sbtvk8wDN+JvEdabmZExoW/HNr1cB7D/j4LT08rMiuikfA7m/JNJg7ATQcgzs34zHnoScDkY0ZRSl29Fkmk36g==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + '@wailsio/runtime@3.0.0-alpha2.117': + resolution: {integrity: sha512-RZr6cncIXjdTbn2IqJ6AZXPm9WooaZEsNlNfaUH+Ru/YlH5sKDDeRdWpbcvsgoMLh4AJ+O8aBzZEiTYWDipGxw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.1.0: + resolution: {integrity: sha512-Qts4KCLKG+waHc9C4m07weIY8qyeixoS0h6RnbsNVD6Fw+pEZGW3vTyObL3WXpE09Mq4Oi7/lBEyLmOiLtlYWQ==} + engines: {node: '>=8'} + + ansi-styles@5.0.0: + resolution: {integrity: sha512-6564t0m0fuQMnockqBv7wJxo9T5C2V9JpYXyNScfRDPVLusOQQhkpMGrFC17QbiolraQ1sMXX+Y5nJpjqozL4g==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.0.0: + resolution: {integrity: sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==} + engines: {node: '>=10'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.0.1: + resolution: {integrity: sha512-Xd8lFX4LM9QEEwxQpF9J9NTUh8pmdJO0cyRJhFiDoLTk2eH8FXlRv2IFGYVadZpqI3j8fhNrSdKCeYPxiAhLXw==} + engines: {node: '>=18'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lucide-react@1.25.0: + resolution: {integrity: sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.24: + resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.0.2: + resolution: {integrity: sha512-mXKbbBPnYTG7Yra9qFBtqj+IXcsvxsvOBco3QHxtxTl+hHKq6QdzMZ+q0CtL4ORHZgwGImRr2XZUX2EWzORxig==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.1: + resolution: {integrity: sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==} + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + supports-color@7.1.0: + resolution: {integrity: sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@exodus/bytes@1.15.1': {} + + '@jest/types@27.0.2': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 26.1.1 + '@types/yargs': 16.0.11 + chalk: 4.0.0 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.4.14': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.14 + + '@napi-rs/wasm-runtime@1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.0.2 + + '@testing-library/jest-dom@7.0.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@16.0.11': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitejs/plugin-react@6.0.3(vite@8.1.5(@types/node@26.1.1))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.5(@types/node@26.1.1) + + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@26.1.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@wailsio/runtime@3.0.0-alpha2.117': {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.1.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.0.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + chai@6.2.2: {} + + chalk@4.0.0: + dependencies: + ansi-styles: 4.1.0 + supports-color: 7.1.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + convert-source-map@2.0.0: {} + + cookie@1.0.1: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + decimal.js@10.6.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + entities@8.0.0: {} + + es-module-lexer@2.3.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + has-flag@4.0.0: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + indent-string@4.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.1.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.29.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@11.5.2: {} + + lucide-react@1.25.0(react@19.2.8): + dependencies: + react: 19.2.8 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + mdn-data@2.27.1: {} + + min-indent@1.0.1: {} + + nanoid@3.3.16: {} + + obug@2.1.4: {} + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.24: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.0.2: + dependencies: + '@jest/types': 27.0.2 + ansi-regex: 5.0.1 + ansi-styles: 5.0.0 + react-is: 17.0.1 + + punycode@2.3.1: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.1: {} + + react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-router: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + + react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.0.1 + react: 19.2.8 + set-cookie-parser: 2.6.0 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react@19.2.8: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-from-string@2.0.2: {} + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@7.8.5: {} + + set-cookie-parser@2.6.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + supports-color@7.1.0: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tldts-core@7.4.9: {} + + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: + optional: true + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} + + undici@7.29.0: {} + + vite@8.1.5(@types/node@26.1.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.24 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.1 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@26.1.1)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.1.5(@types/node@26.1.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.1 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/frontend/src/components/AppWindow.tsx b/frontend/src/components/AppWindow.tsx index 0e30d8a3..92b62121 100644 --- a/frontend/src/components/AppWindow.tsx +++ b/frontend/src/components/AppWindow.tsx @@ -6,12 +6,6 @@ export function AppWindow({ children }: PropsWithChildren) { return (
- -
OneAgent
{children}
diff --git a/frontend/src/pages/ProviderKeyPage.test.tsx b/frontend/src/pages/ProviderKeyPage.test.tsx new file mode 100644 index 00000000..2fbc7f94 --- /dev/null +++ b/frontend/src/pages/ProviderKeyPage.test.tsx @@ -0,0 +1,45 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; + +import { api } from "../backend/api"; +import { initialWizardState, wizardReducer, type WizardAction, type WizardState } from "../state/wizardReducer"; +import { ProviderKeyPage } from "./ProviderKeyPage"; + +let state: WizardState; +const keyRef = { current: "test-key" }; +const dispatch = vi.fn((action: WizardAction) => { + state = wizardReducer(state, action); +}); + +vi.mock("../state/WizardContext", () => ({ + useWizard: () => ({ + state, + dispatch, + secret: { keyRef, setApiKey: vi.fn(), clearApiKey: vi.fn() }, + }), +})); + +describe("ProviderKeyPage", () => { + it("uses a custom model name for the connection test", async () => { + state = { ...initialWizardState, hasApiKey: true }; + dispatch.mockClear(); + const probe = vi.spyOn(api, "probe").mockResolvedValue({ + ok: true, + reachable: true, + status: 200, + message: "ok", + error_code: null, + retryable: false, + }); + const page = render(); + + fireEvent.change(screen.getByLabelText("自定义模型名称(可选)"), { target: { value: "vendor/custom-model" } }); + page.rerender(); + fireEvent.click(screen.getByRole("button", { name: "测试连接" })); + + await waitFor(() => + expect(probe).toHaveBeenCalledWith(expect.objectContaining({ model: "vendor/custom-model", apiKey: "test-key" })), + ); + }); +}); diff --git a/frontend/src/pages/ProviderKeyPage.tsx b/frontend/src/pages/ProviderKeyPage.tsx index 7e69aca9..cf6ac0df 100644 --- a/frontend/src/pages/ProviderKeyPage.tsx +++ b/frontend/src/pages/ProviderKeyPage.tsx @@ -49,7 +49,8 @@ export function ProviderKeyPage() { provider: state.provider, apiBaseUrl: state.provider === "custom" ? state.customBaseUrl : "", apiKey: secret.keyRef.current, - // Empty before the model step; the backend picks its probe default. + // A user-supplied ID lets providers without model discovery validate + // the model that will actually be configured. model: state.model, agents: state.selectedAgentIds, }); @@ -108,6 +109,19 @@ export function ProviderKeyPage() {
)} +
+ + dispatch({ type: "SET_MODEL", value: event.target.value })} + placeholder="例如 deepseek/deepseek-v3" + spellCheck={false} + /> + 填写后将用此模型测试连接;留空时自动选择。 +
+
diff --git a/frontend/src/state/wizardReducer.test.ts b/frontend/src/state/wizardReducer.test.ts index 9e3a1423..867bd672 100644 --- a/frontend/src/state/wizardReducer.test.ts +++ b/frontend/src/state/wizardReducer.test.ts @@ -70,7 +70,7 @@ describe("wizardReducer", () => { expect(state.connectionState).toBe("idle"); }); - it("invalidates a stale probe verdict whenever the key changes", () => { + it("invalidates a stale probe verdict whenever the key or model changes", () => { // The reducer only sees the non-empty boolean (the secret stays in a ref), // so editing "valid key" into "wrong key" dispatches the very same // SET_HAS_API_KEY(true). It must reset the verdict: keeping the stale @@ -88,6 +88,9 @@ describe("wizardReducer", () => { const cleared = wizardReducer(probed, { type: "SET_HAS_API_KEY", value: false }); expect(cleared.connectionState).toBe("idle"); expect(cleared.connection).toBeNull(); + const changedModel = wizardReducer(probed, { type: "SET_MODEL", value: "vendor-model" }); + expect(changedModel.connectionState).toBe("idle"); + expect(changedModel.connection).toBeNull(); }); it("marks skipped configuration steps and clears model selection", () => { diff --git a/frontend/src/state/wizardReducer.ts b/frontend/src/state/wizardReducer.ts index 84e900ad..e8dc8685 100644 --- a/frontend/src/state/wizardReducer.ts +++ b/frontend/src/state/wizardReducer.ts @@ -203,7 +203,7 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS modelsMessage: action.message, }; case "SET_MODEL": - return { ...state, model: action.value }; + return { ...state, model: action.value, connection: null, connectionState: "idle" }; case "REQUEST_ACTIVATION": return { ...state, activationRequested: true }; case "ACTIVATION_LOADING": diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 70c9c75e..fcc31ea9 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -10,7 +10,6 @@ min-height: 680px; display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); - grid-template-rows: var(--titlebar-height) minmax(0, 1fr); overflow: hidden; border: 1px solid var(--border-strong); border-radius: var(--radius-window); @@ -18,39 +17,6 @@ box-shadow: var(--shadow-window); } -.window-chrome { - border-bottom: 1px solid var(--border); - background: rgba(250, 250, 251, 0.96); -} - -.window-chrome-sidebar { - padding: 0 18px; - display: flex; - align-items: center; - gap: 9px; - border-right: 1px solid var(--border); - background: var(--sidebar-bg); -} - -.window-title { - display: flex; - align-items: center; - justify-content: center; - font-size: 13px; - font-weight: 600; -} - -.traffic-light { - width: 12px; - height: 12px; - border-radius: 50%; - border: 1px solid rgba(0, 0, 0, 0.08); -} - -.traffic-light-red { background: #ff5f57; } -.traffic-light-yellow { background: #febc2e; } -.traffic-light-green { background: #28c840; } - .navigation-sidebar { min-height: 0; padding: 22px 12px 16px; @@ -903,8 +869,6 @@ .sidebar-footnote { display: none; } .sidebar-link { justify-content: center; padding: 0; } .sidebar-link.is-active::before { left: -12px; } - .window-chrome-sidebar { justify-content: center; padding: 0; gap: 5px; } - .traffic-light { width: 9px; height: 9px; } } @media (max-height: 760px) { diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 0063e0e6..2f2a35a8 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -27,6 +27,5 @@ --radius-panel: 8px; --radius-window: 12px; --sidebar-width: 232px; - --titlebar-height: 52px; --footer-height: 68px; } diff --git a/internal/binding/services.go b/internal/binding/services.go index 0c86db18..dd9ed1d5 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -21,9 +21,17 @@ type Services struct { Profile *ProfileService } +type ServicesOptions struct { + AfterGetStatus func() +} + func NewServices(core *app.UseCases, opener BrowserOpener) *Services { + return NewServicesWithOptions(core, opener, ServicesOptions{}) +} + +func NewServicesWithOptions(core *app.UseCases, opener BrowserOpener, options ServicesOptions) *Services { return &Services{ - Status: &StatusService{core: core}, + Status: &StatusService{core: core, afterGetStatus: options.AfterGetStatus}, Provider: NewProviderService(core, opener), Agent: NewAgentService(core), Profile: NewProfileService(core), @@ -31,14 +39,19 @@ func NewServices(core *app.UseCases, opener BrowserOpener) *Services { } type StatusService struct { - core *app.UseCases + core *app.UseCases + afterGetStatus func() } func (s *StatusService) GetStatus(ctx context.Context) (app.StatusResponse, error) { if s == nil || s.core == nil { return app.StatusResponse{}, notReady("Status service is not configured") } - return s.core.GetStatus(ctx) + status, err := s.core.GetStatus(ctx) + if err == nil && s.afterGetStatus != nil { + s.afterGetStatus() + } + return status, err } type BrowserOpener func(string) error diff --git a/internal/binding/services_test.go b/internal/binding/services_test.go index b22807e1..1f2fdbf7 100644 --- a/internal/binding/services_test.go +++ b/internal/binding/services_test.go @@ -61,6 +61,28 @@ func TestServiceMethodAllowlist(t *testing.T) { } } +func TestStatusServiceRunsNativeSmokeHookAfterSuccess(t *testing.T) { + called := 0 + service := NewServicesWithOptions(app.NewUseCases(app.StatusOptions{ + Home: t.TempDir(), + Platform: platform.For("linux", "amd64"), + Lookup: func(string) (string, bool) { return "", false }, + }), nil, ServicesOptions{AfterGetStatus: func() { called++ }}).Status + if _, err := service.GetStatus(context.Background()); err != nil { + t.Fatal(err) + } + if called != 1 { + t.Fatalf("native smoke hook calls = %d, want 1", called) + } + + if _, err := (&StatusService{afterGetStatus: func() { called++ }}).GetStatus(context.Background()); err == nil { + t.Fatal("unconfigured status service succeeded") + } + if called != 1 { + t.Fatalf("native smoke hook ran after failed status = %d", called) + } +} + func TestOpenRegistrationUsesCatalogURLOnly(t *testing.T) { var opened string service := &ProviderService{opener: func(value string) error { diff --git a/scripts/check_wails_bindings.sh b/scripts/check_wails_bindings.sh index c80d79c2..12fe0128 100755 --- a/scripts/check_wails_bindings.sh +++ b/scripts/check_wails_bindings.sh @@ -2,9 +2,13 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -wails_bin="${WAILS3_BIN:-wails3}" expected_version="$(sed -n 's/^WAILS_CLI_VERSION=//p' "$repo_root/build/tool-versions.env")" -actual_version="$($wails_bin version 2>&1 | tail -n 1 | tr -d '\r')" +if [[ -n "${WAILS3_BIN:-}" ]]; then + wails_cmd=("$WAILS3_BIN") +else + wails_cmd=(go run "github.com/wailsapp/wails/v3/cmd/wails3@${expected_version}") +fi +actual_version="$("${wails_cmd[@]}" version 2>&1 | tail -n 1 | tr -d '\r')" if [[ "$actual_version" != "$expected_version" ]]; then printf 'Wails CLI version %s does not match pinned %s\n' "$actual_version" "$expected_version" >&2 exit 1 @@ -14,7 +18,7 @@ tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' EXIT ( cd "$repo_root" - "$wails_bin" generate bindings -f "-tags wails" -ts -i -d "$tmp_dir" -clean=true ./cmd/oneagent-desktop + "${wails_cmd[@]}" generate bindings -f "-tags wails" -ts -i -d "$tmp_dir" -clean=true ./cmd/oneagent-desktop ) diff -ru --exclude=README.md "$repo_root/frontend/bindings" "$tmp_dir" diff --git a/scripts/run_container_cleanroom.sh b/scripts/run_container_cleanroom.sh index 9ed5d9b2..ad361d31 100755 --- a/scripts/run_container_cleanroom.sh +++ b/scripts/run_container_cleanroom.sh @@ -201,7 +201,7 @@ mkdir -p build/coverage run_stage python-contracts bash -c ' coverage erase coverage run --branch -m unittest \ - tests.test_core tests.test_cli tests.test_server \ + tests.test_core tests.test_cli \ tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts coverage report --fail-under=85 coverage json @@ -214,12 +214,10 @@ run_stage python-contracts bash -c ' run_stage go-cli-no-python assert_go_cli_without_python run_stage bash-compatibility bash tests/install_test.sh run_stage existing-config bash tests/existing_config_test.sh -run_stage gui-smoke python3.12 tests/gui_smoke_test.py run_stage react-coverage bash -c 'cd frontend && npm run test:coverage' run_stage react-build bash -c 'cd frontend && npm run build' -run_stage browser-e2e bash -c 'cd frontend && npm run e2e' collect_artifacts run_stage policy-scan scan_release_policy diff --git a/tests/gui_smoke_test.py b/tests/gui_smoke_test.py deleted file mode 100755 index 2a44a45a..00000000 --- a/tests/gui_smoke_test.py +++ /dev/null @@ -1,318 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import socket -import subprocess -import sys -import tempfile -import threading -import time -import http.cookiejar -from http.server import BaseHTTPRequestHandler, HTTPServer -from pathlib import Path -from urllib.error import HTTPError -from urllib.request import HTTPCookieProcessor, Request, build_opener - - -ROOT = Path(__file__).resolve().parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from tests.support import LocalHTTPServer # noqa: E402 - -GUI = ROOT / "scripts" / "gui.py" -CLIENT = build_opener(HTTPCookieProcessor(http.cookiejar.CookieJar())) -ORIGIN = "" - - -def fail(message): - raise AssertionError(message) - - -def free_port(): - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] - - -def request_json(url, payload=None): - if payload is None: - req = Request(url) - else: - data = json.dumps(payload).encode() - req = Request( - url, - data=data, - headers={"Content-Type": "application/json", "Origin": ORIGIN}, - method="POST", - ) - try: - with CLIENT.open(req, timeout=5) as res: - return res.status, json.loads(res.read().decode()) - except HTTPError as exc: - try: - return exc.code, json.loads(exc.read().decode()) - finally: - exc.close() - - -def bootstrap_client(base_url): - global CLIENT, ORIGIN - ORIGIN = base_url - CLIENT = build_opener(HTTPCookieProcessor(http.cookiejar.CookieJar())) - try: - with CLIENT.open(base_url + "/", timeout=5) as response: - response.read() - except HTTPError as exc: - try: - exc.read() - finally: - exc.close() - - -def wait_for_server(base_url): - for _ in range(50): - try: - status, _ = request_json(base_url + "/api/status") - if status == 200: - return - except Exception: - time.sleep(0.1) - fail("GUI server did not start") - - -class MockModelHandler(BaseHTTPRequestHandler): - status_code = 200 - models_status_code = 200 - - def log_message(self, *_): - return - - def do_GET(self): - self.send_response(self.models_status_code) - self.send_header("Content-Type", "application/json") - self.end_headers() - if self.models_status_code == 200: - self.wfile.write(b'{"data":[{"id":"model-a"},{"id":"model-b"}]}') - else: - self.wfile.write(b'{"error":"mock"}') - - def do_POST(self): - self.send_response(self.status_code) - self.send_header("Content-Type", "application/json") - self.end_headers() - self.wfile.write(b'{"ok":true}') - - -def start_model_server(status_code, models_status_code=200): - server = LocalHTTPServer(("127.0.0.1", 0), MockModelHandler) - server.RequestHandlerClass.status_code = status_code - server.RequestHandlerClass.models_status_code = models_status_code - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - return server, f"http://127.0.0.1:{server.server_port}/openai" - - -def write_executable(path, text): - path.write_text(text) - path.chmod(0o755) - - -def main(): - if not GUI.exists(): - fail("scripts/gui.py is missing") - - with tempfile.TemporaryDirectory() as tmpdir: - tmp = Path(tmpdir) - home = tmp / "home" - fakebin = tmp / "bin" - home.mkdir() - fakebin.mkdir() - - npm_log = tmp / "npm.log" - uv_log = tmp / "uv.log" - write_executable(fakebin / "codex", "#!/usr/bin/env bash\necho fake codex\n") - write_executable(fakebin / "claude", "#!/usr/bin/env bash\necho fake claude\n") - write_executable(fakebin / "opencode", "#!/usr/bin/env bash\necho fake opencode\n") - write_executable(fakebin / "kilo", "#!/usr/bin/env bash\necho fake kilo\n") - write_executable(fakebin / "aider", "#!/usr/bin/env bash\necho fake aider\n") - write_executable(fakebin / "cursor", "#!/usr/bin/env bash\necho fake cursor\n") - write_executable(fakebin / "npm", f"#!/usr/bin/env bash\necho \"$@\" >> {npm_log}\n") - write_executable(fakebin / "uv", f"#!/usr/bin/env bash\necho \"$@\" >> {uv_log}\n") - write_executable(fakebin / "python3.12", "#!/usr/bin/env bash\necho Python 3.12.0\n") - - env = os.environ.copy() - env["HOME"] = str(home) - env["PATH"] = f"{fakebin}:/usr/bin:/bin" - env["PYTHONUNBUFFERED"] = "1" - - port = free_port() - proc = subprocess.Popen( - [sys.executable, str(GUI), "--port", str(port), "--no-open"], - cwd=str(ROOT), - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - base_url = f"http://127.0.0.1:{port}" - try: - wait_for_server(base_url) - bootstrap_client(base_url) - - status, data = request_json(base_url + "/api/status") - assert status == 200 - assert data["agents"]["codex"]["installed"] is True - assert data["agents"]["claude-code"]["installed"] is True - assert data["agents"]["opencode"]["installed"] is True - assert data["agents"]["kilo-cli"]["installed"] is True - assert data["agents"]["aider"]["installed"] is True - assert data["agents"]["openclaw"]["guideOnly"] is True - assert "catalog" in data - assert {"auto", "gateway", "platform", "ide"} <= {group["id"] for group in data["groups"]} - assert data["providers"]["ppio"]["base_url"] == "https://api.ppio.com/openai" - assert data["providers"]["novita"]["base_url"] == "https://api.novita.ai/openai" - - model_server, model_base = start_model_server(200) - try: - status, data = request_json(base_url + "/api/probe", { - "provider": "custom", - "api_base_url": model_base, - "api_key": "sk-test", - "model": "gpt-test", - }) - assert status == 200 - assert data["ok"] is True - status, data = request_json(base_url + "/api/models", { - "provider": "custom", - "api_base_url": model_base, - "api_key": "sk-test", - }) - assert status == 200 - assert data["models"] == ["model-a", "model-b"] - finally: - model_server.shutdown() - - status, data = request_json(base_url + "/api/install", { - "agents": ["codex", "claude-code"], - "provider": "ppio", - "api_key": "sk-test", - "model": "gpt-test", - "configure": True, - "install_agent": False, - "skip_test": True, - }) - assert status == 200 - assert data["ok"] is True - assert "sk-test" not in data["log"] - assert (home / ".codex" / "config.toml").exists() - assert (home / ".claude" / "settings.json").exists() - assert {item["agent"] for item in data["results"]} == {"codex", "claude-code"} - - status, data = request_json(base_url + "/api/install", { - "agents": ["opencode", "kilo-cli", "aider", "openclaw", "cursor"], - "provider": "ppio", - "api_key": "sk-test", - "model": "gpt-test", - "configure": True, - "install_agent": False, - "skip_test": True, - }) - assert status == 200 - assert data["ok"] is True - assert "sk-test" not in json.dumps(data) - by_agent = {item["agent"]: item["status"] for item in data["results"]} - assert by_agent["opencode"] == "configured" - assert by_agent["kilo-cli"] == "configured" - assert by_agent["aider"] == "configured" - assert by_agent["openclaw"] == "guide-only" - assert by_agent["cursor"] == "guide-only" - - opencode_config = json.loads((home / ".config" / "opencode" / "opencode.jsonc").read_text()) - kilo_config = json.loads((home / ".config" / "kilo" / "kilo.jsonc").read_text()) - assert opencode_config["provider"]["oneagent"]["npm"] == "@ai-sdk/openai-compatible" - assert opencode_config["provider"]["oneagent"]["options"]["baseURL"] == "https://api.ppio.com/openai/v1" - # Each Agent reads its own variable so two of them can point at - # different providers in one shell. - assert opencode_config["provider"]["oneagent"]["options"]["apiKey"] == "{env:ONEAGENT_API_KEY_OPENCODE}" - assert kilo_config["provider"]["oneagent"]["options"]["apiKey"] == "{env:ONEAGENT_API_KEY_KILO_CLI}" - assert kilo_config["provider"]["oneagent"]["options"]["baseURL"] == "https://api.ppio.com/openai/v1" - for agent in ("codex", "opencode", "kilo-cli"): - assert (home / ".oneagent" / "agents" / f"{agent}.env").exists() - assert (home / ".oneagent" / "aider.env").read_text().count("sk-test") == 1 - assert not (home / ".openclaw").exists() - assert not (home / ".cursor").exists() - - status, data = request_json(base_url + "/api/install", { - "agents": ["openclaw"], - "configure": True, - "install_agent": True, - }) - assert status == 200 - assert data["ok"] is True - assert data["results"][0]["status"] == "guide-only" - assert not (home / ".openclaw").exists() - - status, data = request_json(base_url + "/api/install", { - "agents": ["codex"], - "configure": False, - "install_agent": False, - }) - assert status == 200 - assert data["ok"] is True - assert "Model configuration skipped" in data["log"] - - (fakebin / "codex").unlink() - status, data = request_json(base_url + "/api/install", { - "agents": ["codex", "claude-code"], - "provider": "ppio", - "api_key": "sk-test", - "model": "gpt-test", - "configure": True, - "install_agent": True, - "skip_test": True, - }) - assert status == 200 - npm_calls = npm_log.read_text() - assert "@openai/codex" in npm_calls - - (fakebin / "claude").unlink() - status, data = request_json(base_url + "/api/install", { - "agents": ["claude-code"], - "configure": False, - "install_agent": True, - }) - assert status == 200 - assert "@anthropic-ai/claude-code" in npm_log.read_text() - - (fakebin / "opencode").unlink() - (fakebin / "kilo").unlink() - (fakebin / "aider").unlink() - status, data = request_json(base_url + "/api/install", { - "agents": ["opencode", "kilo-cli", "aider", "openclaw"], - "provider": "ppio", - "api_key": "sk-test", - "model": "gpt-test", - "configure": True, - "install_agent": True, - "skip_test": True, - }) - assert status == 200 - assert "opencode-ai" in npm_log.read_text() - assert "@kilocode/cli" in npm_log.read_text() - uv_calls = uv_log.read_text() - assert "tool install" in uv_calls - assert "--no-python-downloads" in uv_calls - assert "aider-chat==0.86.2" in uv_calls - finally: - proc.terminate() - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - - print("gui smoke tests passed") - - -if __name__ == "__main__": - main() diff --git a/tests/macos_cleanroom_test.sh b/tests/macos_cleanroom_test.sh index f0cb3b00..8802c971 100755 --- a/tests/macos_cleanroom_test.sh +++ b/tests/macos_cleanroom_test.sh @@ -3,18 +3,12 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REAL_HOME="${HOME:?HOME is required}" -PACKAGED_BINARY="${ONEAGENT_PACKAGED_BINARY:-}" ARTIFACT_DIR="${ONEAGENT_MACOS_CLEANROOM_ARTIFACTS:-$ROOT_DIR/build/macos-cleanroom}" if [[ "$(uname -s)" != "Darwin" ]]; then echo "macOS cleanroom requires a real Darwin host." >&2 exit 2 fi -if [[ -z "$PACKAGED_BINARY" || ! -x "$PACKAGED_BINARY" ]]; then - echo "ONEAGENT_PACKAGED_BINARY must point to the native OneAgent executable." >&2 - exit 2 -fi - PYTHON_BIN="$(command -v python3.12 || true)" NODE_BIN="$(command -v node || true)" NPM_BIN="$(command -v npm || true)" @@ -41,8 +35,6 @@ FAKE_BIN="$CLEAN_ROOT/bin" BEFORE_SNAPSHOT="$CLEAN_ROOT/real-home-before.json" AFTER_SNAPSHOT="$CLEAN_ROOT/real-home-after.json" MOCK_KEY="oneagent-macos-cleanroom-placeholder" -SERVER_PIDS=() - mkdir -p "$CLEAN_HOME" "$CLEAN_TMP" "$FAKE_BIN" "$ARTIFACT_DIR" chmod 0700 "$CLEAN_HOME" "$CLEAN_TMP" "$FAKE_BIN" ln -s "$PYTHON_BIN" "$FAKE_BIN/python3.12" @@ -128,11 +120,6 @@ clean_env() { finish() { local status=$? trap - EXIT INT TERM - local pid - for pid in "${SERVER_PIDS[@]:-}"; do - kill "$pid" >/dev/null 2>&1 || true - wait "$pid" >/dev/null 2>&1 || true - done snapshot_real_home "$AFTER_SNAPSHOT" if ! cmp -s "$BEFORE_SNAPSHOT" "$AFTER_SNAPSHOT"; then echo "macOS cleanroom modified a real user configuration path." >&2 @@ -156,80 +143,6 @@ assert_mode() { fi } -wait_for_url() { - local file="$1" - local pid="$2" - for _ in {1..100}; do - if [[ -s "$file" ]]; then - head -n 1 "$file" - return 0 - fi - if ! kill -0 "$pid" >/dev/null 2>&1; then - echo "GUI process exited before publishing its URL." >&2 - return 1 - fi - sleep 0.1 - done - echo "Timed out waiting for GUI URL." >&2 - return 1 -} - -check_gui() { - local label="$1" - shift - local url_file="$CLEAN_ROOT/$label.url" - local log_file="$ARTIFACT_DIR/$label.log" - clean_env "$@" > "$url_file" 2> "$log_file" & - local pid=$! - SERVER_PIDS+=("$pid") - local url - url="$(wait_for_url "$url_file" "$pid")" - - clean_env "$PYTHON_BIN" - "$url" "$REAL_HOME" <<'PY' -from __future__ import annotations - -import json -import sys -from http.cookiejar import CookieJar -from urllib.error import HTTPError -from urllib.parse import urljoin -from urllib.request import HTTPCookieProcessor, Request, build_opener, urlopen - -base = sys.argv[1] -real_home = sys.argv[2] -opener = build_opener(HTTPCookieProcessor(CookieJar())) -root = opener.open(base, timeout=5) -cookie = root.headers.get("Set-Cookie", "") -assert "HttpOnly" in cookie and "SameSite=Strict" in cookie, cookie -assert root.headers.get("Content-Security-Policy"), "CSP header is missing" - -status = json.load(opener.open(urljoin(base, "api/status"), timeout=5)) -assert status["platform"]["os"] == "macos", status["platform"] -for path in status["paths"].values(): - assert not str(path).startswith(real_home), path - -payload = json.dumps({}).encode() -try: - opener.open(Request(urljoin(base, "api/models"), data=payload, method="POST", headers={"Content-Type": "application/json"}), timeout=5) -except HTTPError as exc: - assert exc.code == 403 - assert json.load(exc)["error_code"] == "INVALID_ORIGIN" -else: - raise AssertionError("POST without Origin must be rejected") - -origin = base.rstrip("/") -try: - urlopen(Request(urljoin(base, "api/models"), data=payload, method="POST", headers={"Content-Type": "application/json", "Origin": origin}), timeout=5) -except HTTPError as exc: - assert exc.code == 403 -else: - raise AssertionError("POST without session cookie must be rejected") -PY - - kill "$pid" - wait "$pid" || true -} - snapshot_real_home "$BEFORE_SNAPSHOT" for command_name in codex claude opencode kilo aider; do @@ -240,14 +153,13 @@ for command_name in codex claude opencode kilo aider; do done clean_env "$PYTHON_BIN" -m unittest \ - tests.test_core tests.test_cli tests.test_server tests.test_edge_cases \ + tests.test_core tests.test_cli tests.test_edge_cases \ > "$ARTIFACT_DIR/python-contracts.log" 2>&1 clean_env /bin/bash tests/install_test.sh > "$ARTIFACT_DIR/bash-contracts.log" 2>&1 # Every stage above starts from an empty HOME, so a machine that already has # Agent configuration on it -- the normal case for a tool meant to manage them # over time -- was never exercised here. clean_env /bin/bash tests/existing_config_test.sh > "$ARTIFACT_DIR/existing-config.log" 2>&1 -clean_env "$PYTHON_BIN" tests/gui_smoke_test.py > "$ARTIFACT_DIR/gui-smoke.log" 2>&1 for agent in codex claude-code opencode kilo-cli aider; do clean_env env ONEAGENT_API_KEY="$MOCK_KEY" /bin/bash scripts/install.sh \ @@ -313,7 +225,4 @@ if grep -R -Fq -- "$MOCK_KEY" "$ARTIFACT_DIR"; then exit 1 fi -check_gui source-gui "$PYTHON_BIN" scripts/gui.py --port 0 --no-open -check_gui packaged-gui "$PACKAGED_BINARY" gui --port 0 --no-open - echo "Real macOS cleanroom passed on $(uname -m)." diff --git a/tests/test_release_policy.py b/tests/test_release_policy.py index dfddce17..66f4cf49 100644 --- a/tests/test_release_policy.py +++ b/tests/test_release_policy.py @@ -12,7 +12,7 @@ from pathlib import Path from unittest.mock import patch -from oneagent import catalog, entrypoint +from oneagent import catalog from oneagent.catalog import load_manifest from oneagent.installer import Runtime, status_payload from scripts import build_release @@ -81,24 +81,6 @@ def test_runtime_code_does_not_use_shell_true_or_curl_pipe(self): self.assertNotRegex(sources, r"shell\s*=\s*True") self.assertNotRegex(sources, r"curl\s+[^\n|]+\|\s*(?:ba)?sh") - def test_no_bare_http_server_reintroduces_the_reverse_dns_stall(self): - # http.server's server_bind() calls socket.getfqdn(). On a host with no - # reverse resolver that blocks ~35s per process, which is why the macOS - # CI jobs used to spend 44s on a 9s test step. Every server must come - # from a subclass that overrides the bind. - offenders: list[str] = [] - for directory in ("oneagent", "scripts", "tests"): - for path in sorted((ROOT / directory).glob("*.py")): - text = path.read_text(encoding="utf-8") - for line_number, line in enumerate(text.splitlines(), start=1): - if re.search(r"(?
", encoding="utf-8") - (self.static / "assets" / "app-DiZRmN5o.js").write_text("console.log('ok')", encoding="utf-8") - (self.static / "assets" / "payload.bin").write_bytes(b"not an allowed web asset") - self.home = self.root / "home" - self.home.mkdir() - self.runtime = Runtime.create(home=self.home, os_id="linux", which=lambda _name: None) - self.server = create_server("127.0.0.1", 0) - self.base = f"http://127.0.0.1:{self.server.server_port}" - self.origin = self.base - self.cookies = http.cookiejar.CookieJar() - self.opener = build_opener(HTTPCookieProcessor(self.cookies)) - self.patches = [ - patch("oneagent.server.static_root", return_value=self.static), - patch("oneagent.server._runtime", return_value=self.runtime), - patch.dict(os.environ, {"ONEAGENT_DISABLE_BROWSER": "1"}), - ] - for item in self.patches: - item.start() - self.local = LocalServer(self.server) - self.local.__enter__() - - def tearDown(self): - self.local.__exit__(None, None, None) - for item in reversed(self.patches): - item.stop() - self.tmp.cleanup() - - def bootstrap(self): - with self.opener.open(self.base + "/", timeout=2) as response: - self.assertEqual(response.status, 200) - self.assertIn("default-src 'self'", response.headers["Content-Security-Policy"]) - self.assertEqual(response.headers["Cache-Control"], "no-store") - self.assertTrue(any(cookie.name == "oneagent_session" and cookie.value for cookie in self.cookies)) - - def post(self, path, payload, *, origin=True): - return request_json(self.opener, self.base + path, payload, origin=self.origin if origin is True else origin) - - def test_status_is_additive_and_does_not_require_session(self): - status, _, payload = request_json(self.opener, self.base + "/api/status") - self.assertEqual(status, 200) - self.assertEqual(payload["apiVersion"], 1) - self.assertEqual(payload["platform"]["os"], "linux") - self.assertEqual(payload["agents"]["codex"]["lockedVersion"], "0.145.0") - self.assertIn("capabilities", payload) - - def test_status_exposes_only_public_provider_fields(self): - # The payload used to send the PROVIDERS constant wholesale, so adding - # an internal key leaked it to every client: fallback_probe_model - # appeared in /api/status while the frontend type never declared it. - _, _, payload = request_json(self.opener, self.base + "/api/status") - for provider_id, meta in payload["providers"].items(): - with self.subTest(provider=provider_id): - self.assertEqual( - set(meta) - {"anthropic_base_url"}, - {"name", "home", "base_url"}, - ) - self.assertNotIn("fallback_probe_model", json.dumps(payload)) - - def test_bind_does_not_perform_reverse_dns_lookup(self): - # http.server's default server_bind() calls socket.getfqdn(), which blocks - # until it times out on hosts with no reverse resolver. That stalled GUI - # startup by ~70s on CI runners and would do the same on offline machines. - with patch("socket.getfqdn", side_effect=AssertionError("reverse DNS lookup during bind")) as getfqdn: - server = create_server("127.0.0.1", 0) - try: - self.assertEqual(server.server_name, "127.0.0.1") - self.assertEqual(server.server_port, server.socket.getsockname()[1]) - finally: - server.server_close() - getfqdn.assert_not_called() - - def test_server_refuses_to_bind_outside_localhost(self): - # The whole security model assumes a loopback-only listener: the Origin - # allowlist and the session cookie are meaningless if the socket is - # reachable from the network. Nothing covered this guard, so dropping - # it in a refactor would have kept the suite green. - for host in ("0.0.0.0", "::", "192.168.1.10", "localhost", ""): - with self.subTest(host=host): - with self.assertRaises(OneAgentError) as refused: - create_server(host, 0) - self.assertEqual(refused.exception.code, "INVALID_REQUEST") - self.assertIn("127.0.0.1", refused.exception.message) - - def test_main_reports_a_rejected_host_without_a_traceback(self): - # serve_forever is stubbed so that a missing guard fails this test - # immediately. Left unstubbed, main() would bind 0.0.0.0 and block, and - # the regression would surface as a CI job timeout rather than a - # failure naming the cause. - with patch("oneagent.server.webbrowser.open") as browser, patch.object( - OneAgentHTTPServer, - "serve_forever", - side_effect=AssertionError("main() must reject a non-loopback host before serving"), - ): - with self.assertRaises(SystemExit) as exited: - server_main(["--host", "0.0.0.0"]) - self.assertIn("127.0.0.1", str(exited.exception)) - browser.assert_not_called() - - def test_missing_frontend_build_serves_a_readable_page(self): - # Running the GUI from a source checkout before `npm run build` must - # explain itself rather than surface a 404 or a traceback. - with tempfile.TemporaryDirectory() as empty: - with patch("oneagent.server.static_root", return_value=Path(empty)): - try: - self.opener.open(self.base + "/", timeout=2) - self.fail("expected HTTP 503") - except HTTPError as exc: - body = exc.read().decode() - self.assertEqual(exc.code, 503) - self.assertIn("frontend build is missing", body) - self.assertEqual(exc.headers["Cache-Control"], "no-store") - self.assertIn("oneagent_session=", exc.headers["Set-Cookie"]) - self.assertIn("HttpOnly", exc.headers["Set-Cookie"]) - exc.close() - - def test_post_requires_origin_and_session_cookie(self): - status, _, payload = self.post("/api/models", {"provider": "ppio", "api_key": "x"}, origin=self.origin) - self.assertEqual(status, 403) - self.assertEqual(payload["error_code"], "INVALID_ORIGIN") - self.bootstrap() - status, _, payload = self.post("/api/models", {"provider": "ppio", "api_key": "x"}, origin=False) - self.assertEqual(status, 403) - self.assertEqual(payload["error_code"], "INVALID_ORIGIN") - status, _, payload = self.post("/api/models", {"provider": "ppio", "api_key": "x"}, origin="http://127.0.0.1:1") - self.assertEqual(status, 403) - self.assertEqual(payload["error_code"], "INVALID_ORIGIN") - - def test_static_assets_have_safe_cache_and_traversal_is_rejected(self): - self.bootstrap() - with self.opener.open(self.base + "/assets/app-DiZRmN5o.js", timeout=2) as response: - self.assertIn("immutable", response.headers["Cache-Control"]) - self.assertEqual(response.headers["X-Content-Type-Options"], "nosniff") - with self.assertRaises(HTTPError) as blocked_type: - self.opener.open(self.base + "/assets/payload.bin", timeout=2) - self.assertEqual(blocked_type.exception.code, 404) - blocked_type.exception.close() - with self.assertRaises(HTTPError) as error: - self.opener.open(self.base + "/%2e%2e/agents.lock.json", timeout=2) - self.assertEqual(error.exception.code, 404) - error.exception.close() - - def test_json_body_rejects_invalid_negative_and_oversized_lengths(self): - class RejectRead: - def read(self, _length): - raise AssertionError("invalid Content-Length must be rejected before reading the body") - - for value in ["not-a-number", "-1", str(MAX_BODY_BYTES + 1)]: - with self.subTest(content_length=value): - handler = SimpleNamespace(headers={"Content-Length": value}, rfile=RejectRead()) - with self.assertRaises(OneAgentError): - read_json(handler) - - def test_register_urls_and_invalid_provider(self): - self.bootstrap() - status, _, payload = self.post("/api/open-register", {"provider": "ppio", "agents": ["codex"]}) - self.assertEqual(status, 200) - self.assertTrue(payload["url"].startswith("https://ppio.com/?")) - status, _, payload = self.post("/api/open-register", {"provider": "novita", "agents": ["aider"]}) - self.assertEqual(status, 200) - self.assertTrue(payload["url"].startswith("https://novita.ai/?")) - status, _, payload = self.post("/api/open-register", {"provider": "custom"}) - self.assertEqual(status, 400) - self.assertEqual(payload["error_code"], "INVALID_REQUEST") - - def test_probe_and_models_map_provider_failures(self): - provider = LocalHTTPServer(("127.0.0.1", 0), ProviderHandler) - with LocalServer(provider): - custom_base = f"http://127.0.0.1:{provider.server_port}" - self.bootstrap() - for key, expected in [("key-401", "API_KEY_REJECTED"), ("key-403", "API_KEY_REJECTED"), ("key-500", "PROVIDER_UNREACHABLE")]: - with self.subTest(key=key): - status, _, payload = self.post( - "/api/probe", - {"provider": "custom", "api_base_url": custom_base, "api_key": key, "model": "m"}, - ) - self.assertEqual(status, 200) - self.assertEqual(payload["error_code"], expected) - for key, expected in [("key-404", "MODELS_UNSUPPORTED"), ("key-405", "MODELS_UNSUPPORTED"), ("key-500", "PROVIDER_UNREACHABLE")]: - with self.subTest(key=key): - status, _, payload = self.post( - "/api/models", - {"provider": "custom", "api_base_url": custom_base, "api_key": key}, - ) - self.assertEqual(status, 200) - self.assertEqual(payload["error_code"], expected) - status, _, payload = self.post( - "/api/models", - {"provider": "custom", "api_base_url": custom_base, "api_key": "key-200"}, - ) - self.assertEqual(status, 200) - self.assertEqual(payload["models"], ["model-a", "model-b"]) - status, _, payload = self.post( - "/api/models", - {"provider": "custom", "api_base_url": custom_base, "api_key": "key-list"}, - ) - self.assertEqual(status, 200) - self.assertEqual(payload["models"], ["model-list"]) - - def test_probe_tests_the_protocol_of_each_selected_agent(self): - calls: list[tuple[str, str]] = [] - - def fake_probe(*, protocol, model, **_kwargs): - calls.append((protocol, model)) - # Mirror the real endpoint: the model serves chat but refuses Responses. - if protocol == "responses": - return { - "ok": False, "reachable": True, "protocol": protocol, "status": 400, - "message": "does not support OpenAI Responses", - "error_code": "PROTOCOL_UNSUPPORTED", "retryable": False, - } - return { - "ok": True, "reachable": True, "protocol": protocol, "status": 200, - "message": "passed", "error_code": None, "retryable": False, - } - - self.bootstrap() - with patch("oneagent.server.protocol_probe", side_effect=fake_probe): - # Codex alone -> Responses only, and the refusal must surface. - _, _, payload = self.post( - "/api/probe", - {"provider": "custom", "api_base_url": "https://x.test/v1", "api_key": "k", - "model": "glm", "agents": ["codex"]}, - ) - self.assertFalse(payload["ok"]) - self.assertEqual(payload["error_code"], "PROTOCOL_UNSUPPORTED") - self.assertEqual(sorted(payload["protocols"]), ["responses"]) - - calls.clear() - # OpenCode alone speaks Chat Completions, so the same model passes. - _, _, payload = self.post( - "/api/probe", - {"provider": "custom", "api_base_url": "https://x.test/v1", "api_key": "k", - "model": "glm", "agents": ["opencode"]}, - ) - self.assertTrue(payload["ok"]) - self.assertEqual(sorted(payload["protocols"]), ["openai"]) - - calls.clear() - # Mixed selection: every distinct protocol probed once, and the - # failing one is reported even though another succeeded. - _, _, payload = self.post( - "/api/probe", - {"provider": "custom", "api_base_url": "https://x.test/v1", "api_key": "k", - "model": "glm", "agents": ["codex", "opencode", "kilo-cli", "openclaw"]}, - ) - self.assertEqual(sorted(p for p, _ in calls), ["openai", "responses"]) - self.assertFalse(payload["ok"]) - self.assertEqual(payload["error_code"], "PROTOCOL_UNSUPPORTED") - - calls.clear() - # No agents -> unchanged OpenAI-compatible behaviour for older clients. - _, _, payload = self.post( - "/api/probe", - {"provider": "custom", "api_base_url": "https://x.test/v1", "api_key": "k", "model": "glm"}, - ) - self.assertEqual([p for p, _ in calls], ["openai"]) - self.assertTrue(payload["ok"]) - - def test_provider_timeout_is_retryable(self): - provider = LocalHTTPServer(("127.0.0.1", 0), ProviderHandler) - with LocalServer(provider), patch.dict(os.environ, {"ONEAGENT_HTTP_TIMEOUT": "0.05"}): - self.bootstrap() - status, _, payload = self.post( - "/api/probe", - { - "provider": "custom", - "api_base_url": f"http://127.0.0.1:{provider.server_port}", - "api_key": "key-timeout", - "model": "m", - }, - ) - self.assertEqual(status, 200) - self.assertEqual(payload["error_code"], "TIMEOUT") - self.assertTrue(payload["retryable"]) - - def test_install_writes_profile_without_leaking_key(self): - self.bootstrap() - secret = "sentinel-api-secret" - status, _, payload = self.post( - "/api/install", - { - "agents": ["codex", "openclaw"], - "provider": "ppio", - "api_key": secret, - "model": "model-a", - "configure": True, - "install_agent": False, - "skip_test": True, - }, - ) - self.assertEqual(status, 200) - self.assertTrue(payload["ok"]) - self.assertNotIn(secret, json.dumps(payload)) - profile = json.loads((self.home / ".oneagent" / "profiles" / "default.json").read_text(encoding="utf-8")) - self.assertEqual(profile["agent_ids"], ["codex", "openclaw"]) - self.assertNotIn(secret, json.dumps(profile)) - - def test_existing_account_mode_runs_agent_workflow_and_writes_profile(self): - self.bootstrap() - status, _, payload = self.post( - "/api/install", - { - "agents": ["codex"], - "configure": False, - "install_agent": False, - }, - ) - self.assertEqual(status, 200) - self.assertTrue(payload["ok"]) - self.assertEqual(payload["results"][0]["status"], "skipped") - self.assertIn("Model configuration skipped", payload["log"]) - profile = json.loads((self.home / ".oneagent" / "profiles" / "default.json").read_text(encoding="utf-8")) - self.assertEqual(profile["config_mode"], "existing-account") - self.assertEqual(profile["provider"], "existing-account") - self.assertEqual(profile["agent_ids"], ["codex"]) - self.assertIsNone(profile["base_url"]) - self.assertIsNone(profile["model"]) - self.assertFalse((self.home / ".oneagent" / "env").exists()) - - def test_unknown_agent_is_invalid_request(self): - self.bootstrap() - status, _, payload = self.post( - "/api/install", - {"agents": ["unknown"], "configure": False, "install_agent": False}, - ) - self.assertEqual(status, 400) - self.assertEqual(payload["error_code"], "INVALID_REQUEST") - - def test_install_request_types_and_version_modes_are_strict(self): - self.bootstrap() - for payload in ( - {"agents": [], "configure": False}, - {"agents": ["codex"], "configure": "false"}, - {"agents": ["codex"], "configure": False, "install_agent": 1}, - {"agents": ["codex"], "configure": False, "timeout": 0}, - {"agents": ["codex"], "configure": False, "locked_version": True, "latest": True}, - {"agents": ["codex"], "profile_agents": ["opencode"], "configure": False}, - ): - with self.subTest(payload=payload): - status, _, response = self.post("/api/install", payload) - self.assertEqual(status, 400) - self.assertEqual(response["error_code"], "INVALID_REQUEST") - - def test_register_rejects_unknown_agent(self): - self.bootstrap() - status, _, payload = self.post("/api/open-register", {"provider": "ppio", "agents": ["unknown"]}) - self.assertEqual(status, 400) - self.assertEqual(payload["error_code"], "INVALID_REQUEST") - - def test_profiles_endpoint_lists_saved_profiles_without_key_material(self): - self.bootstrap() - status, _, body = request_json(self.opener, self.base + "/api/profiles") - self.assertEqual(status, 200) - self.assertEqual(body, {"ok": True, "profiles": [], "activeProfile": None}) - - secret = "sentinel-profile-key" - status, _, payload = self.post( - "/api/profiles", - { - "provider": "ppio", - "model": "deepseek-v3", - "agents": ["codex", "opencode"], - "api_key": secret, - "label": "PPIO DeepSeek", - }, - ) - self.assertEqual(status, 200) - self.assertEqual(payload["profile"]["id"], "ppio-deepseek-v3") - self.assertTrue(payload["profile"]["hasKey"]) - self.assertNotIn(secret, json.dumps(payload)) - - # Saving stores a profile but does not activate it. - status, _, body = request_json(self.opener, self.base + "/api/profiles") - self.assertEqual(status, 200) - self.assertEqual([item["id"] for item in body["profiles"]], ["ppio-deepseek-v3"]) - self.assertEqual(body["profiles"][0]["agentIds"], ["codex", "opencode"]) - self.assertIsNone(body["activeProfile"]) - self.assertNotIn(secret, json.dumps(body)) - - def test_activate_endpoint_repoints_one_agent_at_a_time(self): - self.bootstrap() - secret = "sentinel-activate-key" - status, _, first = self.post( - "/api/agents/codex/activate", - {"provider": "ppio", "model": "deepseek-v3", "api_key": secret}, - ) - self.assertEqual(status, 200) - self.assertEqual(first["agent"], "codex") - self.assertEqual(first["provider"], "ppio") - self.assertTrue(first["restart"]) - self.assertNotIn(secret, json.dumps(first)) - - status, _, second = self.post( - "/api/agents/opencode/activate", - {"provider": "novita", "model": "qwen-max", "api_key": "other-key"}, - ) - self.assertEqual(status, 200) - - # Status reports the two Agents independently. - status, _, body = request_json(self.opener, self.base + "/api/status") - self.assertEqual(body["agents"]["codex"]["provider"], "ppio") - self.assertEqual(body["agents"]["opencode"]["provider"], "novita") - self.assertNotIn(secret, json.dumps(body)) - - def test_activate_endpoint_rejects_bad_agents_and_enforces_origin(self): - self.bootstrap() - for agent_id, expected in (("cursor", 400), ("nope", 400), ("..%2Fescape", 400)): - with self.subTest(agent=agent_id): - status, _, body = self.post( - f"/api/agents/{agent_id}/activate", - {"provider": "ppio", "model": "m", "api_key": "k"}, - ) - self.assertEqual(status, expected) - self.assertFalse(body["ok"]) - # A route that does not match the shape stays a 404, not a activation. - status, _, _ = self.post("/api/agents//activate", {"provider": "ppio"}) - self.assertEqual(status, 404) - - def test_profiles_endpoint_validates_input(self): - self.bootstrap() - for payload in ( - {"provider": "ppio", "agents": ["codex"]}, # no model - {"provider": "ppio", "model": "m"}, # no agents - {"provider": "nope", "model": "m", "agents": ["codex"]}, # unknown provider - {"profile_id": "../x", "provider": "ppio", "model": "m", "agents": ["codex"]}, - ): - with self.subTest(payload=payload): - status, _, response = self.post("/api/profiles", payload) - self.assertEqual(status, 400) - self.assertEqual(response["error_code"], "INVALID_REQUEST") - - def test_install_activates_the_default_profile(self): - self.bootstrap() - status, _, payload = self.post( - "/api/install", - { - "agents": ["codex"], - "provider": "ppio", - "api_key": "sentinel-install-key", - "model": "m", - "install_agent": False, - "skip_test": True, - }, - ) - self.assertEqual(status, 200) - self.assertTrue(payload["ok"]) - status, _, body = request_json(self.opener, self.base + "/api/profiles") - self.assertEqual(status, 200) - self.assertEqual(body["activeProfile"], "default") - self.assertEqual([item["id"] for item in body["profiles"]], ["default"]) - self.assertTrue(body["profiles"][0]["hasKey"]) - self.assertNotIn("sentinel-install-key", json.dumps(body)) - - -if __name__ == "__main__": - unittest.main() From 5f7f9ea0b7319de11f683baa042bd72f2bccfd61 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:52:33 +0800 Subject: [PATCH 43/63] feat: show real time installation log --- cmd/oneagent-desktop/main_wails.go | 12 ++++++-- frontend/src/backend/wails.test.ts | 21 ++++++++++++- frontend/src/backend/wails.ts | 15 +++++++++ frontend/src/components/LogDisclosure.tsx | 9 ++++-- frontend/src/pages/ActivationPage.tsx | 4 ++- frontend/src/state/wizardReducer.test.ts | 8 +++++ frontend/src/state/wizardReducer.ts | 14 +++++++++ frontend/src/types/api.ts | 4 +++ internal/app/install.go | 13 ++++++++ internal/binding/services.go | 8 +++-- internal/install/runtime.go | 12 +++++++- internal/process/process.go | 37 +++++++++++++++++++++-- internal/process/process_test.go | 14 +++++++++ 13 files changed, 159 insertions(+), 12 deletions(-) diff --git a/cmd/oneagent-desktop/main_wails.go b/cmd/oneagent-desktop/main_wails.go index e3a0684e..0aae2faa 100644 --- a/cmd/oneagent-desktop/main_wails.go +++ b/cmd/oneagent-desktop/main_wails.go @@ -11,12 +11,13 @@ import ( oneagent "github.com/MaimoryLab/OneAgent" "github.com/MaimoryLab/OneAgent/internal/binding" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/process" "github.com/wailsapp/wails/v3/pkg/application" ) func main() { - core := newDesktopUseCases() var appInstance *application.App + core := newDesktopUseCases() var nativeSmokeOnce sync.Once var afterGetStatus func() if os.Getenv("ONEAGENT_NATIVE_SMOKE") == "1" { @@ -39,7 +40,14 @@ func main() { return oneerrors.New(oneerrors.InternalError, "Desktop browser is not ready") } return current.Browser.OpenURL(url) - }, binding.ServicesOptions{AfterGetStatus: afterGetStatus}) + }, binding.ServicesOptions{ + AfterGetStatus: afterGetStatus, + InstallOutput: func(output process.Output) { + if appInstance != nil { + appInstance.Event.Emit("oneagent:install-output", output) + } + }, + }) // No Route or RawMessageHandler is configured. The default Wails transport // is internal IPC; the production app does not expose a business HTTP port. diff --git a/frontend/src/backend/wails.test.ts b/frontend/src/backend/wails.test.ts index b3a6fa73..f1b648dc 100644 --- a/frontend/src/backend/wails.test.ts +++ b/frontend/src/backend/wails.test.ts @@ -11,8 +11,10 @@ const bridge = vi.hoisted(() => ({ activate: vi.fn(), profiles: vi.fn(), saveProfile: vi.fn(), + eventsOn: vi.fn(), })); +vi.mock("@wailsio/runtime", () => ({ Events: { On: bridge.eventsOn } })); vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/statusservice.js", () => ({ GetStatus: bridge.status })); vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.js", () => ({ Probe: bridge.probe, @@ -28,7 +30,7 @@ vi.mock("../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profiles SaveProfile: bridge.saveProfile, })); -import { normalizeWailsError, wailsApi } from "./wails"; +import { INSTALL_OUTPUT_EVENT, normalizeWailsError, onInstallOutput, wailsApi } from "./wails"; describe("Wails backend adapter", () => { afterEach(() => vi.resetAllMocks()); @@ -82,4 +84,21 @@ describe("Wails backend adapter", () => { message: "无法调用本机 OneAgent 服务", code: "INTERNAL_ERROR", status: 500, retryable: true, }); }); + + it("subscribes to and filters installation output events", () => { + const unsubscribe = vi.fn(); + const listener = vi.fn(); + bridge.eventsOn.mockImplementation((_name, callback) => { + callback({ data: { kind: "command", args: ["npm"] } }); + callback({ data: { kind: "output", stream: "stdout", text: "ready" } }); + callback({ data: null }); + callback({ data: "ignored" }); + callback({ data: { kind: "other" } }); + return unsubscribe; + }); + + expect(onInstallOutput(listener)).toBe(unsubscribe); + expect(bridge.eventsOn).toHaveBeenCalledWith(INSTALL_OUTPUT_EVENT, expect.any(Function)); + expect(listener).toHaveBeenCalledTimes(2); + }); }); diff --git a/frontend/src/backend/wails.ts b/frontend/src/backend/wails.ts index ff676c42..a1660261 100644 --- a/frontend/src/backend/wails.ts +++ b/frontend/src/backend/wails.ts @@ -1,3 +1,5 @@ +import { Events } from "@wailsio/runtime"; + import * as AgentService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/agentservice.js"; import * as ProfileService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/profileservice.js"; import * as ProviderService from "../../bindings/github.com/MaimoryLab/OneAgent/internal/binding/providerservice.js"; @@ -5,6 +7,7 @@ import * as StatusService from "../../bindings/github.com/MaimoryLab/OneAgent/in import type { ActivateAgentResponse, InstallRequest, + InstallOutput, InstallResponse, ModelsResponse, OpenRegistrationResponse, @@ -17,6 +20,17 @@ import { OneAgentApiError } from "./errors"; export { OneAgentApiError, describeError } from "./errors"; +export const INSTALL_OUTPUT_EVENT = "oneagent:install-output"; + +export function onInstallOutput(listener: (output: InstallOutput) => void): () => void { + return Events.On(INSTALL_OUTPUT_EVENT, (event) => { + const data = event.data; + if (!data || typeof data !== "object") return; + const kind = (data as { kind?: unknown }).kind; + if (kind === "command" || kind === "output") listener(data as InstallOutput); + }); +} + type ErrorCause = Record; function causeOf(error: unknown): ErrorCause { @@ -62,6 +76,7 @@ async function call(operation: () => PromiseLike): Promise { } export const wailsApi = { + onInstallOutput, status: (): Promise => call(() => StatusService.GetStatus()) as Promise, probe: (input: { provider: ProviderId; apiBaseUrl: string; apiKey: string; model: string; agents?: string[] }): Promise => call(() => ProviderService.Probe({ diff --git a/frontend/src/components/LogDisclosure.tsx b/frontend/src/components/LogDisclosure.tsx index 08cfd0a1..3abbc030 100644 --- a/frontend/src/components/LogDisclosure.tsx +++ b/frontend/src/components/LogDisclosure.tsx @@ -1,8 +1,11 @@ import { ChevronDown, TerminalSquare } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; -export function LogDisclosure({ log }: { log: string }) { - const [open, setOpen] = useState(false); +export function LogDisclosure({ log, open: openByParent = false }: { log: string; open?: boolean }) { + const [open, setOpen] = useState(openByParent); + useEffect(() => { + if (openByParent) setOpen(true); + }, [openByParent]); if (!log) return null; return (
diff --git a/frontend/src/pages/ActivationPage.tsx b/frontend/src/pages/ActivationPage.tsx index ece82ddf..ac1aa5a2 100644 --- a/frontend/src/pages/ActivationPage.tsx +++ b/frontend/src/pages/ActivationPage.tsx @@ -57,6 +57,8 @@ export function ActivationPage() { } }, [dispatch, refreshStatus, requestFor, secret, state.selectedAgentIds]); + useEffect(() => api.onInstallOutput((output) => dispatch({ type: "ACTIVATION_OUTPUT", output })), [dispatch]); + useEffect(() => { // Runs only for an explicit request from the review page. Returning here // via browser back keeps activationRequested false, so a mount alone never @@ -126,7 +128,7 @@ export function ActivationPage() {
{state.activationNext}
) : null} - + ); } diff --git a/frontend/src/state/wizardReducer.test.ts b/frontend/src/state/wizardReducer.test.ts index 867bd672..ed60010a 100644 --- a/frontend/src/state/wizardReducer.test.ts +++ b/frontend/src/state/wizardReducer.test.ts @@ -155,6 +155,14 @@ describe("wizardReducer", () => { expect(state.activationState).toBe("loading"); }); + it("appends installation commands and output as it arrives", () => { + let state = wizardReducer(initialWizardState, { type: "ACTIVATION_LOADING", agentIds: ["codex"] }); + state = wizardReducer(state, { type: "ACTIVATION_OUTPUT", output: { kind: "command", args: ["npm", "install", "-g", "agent@1.0.0"] } }); + state = wizardReducer(state, { type: "ACTIVATION_OUTPUT", output: { kind: "output", stream: "stdout", text: "fetching\n" } }); + state = wizardReducer(state, { type: "ACTIVATION_OUTPUT", output: { kind: "output", stream: "stderr", text: "warning\n" } }); + expect(state.activationLog).toBe("$ npm install -g agent@1.0.0\nfetching\nwarning\n"); + }); + it("merges retry results and preserves non-secret activation summary", () => { let state = wizardReducer(initialWizardState, { type: "ACTIVATION_LOADING", agentIds: ["codex", "opencode"] }); expect(state.activationResults).toHaveLength(2); diff --git a/frontend/src/state/wizardReducer.ts b/frontend/src/state/wizardReducer.ts index e8dc8685..6484f518 100644 --- a/frontend/src/state/wizardReducer.ts +++ b/frontend/src/state/wizardReducer.ts @@ -5,6 +5,7 @@ import type { ProbeResponse, ProviderId, StatusResponse, + InstallOutput, } from "../types/api"; export type ConfigMode = "provider" | "existing-account" | null; @@ -79,6 +80,7 @@ export type WizardAction = | { type: "SET_MODEL"; value: string } | { type: "REQUEST_ACTIVATION" } | { type: "ACTIVATION_LOADING"; agentIds: string[] } + | { type: "ACTIVATION_OUTPUT"; output: InstallOutput } | { type: "ACTIVATION_RESULT"; results: AgentInstallResult[]; @@ -103,6 +105,16 @@ function mergeResults( ]; } +function formatCommand(args: string[]): string { + return args.map((arg) => (/^[A-Za-z0-9_./:@%+=,-]+$/.test(arg) ? arg : JSON.stringify(arg))).join(" "); +} + +function appendActivationOutput(log: string, output: InstallOutput): string { + const text = output.kind === "command" ? `$ ${formatCommand(output.args)}\n` : output.text; + if (!text) return log; + return `${log}${output.kind === "command" && log && !log.endsWith("\n") ? "\n" : ""}${text}`; +} + export function wizardReducer(state: WizardState, action: WizardAction): WizardState { switch (action.type) { case "STATUS_LOADING": @@ -215,6 +227,8 @@ export function wizardReducer(state: WizardState, action: WizardAction): WizardS ? state.activationResults : action.agentIds.map((agent) => ({ agent, status: "skipped", retryable: false })), }; + case "ACTIVATION_OUTPUT": + return { ...state, activationLog: appendActivationOutput(state.activationLog, action.output) }; case "ACTIVATION_RESULT": { const activationResults = mergeResults(state.activationResults, action.results, action.replaceAgents); return { diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index f0a6a5be..e88b961d 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -80,6 +80,10 @@ export type InstallResponse = Omit>; }; +export type InstallOutput = + | { kind: "command"; args: string[] } + | { kind: "output"; stream: "stdout" | "stderr"; text: string }; + export type InstallRequest = Pick< BindingModels.InstallRequest, "api_key" | "model" | "configure" | "install_agent" | "skip_test" diff --git a/internal/app/install.go b/internal/app/install.go index adb05409..741a1ce3 100644 --- a/internal/app/install.go +++ b/internal/app/install.go @@ -13,6 +13,7 @@ import ( configWriter "github.com/MaimoryLab/OneAgent/internal/config" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" "github.com/MaimoryLab/OneAgent/internal/install" + "github.com/MaimoryLab/OneAgent/internal/process" profileStore "github.com/MaimoryLab/OneAgent/internal/profile" "github.com/MaimoryLab/OneAgent/internal/provider" ) @@ -36,6 +37,7 @@ type InstallAgentsOptions struct { Latest bool Timeout time.Duration Registry string + Output process.OutputListener // ProfileID is optional. It is used only as the binding's profile reference; // the active profile store keeps its existing/default id semantics. ProfileID string @@ -134,6 +136,7 @@ func (u *UseCases) InstallAgents(ctx context.Context, options InstallAgentsOptio options: options, providerName: providerName, probes: probes, + output: options.Output, } for _, agentID := range options.Agents { run.step(ctx, agentID) @@ -303,6 +306,7 @@ type installRun struct { options InstallAgentsOptions providerName string probes map[string]provider.ProbeResult + output process.OutputListener results []AgentInstallResult logs []string nextSteps []string @@ -329,6 +333,15 @@ func (r *installRun) step(ctx context.Context, agentID string) { func (r *installRun) configure(ctx context.Context, agentID string, agent catalog.Agent) error { runtime := install.NewRuntime(r.core.status.Home, r.core.status.Platform, r.core.runner, r.core.environment) + runtime.OnOutput = func(output process.Output) { + output.Text = install.Redact(output.Text, []string{r.options.APIKey}) + for index, argument := range output.Args { + output.Args[index] = install.Redact(argument, []string{r.options.APIKey}) + } + if r.output != nil { + r.output(output) + } + } installed := install.Result{Version: install.InstalledVersion(ctx, runtime, agent)} if agent.Package != nil { installed.LockedVersion = agent.Package.Version diff --git a/internal/binding/services.go b/internal/binding/services.go index dd9ed1d5..be382f20 100644 --- a/internal/binding/services.go +++ b/internal/binding/services.go @@ -11,6 +11,7 @@ import ( "github.com/MaimoryLab/OneAgent/internal/app" "github.com/MaimoryLab/OneAgent/internal/catalog" oneerrors "github.com/MaimoryLab/OneAgent/internal/errors" + "github.com/MaimoryLab/OneAgent/internal/process" "github.com/MaimoryLab/OneAgent/internal/provider" ) @@ -23,6 +24,7 @@ type Services struct { type ServicesOptions struct { AfterGetStatus func() + InstallOutput process.OutputListener } func NewServices(core *app.UseCases, opener BrowserOpener) *Services { @@ -33,7 +35,7 @@ func NewServicesWithOptions(core *app.UseCases, opener BrowserOpener, options Se return &Services{ Status: &StatusService{core: core, afterGetStatus: options.AfterGetStatus}, Provider: NewProviderService(core, opener), - Agent: NewAgentService(core), + Agent: &AgentService{core: core, onOutput: options.InstallOutput}, Profile: NewProfileService(core), } } @@ -126,7 +128,8 @@ func (s *ProviderService) OpenRegistration(ctx context.Context, request OpenRegi } type AgentService struct { - core *app.UseCases + core *app.UseCases + onOutput process.OutputListener } func NewAgentService(core *app.UseCases) *AgentService { @@ -164,6 +167,7 @@ func (s *AgentService) Install(ctx context.Context, request InstallRequest) (Ins Latest: request.Latest, Timeout: timeout, Registry: request.Registry, + Output: s.onOutput, }) if err != nil { return InstallResponse{}, err diff --git a/internal/install/runtime.go b/internal/install/runtime.go index 4a0faa46..d4281927 100644 --- a/internal/install/runtime.go +++ b/internal/install/runtime.go @@ -24,6 +24,7 @@ type Runtime struct { Platform platform.Info Env map[string]string Runner process.Runner + OnOutput process.OutputListener } func NewRuntime(home string, info platform.Info, runner process.Runner, env map[string]string) Runtime { @@ -55,7 +56,16 @@ func (r Runtime) command(ctx context.Context, argv []string, env map[string]stri } overrides := cloneEnv(r.Env) maps.Copy(overrides, env) - result, err := r.Runner.Run(ctx, argv, overrides, timeout) + if r.OnOutput != nil { + r.OnOutput(process.Output{Kind: "command", Args: append([]string(nil), argv...)}) + } + var result process.Result + var err error + if runner, ok := r.Runner.(process.StreamingRunner); ok { + result, err = runner.RunWithOutput(ctx, argv, overrides, timeout, r.OnOutput) + } else { + result, err = r.Runner.Run(ctx, argv, overrides, timeout) + } if err == nil { return result, nil } diff --git a/internal/process/process.go b/internal/process/process.go index 8f7c7145..da52b898 100644 --- a/internal/process/process.go +++ b/internal/process/process.go @@ -25,6 +25,19 @@ type Result struct { Stderr string } +type Output struct { + Kind string `json:"kind"` + Args []string `json:"args,omitempty"` + Stream string `json:"stream,omitempty"` + Text string `json:"text,omitempty"` +} + +type OutputListener func(Output) + +type StreamingRunner interface { + RunWithOutput(context.Context, []string, map[string]string, time.Duration, OutputListener) (Result, error) +} + // Runner is deliberately small so install tests can assert exact argv and // environment without starting a process. type Runner interface { @@ -56,6 +69,10 @@ func (r OSRunner) LookPath(command string) (string, bool) { } func (r OSRunner) Run(ctx context.Context, argv []string, overrides map[string]string, timeout time.Duration) (Result, error) { + return r.RunWithOutput(ctx, argv, overrides, timeout, nil) +} + +func (r OSRunner) RunWithOutput(ctx context.Context, argv []string, overrides map[string]string, timeout time.Duration, listener OutputListener) (Result, error) { result := Result{Args: append([]string(nil), argv...), ExitCode: -1} if len(argv) == 0 || strings.TrimSpace(argv[0]) == "" { return result, fmt.Errorf("process argv must not be empty") @@ -73,8 +90,8 @@ func (r OSRunner) Run(ctx context.Context, argv []string, overrides map[string]s command.Env = mergeEnvironment(r.Env, overrides) stdout := &boundedBuffer{limit: MaxOutputBytes} stderr := &boundedBuffer{limit: MaxOutputBytes} - command.Stdout = stdout - command.Stderr = stderr + command.Stdout = &streamWriter{stream: "stdout", buffer: stdout, listener: listener} + command.Stderr = &streamWriter{stream: "stderr", buffer: stderr, listener: listener} err := command.Run() result.Stdout = stdout.String() result.Stderr = stderr.String() @@ -97,6 +114,22 @@ func (r OSRunner) Run(ctx context.Context, argv []string, overrides map[string]s return result, nil } +type streamWriter struct { + stream string + buffer *boundedBuffer + listener OutputListener +} + +func (w *streamWriter) Write(data []byte) (int, error) { + before := w.buffer.buffer.Len() + n, err := w.buffer.Write(data) + accepted := w.buffer.buffer.Len() - before + if w.listener != nil && accepted > 0 { + w.listener(Output{Kind: "output", Stream: w.stream, Text: string(data[:accepted])}) + } + return n, err +} + type boundedBuffer struct { buffer bytes.Buffer limit int diff --git a/internal/process/process_test.go b/internal/process/process_test.go index f1745307..ea06832b 100644 --- a/internal/process/process_test.go +++ b/internal/process/process_test.go @@ -61,6 +61,20 @@ func TestOSRunnerUsesArgvAndMergesEnvironment(t *testing.T) { } } +func TestOSRunnerStreamsOutputWithoutChangingResult(t *testing.T) { + runner := helperRunner(t) + outputs := make([]Output, 0) + result, err := runner.RunWithOutput(context.Background(), []string{os.Args[0], "-test.run=TestProcessHelper"}, map[string]string{ + "ONEAGENT_PROCESS_VALUE": "stream-value", + }, helperTimeout, func(output Output) { outputs = append(outputs, output) }) + if err != nil || result.ExitCode != 0 || result.Stdout != "stream-value" { + t.Fatalf("streamed process result = %#v, err=%v", result, err) + } + if len(outputs) != 1 || outputs[0].Kind != "output" || outputs[0].Stream != "stdout" || outputs[0].Text != "stream-value" { + t.Fatalf("streamed outputs = %#v", outputs) + } +} + func TestOSRunnerReturnsExitCodeAndCapturesOutput(t *testing.T) { runner := helperRunner(t) result, err := runner.Run(context.Background(), []string{os.Args[0], "-test.run=TestProcessHelper"}, map[string]string{ From d0cc29c87d9e4e3b70158c1d69c56b5b9fb7f4d7 Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:49:30 +0800 Subject: [PATCH 44/63] fix: fetch version info --- .claude/launch.json | 11 ++++++ .gitignore | 3 +- Taskfile.yml | 62 ++++++++++++++++++++++----------- build/Taskfile.yml | 68 ++++++++++++++++++++++++++++++++++--- build/config.yml | 14 ++++---- build/darwin/Taskfile.yml | 48 ++++++++++++++++++++++++++ build/linux/Taskfile.yml | 30 ++++++++++++++++ build/windows/Taskfile.yml | 27 +++++++++++++++ frontend/vite.config.ts | 10 ++++-- internal/app/status.go | 35 ++++++++++++++++++- internal/app/status_test.go | 30 ++++++++++++++++ 11 files changed, 302 insertions(+), 36 deletions(-) create mode 100644 .claude/launch.json create mode 100644 build/darwin/Taskfile.yml create mode 100644 build/linux/Taskfile.yml create mode 100644 build/windows/Taskfile.yml diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..20010516 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "vite-dev", + "runtimeExecutable": "sh", + "runtimeArgs": ["-c", "cd frontend && pnpm run dev --port 9245 --strictPort"], + "port": 9245 + } + ] +} diff --git a/.gitignore b/.gitignore index 184a859c..d4f803dd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ __pycache__/ .coverage .venv/ htmlcov/ -build/ dist/ release/ frontend/node_modules/ @@ -37,3 +36,5 @@ site/playwright-report/ # 把只有本机才有的产物校验和写进仓库。 site/src/generated/ site/public/downloads/ + +.task \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index ac3b7e4a..5d12aa0d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,10 +1,43 @@ version: '3' vars: + APP_NAME: oneagent-desktop + BIN_DIR: bin + VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' + # Target OS for build/run. Defaults to the host OS; override with GOOS=... to + # cross-compile (only windows supports cross-compilation, see build/*/Taskfile.yml). + GOOS: '{{.GOOS | default OS}}' WAILS_MODULE: github.com/wailsapp/wails/v3 WAILS_VERSION: v3.0.0-alpha2.119 + # Pinned Wails CLI (see build/tool-versions.env); never rely on a global wails3. + WAILS3: go run {{.WAILS_MODULE}}/cmd/wails3@{{.WAILS_VERSION}} + +includes: + common: ./build/Taskfile.yml + darwin: ./build/darwin/Taskfile.yml + linux: ./build/linux/Taskfile.yml + windows: ./build/windows/Taskfile.yml tasks: + build: + summary: Builds the desktop application for {{.GOOS}} + cmds: + - task: '{{.GOOS}}:build' + vars: + ARCH: '{{.ARCH}}' + DEV: '{{.DEV}}' + OUTPUT: '{{.OUTPUT}}' + + run: + summary: Runs the built desktop application + cmds: + - task: '{{.GOOS}}:run' + + dev: + summary: Runs the application in development mode (vite HMR + Go rebuild on change) + cmds: + - '{{.WAILS3}} dev -config ./build/config.yml -port {{.VITE_PORT}}' + test:go: summary: Run the Python-independent Go migration tests cmds: @@ -13,45 +46,34 @@ tasks: build:cli: summary: Build the headless Go CLI cmds: - - mkdir -p bin - - go build -o bin/oneagent ./cmd/oneagent + - mkdir -p {{.BIN_DIR}} + - go build -o {{.BIN_DIR}}/oneagent ./cmd/oneagent generate:bindings: summary: Regenerate Wails TypeScript bindings cmds: - - go run {{.WAILS_MODULE}}/cmd/wails3@{{.WAILS_VERSION}} generate bindings -f "-tags wails" -ts -i -d frontend/bindings ./cmd/oneagent-desktop + - task: common:generate:bindings build:frontend: - summary: Build the existing React frontend for the migration shell - dir: frontend + summary: Build the React frontend cmds: - - npm ci - - npm run build + - task: common:build:frontend build:desktop: - summary: Build the opt-in Wails desktop shell - deps: - - build:frontend - - generate:bindings + summary: Build the production Wails desktop binary for the host OS cmds: - - mkdir -p bin - - go build -tags "wails,production" -o bin/oneagent-desktop ./cmd/oneagent-desktop + - task: build test:e2e: summary: Run the Wails server binding browser test deps: - build:frontend cmds: - - cd frontend && npm run test:e2e + - cd frontend && pnpm run test:e2e test:native: summary: Start the native Wails binary and require a GetStatus binding call deps: - build:desktop cmds: - - go run ./cmd/oneagent-native-smoke -binary ./bin/oneagent-desktop - - dev:desktop: - summary: Run the Wails development shell - cmds: - - go run {{.WAILS_MODULE}}/cmd/wails3@{{.WAILS_VERSION}} dev -config build/config.yml + - go run ./cmd/oneagent-native-smoke -binary ./{{.BIN_DIR}}/{{.APP_NAME}} diff --git a/build/Taskfile.yml b/build/Taskfile.yml index 1a6c04f2..2eee4b7a 100644 --- a/build/Taskfile.yml +++ b/build/Taskfile.yml @@ -1,12 +1,70 @@ version: '3' tasks: - test:go: - summary: Run the Go migration tests + go:mod:tidy: + summary: Runs `go mod tidy` + internal: true + # darwin:build:universal runs its per-arch builds as parallel deps; two + # `go mod tidy` processes racing on go.mod can corrupt it. + run: once cmds: - - go test ./... + - go mod tidy + + install:frontend:deps: + summary: Install frontend dependencies (pnpm only) + run: once + dir: frontend + sources: + - package.json + - pnpm-lock.yaml + generates: + - node_modules + preconditions: + - sh: pnpm --version + msg: "pnpm not found. Install it first: https://pnpm.io/installation" + cmds: + - pnpm install + + build:frontend: + summary: Build the React frontend (tsc --noEmit runs as part of the build script) + # Universal builds run per-arch builds as parallel deps, each depending on + # this task; without run:once the two executions race on frontend/bindings. + run: once + dir: frontend + sources: + - "**/*" + - exclude: node_modules/**/* + generates: + - dist/**/* + deps: + - task: install:frontend:deps + - task: generate:bindings + cmds: + - pnpm run build + env: + PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}' generate:bindings: - summary: Generate bindings using the pinned Wails version + summary: Regenerate Wails TypeScript bindings with the pinned Wails CLI + run: once + deps: + - task: go:mod:tidy + sources: + - "**/*.go" + - go.mod + - go.sum + - exclude: frontend/**/* + generates: + - frontend/bindings/**/* + cmds: + - '{{.WAILS3}} generate bindings -f "-tags wails" -ts -i -d frontend/bindings ./cmd/oneagent-desktop' + + dev:frontend: + summary: Runs the Vite dev server (used by `task dev`) + deps: + - task: install:frontend:deps + dir: frontend cmds: - - go run github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.119 generate bindings -f "-tags wails" -ts -i -d frontend/bindings ./cmd/oneagent-desktop + # Vite 8 binds to [::1] only by default, but the Wails dev asset proxy + # dials localhost with forced IPv4 — bind 127.0.0.1 so they can meet. + - pnpm run dev --host 127.0.0.1 --port {{.VITE_PORT}} --strictPort diff --git a/build/config.yml b/build/config.yml index f0478df1..a0d1ce0a 100644 --- a/build/config.yml +++ b/build/config.yml @@ -15,8 +15,8 @@ dev_mode: ignore: dir: - .git - - frontend/node_modules - - frontend/dist + - node_modules + - frontend - bin file: - .DS_Store @@ -27,9 +27,11 @@ dev_mode: - "*.go" git_ignore: true executes: - - cmd: task build:frontend + # Dev build regenerates bindings and the frontend dist via task deps; the + # running app itself loads the frontend from the background Vite server. + - cmd: task build DEV=true type: blocking - - cmd: task generate:bindings - type: blocking - - cmd: go run -tags wails ./cmd/oneagent-desktop + - cmd: task common:dev:frontend + type: background + - cmd: task run type: primary diff --git a/build/darwin/Taskfile.yml b/build/darwin/Taskfile.yml new file mode 100644 index 00000000..8f9f8383 --- /dev/null +++ b/build/darwin/Taskfile.yml @@ -0,0 +1,48 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: Builds the macOS desktop binary (native only, no Docker cross-compilation) + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + DEV: + ref: .DEV + preconditions: + - sh: test "$(uname -s)" = "Darwin" + msg: "macOS binaries require CGO and must be built on macOS." + cmds: + - go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}" ./cmd/oneagent-desktop + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}-tags wails -buildvcs=false -gcflags=all="-l"{{else}}-tags wails,production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}' + OUTPUT: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .APP_NAME)}}' + env: + GOOS: darwin + CGO_ENABLED: 1 + GOARCH: '{{.ARCH | default ARCH}}' + CGO_CFLAGS: "-mmacosx-version-min=12.0" + CGO_LDFLAGS: "-mmacosx-version-min=12.0" + MACOSX_DEPLOYMENT_TARGET: "12.0" + + build:universal: + summary: Builds a darwin universal binary (arm64 + amd64) + deps: + - task: build + vars: + ARCH: amd64 + OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}-amd64' + - task: build + vars: + ARCH: arm64 + OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}-arm64' + cmds: + - lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + - rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64" + + run: + cmds: + - '"{{.BIN_DIR}}/{{.APP_NAME}}"' diff --git a/build/linux/Taskfile.yml b/build/linux/Taskfile.yml new file mode 100644 index 00000000..64c9bfa2 --- /dev/null +++ b/build/linux/Taskfile.yml @@ -0,0 +1,30 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: Builds the Linux desktop binary (native only, no Docker cross-compilation) + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + DEV: + ref: .DEV + preconditions: + - sh: test "$(uname -s)" = "Linux" + msg: "Linux binaries require CGO (WebKitGTK) and must be built on Linux." + cmds: + - go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}" ./cmd/oneagent-desktop + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}-tags wails -buildvcs=false -gcflags=all="-l"{{else}}-tags wails,production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}' + OUTPUT: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .APP_NAME)}}' + env: + GOOS: linux + CGO_ENABLED: 1 + GOARCH: '{{.ARCH | default ARCH}}' + + run: + cmds: + - '"{{.BIN_DIR}}/{{.APP_NAME}}"' diff --git a/build/windows/Taskfile.yml b/build/windows/Taskfile.yml new file mode 100644 index 00000000..444b38be --- /dev/null +++ b/build/windows/Taskfile.yml @@ -0,0 +1,27 @@ +version: '3' + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: Builds the Windows desktop binary (pure-Go cross-compile works from any host) + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + vars: + DEV: + ref: .DEV + cmds: + - go build {{.BUILD_FLAGS}} -o "{{.OUTPUT}}" ./cmd/oneagent-desktop + vars: + BUILD_FLAGS: '{{if eq .DEV "true"}}-tags wails -buildvcs=false -gcflags=all="-l"{{else}}-tags wails,production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{end}}' + OUTPUT: '{{.OUTPUT | default (printf "%s/%s.exe" .BIN_DIR .APP_NAME)}}' + env: + GOOS: windows + CGO_ENABLED: 0 + GOARCH: '{{.ARCH | default ARCH}}' + + run: + cmds: + - '"{{.BIN_DIR}}/{{.APP_NAME}}.exe"' diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c51da4a0..6ecb026e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,8 +1,12 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; -export default defineConfig({ - base: "./", +export default defineConfig(({ command }) => ({ + // Relative base is required for the packaged app (assets served from an + // embedded FS with no fixed root), but Vite's dev server does not support + // a relative base and will emit broken asset URLs (blank screen under + // `wails3 dev`). Only apply it to the production build. + base: command === "build" ? "./" : "/", plugins: [react()], build: { outDir: "dist", @@ -36,4 +40,4 @@ export default defineConfig({ }, }, }, -}); +})); diff --git a/internal/app/status.go b/internal/app/status.go index 88c95f6b..397a5abc 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -9,8 +9,10 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "sync" + "time" "github.com/MaimoryLab/OneAgent/internal/catalog" configReader "github.com/MaimoryLab/OneAgent/internal/config" @@ -225,8 +227,9 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { paths[id+"_config"] = configPath } installed := false + executable := "" if agent.Command != "" { - _, installed = options.Lookup(agent.Command) + executable, installed = options.Lookup(agent.Command) } canInstall := false if agent.Package != nil { @@ -264,11 +267,16 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { if agent.ConfigMode == "auto" && configPath != "" { detected = detectedConfig(configReader.DetectFile(configPath, agent.ConfigAdapter, agent.EnvVars)) } + var installedVersion *string + if installed && agent.ConfigMode == "auto" { + installedVersion = u.installedVersion(ctx, executable, agent.VersionArgs) + } statuses[id] = AgentStatus{ Installed: installed, Configured: fileExists(configPath), GuideOnly: agent.ConfigMode == "guide", Config: configPath, + Version: installedVersion, LockedVersion: lockedVersion, CanInstall: canInstall, Provider: boundProvider, @@ -297,6 +305,31 @@ func (u *UseCases) GetStatus(ctx context.Context) (StatusResponse, error) { }, nil } +var versionPattern = regexp.MustCompile(`(^|[^\d])(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)`) + +// installedVersion mirrors the legacy installed_version(): run the Agent's +// version command and take the first semver-looking token from either stream. +// Any failure means "unknown", never an error — status must not break because +// an Agent's --version is misbehaving. +func (u *UseCases) installedVersion(ctx context.Context, executable string, versionArgs []string) *string { + if executable == "" || u.runner == nil { + return nil + } + args := versionArgs + if len(args) == 0 { + args = []string{"--version"} + } + result, err := u.runner.Run(ctx, append([]string{executable}, args...), nil, 30*time.Second) + if err != nil { + return nil + } + match := versionPattern.FindStringSubmatch(result.Stdout + "\n" + result.Stderr) + if match == nil { + return nil + } + return &match[2] +} + func detectedConfig(value *configReader.Detected) *DetectedConfig { if value == nil { return nil diff --git a/internal/app/status_test.go b/internal/app/status_test.go index efe685b9..28d3c768 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -263,6 +263,36 @@ api_key = "sk-detected-secret" } } +func TestStatusReportsInstalledVersionFromVersionCommand(t *testing.T) { + home := t.TempDir() + runner := &installAppRunner{paths: map[string]string{"codex": "/fake/codex"}} + core := NewUseCases(StatusOptions{ + Home: home, + Platform: platform.For("linux", "amd64"), + Runner: runner, + }) + status, err := core.GetStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + codex := status.Agents["codex"] + if !codex.Installed || codex.Version == nil || *codex.Version != "1.0.0" { + t.Fatalf("installed version not detected: %#v", codex) + } + found := false + for _, call := range runner.calls { + if reflect.DeepEqual(call, []string{"/fake/codex", "--version"}) { + found = true + } + } + if !found { + t.Fatalf("version command was not invoked: %#v", runner.calls) + } + if openclaw := status.Agents["openclaw"]; openclaw.Version != nil { + t.Fatalf("guide-only Agent should not report a version: %#v", openclaw) + } +} + func TestStatusMatchesPythonEmptyLinuxARM64Fixture(t *testing.T) { home := t.TempDir() core := NewUseCases(StatusOptions{ From f4df76619e12964d8dd202a51cf2d1af05e396ad Mon Sep 17 00:00:00 2001 From: Paul Liu <20290410+Paulkm2006@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:24:18 +0800 Subject: [PATCH 45/63] feat: dark mode --- .claude/launch.json | 8 +- .github/workflows/ci.yml | 72 +-- .github/workflows/site.yml | 75 ++++ .github/workflows/technical-preview.yml | 139 +----- .gitignore | 8 +- README.md | 8 +- distribution/channels.json | 119 ----- ...public-site-and-generated-release-index.md | 18 +- docs/public-site-operations.md | 115 ++--- docs/wails-v3-migration-plan.md | 8 +- frontend/index.html | 5 +- frontend/src/styles/app.css | 34 +- frontend/src/styles/base.css | 8 +- frontend/src/styles/tokens.css | 41 +- internal/config/testdata/parity/claude.golden | 11 + internal/config/testdata/parity/kilo.golden | 23 + .../config/testdata/parity/opencode.golden | 24 + scripts/build_release.py | 18 +- scripts/build_release_index.py | 418 ------------------ scripts/build_site_catalog.py | 143 ------ site/e2e/site.spec.ts | 32 +- site/package-lock.json | 1 - site/package.json | 6 +- site/playwright.config.ts | 2 +- site/scripts/validate-build.mjs | 21 +- site/src/components/DownloadSelector.astro | 140 +++--- site/src/components/Footer.astro | 5 +- site/src/layouts/BaseLayout.astro | 10 +- site/src/lib/catalog.ts | 102 +++++ site/src/lib/downloads.test.ts | 110 ++--- site/src/lib/downloads.ts | 129 +++--- site/src/pages/agents/[id].astro | 6 +- site/src/pages/agents/index.astro | 5 +- site/src/pages/changelog/index.astro | 17 +- site/src/pages/index.astro | 20 +- site/src/pages/providers/[id].astro | 8 +- site/src/pages/providers/index.astro | 7 +- site/src/pages/release-index.json.ts | 13 - site/src/pages/security/index.astro | 24 +- tests/test_distribution_data.py | 303 ------------- tests/test_release_policy.py | 12 +- 41 files changed, 605 insertions(+), 1663 deletions(-) create mode 100644 .github/workflows/site.yml delete mode 100644 distribution/channels.json create mode 100644 internal/config/testdata/parity/claude.golden create mode 100644 internal/config/testdata/parity/kilo.golden create mode 100644 internal/config/testdata/parity/opencode.golden delete mode 100755 scripts/build_release_index.py delete mode 100755 scripts/build_site_catalog.py create mode 100644 site/src/lib/catalog.ts delete mode 100644 site/src/pages/release-index.json.ts delete mode 100644 tests/test_distribution_data.py diff --git a/.claude/launch.json b/.claude/launch.json index 20010516..4cd6bdd3 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -2,10 +2,10 @@ "version": "0.0.1", "configurations": [ { - "name": "vite-dev", - "runtimeExecutable": "sh", - "runtimeArgs": ["-c", "cd frontend && pnpm run dev --port 9245 --strictPort"], - "port": 9245 + "name": "oneagent-frontend", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "frontend", "run", "dev"], + "port": 5173 } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd97dd3f..1e770c1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: run: | mkdir -p build/coverage coverage erase - coverage run --branch -m unittest tests.test_core tests.test_cli tests.test_release_policy tests.test_distribution_data tests.test_edge_cases tests.test_rc_scripts tests.test_install_contract tests.test_config_discovery + coverage run --branch -m unittest tests.test_core tests.test_cli tests.test_release_policy tests.test_edge_cases tests.test_rc_scripts tests.test_install_contract tests.test_config_discovery coverage report --fail-under=85 coverage json python -c "import json, os; files = json.load(open('build/coverage/coverage.json', encoding='utf-8'))['files']; normalized = {key.replace(os.sep, '/'): value for key, value in files.items()}; summary = normalized['oneagent/installer.py']['summary']; assert summary['percent_branches_covered'] == 100 and summary['num_partial_branches'] == 0, summary" @@ -177,18 +177,6 @@ jobs: if: runner.os == 'macOS' run: bash tests/macos_cleanroom_test.sh - - name: Validate macOS arm64 release for the public site - if: matrix.os == 'macos-15' - run: python scripts/check_release.py release - - - name: Upload verified macOS arm64 release for the public site - if: matrix.os == 'macos-15' - uses: actions/upload-artifact@v4 - with: - name: public-site-release-macos-arm64 - path: release/* - if-no-files-found: error - - name: Upload macOS cleanroom failures if: failure() && runner.os == 'macOS' uses: actions/upload-artifact@v4 @@ -196,61 +184,3 @@ jobs: name: macos-cleanroom-failures-${{ matrix.os }} path: build/macos-cleanroom if-no-files-found: ignore - - - public-site: - name: Public distribution site - needs: contract-and-browser - runs-on: ubuntu-22.04 - - steps: - - uses: actions/checkout@v4 - - - name: Download verified macOS arm64 release - uses: actions/download-artifact@v4 - with: - name: public-site-release-macos-arm64 - path: release - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: site/package-lock.json - - - name: Install site dependencies - working-directory: site - run: npm ci - - - name: Unit, type and release-data checks - working-directory: site - run: | - npm test - npm run build - - - name: Install Chromium - working-directory: site - run: npx playwright install --with-deps chromium - - - name: Browser and accessibility checks - working-directory: site - run: npm run test:e2e - - - name: Verify GitHub Pages base path build - working-directory: site - env: - SITE_URL: https://example.com - BASE_PATH: /OneAgent - run: npm run build - - - name: Upload site browser failures - if: failure() - uses: actions/upload-artifact@v4 - with: - name: public-site-browser-failures - path: site/test-results - if-no-files-found: ignore diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml new file mode 100644 index 00000000..2d5eb8c2 --- /dev/null +++ b/.github/workflows/site.yml @@ -0,0 +1,75 @@ +name: Public Site + +on: + workflow_dispatch: + release: + types: [published] + push: + branches: [main] + paths: + - "site/**" + - "agents.lock.json" + - "distribution/providers.json" + - ".github/workflows/site.yml" + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: github-pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: site/package-lock.json + + - name: Configure GitHub Pages + id: pages + uses: actions/configure-pages@v5 + + - name: Install and test site + working-directory: site + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + npm ci + npm test + npx playwright install --with-deps chromium + npm run test:e2e + + - name: Build site + working-directory: site + env: + GITHUB_TOKEN: ${{ github.token }} + SITE_URL: ${{ steps.pages.outputs.origin }} + BASE_PATH: ${{ steps.pages.outputs.base_path }} + PUBLIC_SUPPORT_URL: ${{ vars.ONEAGENT_PUBLIC_SUPPORT_URL }} + PUBLIC_BUSINESS_EMAIL: ${{ vars.ONEAGENT_PUBLIC_BUSINESS_EMAIL }} + run: npm run build + + - uses: actions/upload-pages-artifact@v3 + with: + path: site/dist + + deploy: + needs: build + runs-on: ubuntu-22.04 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/technical-preview.yml b/.github/workflows/technical-preview.yml index 81b9e9c5..7fe1def6 100644 --- a/.github/workflows/technical-preview.yml +++ b/.github/workflows/technical-preview.yml @@ -13,19 +13,12 @@ on: required: true default: true type: boolean - deploy_pages: - description: Deploy after the draft release has been reviewed and published - required: true - default: false - type: boolean push: tags: - "v*-preview*" permissions: contents: write - pages: write - id-token: write concurrency: group: technical-preview-${{ github.ref }} @@ -115,11 +108,9 @@ jobs: if-no-files-found: ignore assemble: - name: Assemble release and public site + name: Assemble release needs: build runs-on: ubuntu-22.04 - outputs: - page_artifact_uploaded: ${{ steps.page_condition.outputs.uploaded }} steps: - uses: actions/checkout@v4 @@ -131,73 +122,6 @@ jobs: path: build/native-release merge-multiple: true - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: npm - cache-dependency-path: site/package-lock.json - - - name: Configure GitHub Pages metadata - if: github.event_name == 'workflow_dispatch' && inputs.deploy_pages - id: pages - uses: actions/configure-pages@v5 - - - name: Select immutable release bytes - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }} - DEPLOY_PAGES: ${{ github.event_name == 'workflow_dispatch' && inputs.deploy_pages || false }} - shell: bash - run: | - rm -rf release - mkdir -p release - if [[ "$DEPLOY_PAGES" == "true" ]]; then - release_state="$(gh release view "$RELEASE_TAG" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | @tsv')" - if [[ "$release_state" != $'false\ttrue' ]]; then - echo "Release $RELEASE_TAG must be a published prerelease before the website is deployed." >&2 - exit 1 - fi - gh release download "$RELEASE_TAG" --dir release - rm -f release/release-index.json - else - cp build/native-release/* release/ - fi - - - name: Install and test public site - working-directory: site - run: | - npm ci - npm test - - - name: Install Chromium for public site checks - working-directory: site - run: npx playwright install --with-deps chromium - - - name: Run public site browser and accessibility checks - working-directory: site - run: npm run test:e2e - - - name: Build verified public site - working-directory: site - env: - SITE_URL: ${{ steps.pages.outputs.origin || 'http://localhost:4321' }} - BASE_PATH: ${{ steps.pages.outputs.base_path || '/' }} - PUBLIC_SUPPORT_URL: ${{ vars.ONEAGENT_PUBLIC_SUPPORT_URL }} - PUBLIC_BUSINESS_EMAIL: ${{ vars.ONEAGENT_PUBLIC_BUSINESS_EMAIL }} - run: npm run build - - - name: Prepare public release assets - run: | - python scripts/build_release_index.py \ - --release-dir release \ - --channels distribution/channels.json \ - --output build/release-index.json \ - --copy-release-assets build/public-release - - name: Create immutable draft prerelease if: github.event_name == 'push' || inputs.create_draft_release env: @@ -205,57 +129,12 @@ jobs: RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }} run: | if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then - mkdir -p build/existing-release - gh release download "$RELEASE_TAG" --dir build/existing-release - python - <<'PY' - import hashlib - import os - from pathlib import Path - - expected = Path("build/public-release") - existing = Path("build/existing-release") - expected_files = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in expected.iterdir() if path.is_file()} - existing_files = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in existing.iterdir() if path.is_file()} - if expected_files != existing_files: - raise SystemExit( - f"Release {os.environ['RELEASE_TAG']} already exists with different assets; bump the version/tag instead of replacing bytes." - ) - print(f"Release {os.environ['RELEASE_TAG']} already contains the exact verified assets; nothing was replaced.") - PY - else - gh release create "$RELEASE_TAG" build/public-release/* \ - --draft \ - --prerelease \ - --target "$GITHUB_SHA" \ - --title "OneAgent $RELEASE_TAG" \ - --notes "Unsigned technical preview. Verify platform, architecture and SHA-256 before use." + echo "Release $RELEASE_TAG already exists; bump the tag instead of replacing assets." >&2 + exit 1 fi - - - name: Mark Pages artifact condition - id: page_condition - shell: bash - run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.deploy_pages }}" == "true" ]]; then - echo "uploaded=true" >> "$GITHUB_OUTPUT" - else - echo "uploaded=false" >> "$GITHUB_OUTPUT" - fi - - - name: Upload GitHub Pages artifact - if: steps.page_condition.outputs.uploaded == 'true' - uses: actions/upload-pages-artifact@v3 - with: - path: site/dist - - deploy-pages: - name: Deploy verified public site - needs: assemble - if: needs.assemble.outputs.page_artifact_uploaded == 'true' - runs-on: ubuntu-22.04 - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + gh release create "$RELEASE_TAG" build/native-release/* \ + --draft \ + --prerelease \ + --target "$GITHUB_SHA" \ + --title "OneAgent $RELEASE_TAG" \ + --notes "Unsigned technical preview. Verify platform, architecture and SHA-256 before use." diff --git a/.gitignore b/.gitignore index d4f803dd..8b8471f1 100644 --- a/.gitignore +++ b/.gitignore @@ -31,10 +31,4 @@ site/coverage/ site/test-results/ site/playwright-report/ -# 站点数据由 `npm run prepare:data` 从 agents.lock.json、distribution/ 与 -# release/ 重新生成。提交它们会让每次构建都产生 diff,而 release-index 还会 -# 把只有本机才有的产物校验和写进仓库。 -site/src/generated/ -site/public/downloads/ - -.task \ No newline at end of file +.task diff --git a/README.md b/README.md index aff8f125..1e1b8026 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,12 @@ Go application use cases - `cmd/oneagent-desktop/`:Wails 桌面入口,仅在 `wails` build tag 下链接 Wails。 - `frontend/`:React 七页向导;发行包只携带构建后的 `dist`,终端用户不需要 Node.js。 - `site/`:独立 Astro 静态公开站;不进入 Launcher 包体,也不复用本地路由和状态。 -- `distribution/`:公开渠道状态与 Provider 商业关系披露;技术排序与商业数据保持分离。 +- `distribution/`:Provider 商业关系披露;技术排序与商业数据保持分离。 - `agents.lock.json`:五个自动配置 Agent 的版本、包管理器、配置适配器、平台、来源和许可证锁定清单。 ## 公开分发站 -公开站从平台 manifest、SHA256SUMS、`agents.lock.json` 和 `distribution/` 配置生成下载与兼容目录,不手工复制版本或哈希: +公开站构建时从 GitHub Releases API 读取已发布版本和下载资产,并直接从 `agents.lock.json`、`distribution/providers.json` 读取兼容目录: ```bash cd site @@ -62,7 +62,7 @@ npx playwright install chromium npm run test:e2e ``` -`npm run build` 会生成 `/release-index.json` 并校验公开 artifact 的大小与 SHA-256。`site/src/generated/` 与 `site/public/downloads/` 都由构建重新生成,不提交到 Git。GitHub Pages 子路径构建可设置 `SITE_URL` 与 `BASE_PATH`(该产物无法本地预览,原因见运营手册)。完整发布、镜像、Provider 披露和撤回流程见 [公开分发站运营与发布手册](docs/public-site-operations.md)。 +站点不读取本地 `release/`,不复制 App 资产,也不依赖 Python。`.github/workflows/site.yml` 与 App 发布工作流独立,Release 发布后自动重建 Pages;GitHub Pages 子路径构建可设置 `SITE_URL` 与 `BASE_PATH`。完整流程见 [公开站运营与发布手册](docs/public-site-operations.md)。 ## 快速启动 @@ -444,7 +444,7 @@ python3 scripts/provider_rc_smoke.py \ - [产品边界基线](docs/product-boundary-baseline.md) - [公开分发站运营与发布手册](docs/public-site-operations.md) -- [独立公开站与机器生成发行索引 ADR](docs/decisions/ADR-006-public-site-and-generated-release-index.md) +- [独立公开站与 GitHub Release 事实源 ADR](docs/decisions/ADR-006-public-site-and-generated-release-index.md) - [多渠道分发与合规政策](docs/distribution-compliance-policy.md) - [渠道无关的二进制分发 ADR](docs/decisions/ADR-005-channel-neutral-distribution-and-compliance.md) - [三平台 Python 内核与版本锁定 ADR](docs/decisions/ADR-003-three-platform-python-core-and-release-policy.md) diff --git a/distribution/channels.json b/distribution/channels.json deleted file mode 100644 index b52aae7b..00000000 --- a/distribution/channels.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "schema_version": 1, - "product": { - "name": "OneAgent", - "tagline": "用一个可信的本地流程,激活你自己的 Agent、账号和 Provider。", - "release_policy": "security/", - "privacy_policy": "security/#privacy" - }, - "channels": { - "technical-preview-unsigned": { - "label": "未签名技术预览版", - "published_at": "2026-07-28T00:00:00Z", - "targets": [ - { - "platform": "macos", - "arch": "arm64", - "status": "available", - "verification": { - "native_build": true, - "cleanroom": "verified", - "evidence": "security/#release-evidence" - }, - "mirrors": [ - { - "id": "website", - "label": "官网下载", - "kind": "official", - "url": "downloads/{file}", - "primary": true - } - ] - }, - { - "platform": "macos", - "arch": "x64", - "status": "verification-pending", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - }, - { - "platform": "windows", - "arch": "x64", - "status": "verification-pending", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - }, - { - "platform": "linux", - "arch": "x64", - "status": "verification-pending", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - } - ] - }, - "stable": { - "label": "Stable", - "published_at": null, - "targets": [ - { - "platform": "macos", - "arch": "arm64", - "status": "planned", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - }, - { - "platform": "macos", - "arch": "x64", - "status": "planned", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - }, - { - "platform": "windows", - "arch": "x64", - "status": "planned", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - }, - { - "platform": "linux", - "arch": "x64", - "status": "planned", - "verification": { - "native_build": false, - "cleanroom": "not-recorded", - "evidence": null - }, - "mirrors": [] - } - ] - } - } -} diff --git a/docs/decisions/ADR-006-public-site-and-generated-release-index.md b/docs/decisions/ADR-006-public-site-and-generated-release-index.md index d4356ab5..f46a0c0f 100644 --- a/docs/decisions/ADR-006-public-site-and-generated-release-index.md +++ b/docs/decisions/ADR-006-public-site-and-generated-release-index.md @@ -1,23 +1,23 @@ -# ADR-006:独立公开站与机器生成发行索引 +# ADR-006:独立公开站与 GitHub Release 事实源 -- 状态:Accepted +- 状态:Accepted(2026-07-31 修订) - 日期:2026-07-28 ## 背景 -OneAgent 的 React 前端是随本地 Launcher 打包的操作界面。公开下载、搜索内容、发行证据和企业服务需要静态可索引页面,两者的安全、缓存、路由和发布周期不同。手工维护下载页版本与哈希会产生事实漂移。 +OneAgent 的 React 前端是随本地 Launcher 打包的操作界面。公开下载、搜索内容、发行证据和企业服务需要静态可索引页面,两者的安全、缓存、路由和发布周期不同。原方案又让站点构建依赖 App 的本地产物、Python 生成器和同一个发布工作流,实际没有获得独立发布周期。 ## 决策 1. 在同一仓库维护独立 `site/` Astro 静态站,不把营销路由加入本地 Launcher。 -2. 平台 manifest 与 SHA256SUMS 保持构建事实源;人工渠道状态放入 `distribution/channels.json`。 -3. 通过 `scripts/build_release_index.py` 验证并生成公开 `/release-index.json`,下载页面只消费该数据。 -4. Agent 兼容目录由 `agents.lock.json` 生成只读投影;Provider 商业披露放在独立数据文件,不能影响 rank 或技术结论。 +2. App 工作流只创建 GitHub Release;站点工作流由站点变更、Release 发布或人工操作独立触发。 +3. 公开版本、发布日期、下载资产、大小和摘要只读取 GitHub Releases API,不读取本地 App 构建目录,也不维护手工回退版本。 +4. Agent 兼容目录直接读取 `agents.lock.json`;Provider 商业披露直接读取独立数据文件,不能影响 rank 或技术结论。 5. 网站默认不加载客户端分析脚本;Launcher 保持默认无遥测。 ## 后果 - Launcher 无需为官网 SEO、域名或外部托管做重构。 -- 发布站点必须拿到受验证的原生 artifact 才能显示下载按钮。 -- GitHub Pages、自有对象存储或其他镜像可以更换,但同版本包体与 SHA-256 不得变化。 -- 网站新增独立 Node 依赖和 CI 作业;源代码包包含网站源码但不包含 `node_modules`、`site/dist` 或复制后的下载目录。 +- Draft 和本地构建不会出现在官网;只有已发布 GitHub Release 能产生版本和下载按钮。 +- GitHub Pages 发布不构建 App,App 发布也不构建或部署 Pages。 +- 网站只需要 Node 工具链;App 源代码包不再携带站点源码。 diff --git a/docs/public-site-operations.md b/docs/public-site-operations.md index 3250d5e6..a08022f1 100644 --- a/docs/public-site-operations.md +++ b/docs/public-site-operations.md @@ -1,36 +1,25 @@ -# OneAgent 公开分发站运营与发布手册 +# OneAgent 公开站运营与发布手册 -状态:实施中,适用于 `technical-preview-unsigned` 和未来逐平台 Stable 发布。 +状态:实施中。 -## 1. 固定架构 +## 架构边界 -- `frontend/` 是随 Launcher 打包的本地七页向导,继续服从无 CDN、资源内联和本地 API 安全约束。 -- `site/` 是独立的 Astro 静态站,只提供产品说明、下载、教程、兼容目录、安全政策、支持和企业服务页面。 -- 两者不共享路由、状态或运行时组件;首期只复用品牌语言、真实截图和 Agent 标识资产。 -- 官网不进入 OneAgent 安装包,网站构建失败不能改变 Launcher 的本地运行行为。 +- `frontend/` 是随桌面 App 打包的 React 客户端。 +- `site/` 是独立构建和部署的 Astro 静态站,不进入 App 包体。 +- `.github/workflows/technical-preview.yml` 只构建 App 资产并创建 Draft GitHub Release。 +- `.github/workflows/site.yml` 只测试、构建和部署 GitHub Pages。 -## 2. 唯一发行事实源 +两个工作流没有 artifact 或 job 依赖。发布者人工审核并发布 Draft Release 后,`release.published` 事件会触发站点重建。 -公开下载数据由三层组成: +## 版本事实源 -1. `release/release-manifest--.json`:构建产生的版本、平台、架构、Agent 锁定版本和 artifact 哈希。 -2. `release/SHA256SUMS--.txt`:artifact 与 manifest 的独立校验记录。 -3. `distribution/channels.json`:人工审核的平台公开状态、原生构建/cleanroom 证据和下载渠道。 +公开站在构建时调用 GitHub Releases API,只读取已发布、非 Draft 的 Release。页面上的版本标签、发布日期、下载地址、文件大小和可用的 SHA-256 digest 均来自该 API;没有 Release 时页面明确显示尚未发布。 -`scripts/build_release_index.py` 校验三层一致性并生成 `site/src/generated/release-index.json`。可公开下载的平台必须同时满足: +站点不读取 App 的本地 `release/` 目录,不复制下载资产,也不维护版本回退值。Agent 目录直接读取 `agents.lock.json`,Provider 披露直接读取 `distribution/providers.json`。 -- manifest 和 checksum 文件存在; -- artifact 文件大小与 SHA-256 完全一致; -- `native_build=true`; -- `cleanroom=verified` 且 evidence 非空; -- 有且只有一个 primary 官方下载渠道; -- 外部镜像使用 HTTPS,并声明与 artifact 完全相同的 `verified_sha256`; -- available 渠道有明确的 `published_at`; -- 渠道与签名状态一致,unsigned 构建不能进入 Stable。 +私有仓库构建需要提供具有 `contents:read` 权限的 `GITHUB_TOKEN`。独立 Pages 工作流使用当前任务的 GitHub token;未提供 token 的本地构建若无法读取私有仓库,会渲染“尚无已发布版本”。 -网站的 `/release-index.json` 与下载页读取同一份生成数据,禁止再维护手工版本表。 - -## 3. 本地开发和验收 +## 本地验证 ```bash cd site @@ -41,83 +30,29 @@ npx playwright install chromium npm run test:e2e ``` -`npm run prepare:data` 会从仓库根目录的 manifest、渠道配置、`agents.lock.json` 和 Provider 公开配置重新生成网站数据,并把当前标记为 available 的官方同包 artifact 复制到网站构建目录。 - -`site/src/generated/` 与 `site/public/downloads/` 都是可再生目录,**均不提交到 Git**:`catalog.json` 是 `agents.lock.json` 加 Provider 配置的纯函数,提交它只会让每次构建产生 diff;`release-index.json` 含 artifact 校验和,提交等于把某一台机器的构建结果冻进仓库。干净检出下 `npm run build` 会先跑 `prepare:data` 重新生成,无需额外步骤。 - 模拟 GitHub Pages 子路径部署: ```bash SITE_URL=https://example.com BASE_PATH=/OneAgent npm run build ``` -**这份产物不能用 `astro preview` 在本地查看。** `BaseLayout.astro` 会输出绝对 URL 的 ``,而同一份 CSP 声明了 `base-uri 'self'`:从本机 origin 伺服时浏览器拒绝该 base 标签,样式表与 Agent 标识全部 404,**而每个页面仍然返回 200,只是退化成无样式 HTML**。部署到 Pages 时 base 与页面同源,`'self'` 放行,因此线上不受影响。 - -跑完这条命令后要恢复可预览的产物,直接重新 `npm run build` 即可。`site.spec.ts` 有一条断言同时检查无 4xx、背景色取自本站样式表、图片全部解码成功,正是为了让这种「200 但坏了」的状态在测试里可见而不是靠肉眼发现。 - -## 4. 受控预览发布 +该子路径产物的绝对 `` 只适用于配置的 origin。恢复本地预览时重新运行普通 `npm run build`。 -`.github/workflows/technical-preview.yml` 的顺序固定为: +## 发布顺序 -1. 四个平台原生构建 unsigned preview; -2. 打包 CLI 冒烟和包体检查; -3. macOS arm64 执行真实 cleanroom; -4. 汇总所有平台 manifest 与 checksum; -5. 生成公开 release index、执行网站单元/类型/完整性、三档视口浏览器与可访问性检查; -6. 只把 `distribution/channels.json` 中标记为 available 的平台 artifact、manifest、checksum 和 release index 放进 Draft GitHub prerelease; -7. 人工核对并在 GitHub 上公开该 prerelease; -8. 再次手动运行工作流,关闭 `create_draft_release`、开启 `deploy_pages`;工作流会丢弃本次重建的包体,重新下载已公开 prerelease 的不可变资产来生成下载页,通过门禁后部署 GitHub Pages。 - -手动触发需要指定 preview tag。`deploy_pages` 默认关闭;Tag 触发只创建不可变 Draft,不会自动部署网站。Draft 只在人工复核平台状态、发行说明和下载校验后公开。工作流拒绝覆盖同一 tag 下已有的不同字节;任何包体变化都必须先提升 OneAgent 版本并使用新 tag,不得在同一版本下替换 artifact。 - -四个平台的构建结果仍会作为 CI artifact 保留用于验证,但未标记 available 的 Windows、Linux 或 macOS x64 包不会进入 GitHub Release,也不会被复制到网站下载目录。 +1. 运行 `Technical Preview Packages`,构建并验证各平台 App 资产。 +2. 工作流以新 tag 创建不可变 Draft prerelease;已有 tag 会直接失败,不覆盖资产。 +3. 人工检查资产、校验和、签名状态和发行说明后发布 Release。 +4. `Public Site` 工作流自动从默认分支构建站点,从该 Release 读取版本与下载信息并部署 Pages。 +5. 仅修改站点、Agent 目录或 Provider 披露时,合入 `main` 即可独立部署,不触发 App 构建。 GitHub repository variables: -- `ONEAGENT_PUBLIC_SUPPORT_URL`:公开 Issues、Discussions 或其他可访问的反馈入口;为空时网站明确显示尚未启用。 -- `ONEAGENT_PUBLIC_BUSINESS_EMAIL`:公开商务邮箱;为空时企业页不展示虚构联系方式。 - -域名、DNS、备案、Pages 开关和签名证书属于仓库外部依赖。 - -## 5. 镜像与撤回 - -新增官网外镜像时: - -1. 上传现有官方 artifact,禁止重新压缩、追加文件和二次签名; -2. 下载镜像文件并重新计算 SHA-256; -3. 在 `distribution/channels.json` 中使用 `kind=mirror` 并填写与 manifest 完全相同的 `verified_sha256`; -4. 在镜像的 `audit` 对象中记录 `uploaded_by`、`uploaded_at`、`verified_at`、`withdrawal_owner` 和 `withdrawn`;撤回时补充 `withdrawn_at`。镜像必须使用 HTTPS,且不能成为 primary 官方渠道; -5. 重新构建网站,生成器会拒绝哈希未确认的外部镜像。 - -发生错误或安全事件时,将目标状态改为 `withdrawn`、移除下载链接并重建网站,同时撤回所有渠道。禁止在同一个版本号下替换为不同字节的文件;修复后必须发布新版本。 - -## 6. Provider 合作披露 - -Provider 的公开商业数据保存在 `distribution/providers.json`,与 Agent rank 和运行时兼容逻辑分离。 - -- `relationship=none`:只链接官方主页。 -- `relationship=referral` 或 `sponsor`:必须同时填写 disclosure 和 referral URL。 -- 商业关系不能改变 Agent/Provider 技术结论、默认选择、连接探测或页面排序。 -- OneAgent 不代理推理请求、不托管 Key、不代收充值,不承诺 Provider 的永久价格或固定免费额度。 -- 首期只链接官方价格页,不在仓库内复制容易过期的价格表。 - -## 7. 支持、统计与增长 - -- 文档、校验信息与已知限制保持公开,社群不是下载前置条件。 -- 应用继续默认无遥测;网站当前也不加载客户端分析脚本。 -- 如后续启用无 Cookie 聚合统计,允许事件仅限页面访问、下载点击、快速开始入口和企业联系入口;禁止设备指纹、跨站身份、API Key、本地路径和模型请求内容。 -- 初始漏斗目标:主页到下载页 20%,下载用户进入快速开始 40%,每 100 次下载的重复性安装支持请求少于 20。 - -## 8. 企业服务与产品化门禁 - -首期只销售三个可复用结果:团队启用、环境标准化和商业支持。内部镜像仍只能分发 OneAgent 官方同包产物,不得包含未授权 Agent 二进制。 +- `ONEAGENT_PUBLIC_SUPPORT_URL`:公开支持入口;为空时不展示虚构地址。 +- `ONEAGENT_PUBLIC_BUSINESS_EMAIL`:公开商务邮箱;为空时不展示虚构邮箱。 -在至少获得 3 个设计合作方、其中 2 个付费试点前,不开发专业版许可证系统。若试点反复提出团队模板、合规报告、组织版本策略或内部升级策略,必须新建 ADR,重新评估权限、秘密管理、迁移与隐私边界。 +## Provider 与稳定版边界 -## 9. Stable 门禁 +Provider 商业数据保存在 `distribution/providers.json`,不能影响 Agent rank、兼容性结论、默认选择或连接测试。 -- macOS:Developer ID、notarization、stapled ticket、原生 cleanroom。 -- Windows:有效 Authenticode、原生构建、SmartScreen 场景验证。 -- Linux:按实际架构原生构建和 cleanroom。 -- 每个平台独立进入 Stable;网站允许同一时间存在不同成熟度。 -- Stable 初期仍采用手动升级说明,不在本阶段加入自动更新。 +Stable 仍需按平台满足签名、公证和原生 cleanroom 门禁。GitHub Release 是公开版本与资产的事实源,不替代 App 发布流程中的产物验证。 diff --git a/docs/wails-v3-migration-plan.md b/docs/wails-v3-migration-plan.md index d2e1c83c..b0e265c0 100644 --- a/docs/wails-v3-migration-plan.md +++ b/docs/wails-v3-migration-plan.md @@ -35,7 +35,7 @@ OneAgent 应迁移为“Wails v3 桌面壳 + Go 领域核心 + React/Vite 前端 - OneAgent 的源码开发、测试、构建、运行和发布均不要求 Python。 - 删除所有受版本控制的 `.py`、PyInstaller、wheel/setuptools 和 Python CI 步骤。 -- `site/` 保持 Astro 静态站,不塞入 Wails;它当前依赖的 Python 数据生成器改为 TypeScript。 +- `site/` 保持 Astro 静态站,不塞入 Wails;直接读取仓库 JSON 和 GitHub Releases API,不再运行 Python 数据生成器。 - Aider 是唯一需要单独解释的外部边界:Aider 上游本身是 Python 工具。建议保留“检测、配置已安装 Aider”和“用户明确选择时通过 `uv` 安装”的能力,但将 Python 3.12 明确标记为 **Aider 的可选上游前置条件**,不是 OneAgent 的运行依赖。若产品要求目标机器绝对不能存在 Python,则必须把 Aider 自动安装降为 guide-only;两者不能同时成立。 迁移采用契约优先、分阶段切换,不做一次性重写。Python 生产入口只在 Go 实现尚未达到行为等价时保留;正式切换后不提供 Python fallback,否则无法证明已经完成去依赖。 @@ -72,7 +72,7 @@ OneAgent 应迁移为“Wails v3 桌面壳 + Go 领域核心 + React/Vite 前端 | `oneagent/entrypoint.py`、`scripts/gui.py` | GUI/CLI 分流和源码入口 | `cmd/oneagent-desktop`、`cmd/oneagent` | | `frontend/src/api/client.ts` | fetch、HTTP 错误归一化 | `frontend/src/backend/wails.ts` | | `packaging/oneagent.spec` | PyInstaller onedir | Wails v3 Taskfile 与 `build/config.yml` | -| Python 发布脚本 | 构建、校验、notice、manifest、官网数据 | Go release tools + `site/scripts/*.ts` | +| Python 发布脚本 | 构建、校验、notice、manifest | Go release tools;官网独立读取 GitHub Release | 以下是迁移验收契约,不是实现细节: @@ -339,7 +339,7 @@ HTTP 删除后,Cookie、Host 和 Origin 校验也随之删除,但不能简 - Wails Taskfile 取代 PyInstaller spec;`go:embed` 取代 resource staging。 - `cmd/oneagent-release` 取代 build/check/notices/lock verification Python 脚本。 -- `site/scripts/build-release-index.ts` 和 `build-site-catalog.ts` 取代两个 Python 生成器;Playwright 用 Astro preview,不再用 `python -m http.server`。 +- 官网直接读取 GitHub Releases API 和仓库 JSON;Playwright 用 Astro preview,不再用 `python -m http.server`。 - release manifest schema 升级,删除 `python` 字段,增加精确 `go`、`wails`、`frontend` 和 system WebView 要求。 - 第三方 notice 同时覆盖 Go modules、Wails、npm 前端依赖和锁定 Agent 元数据。 @@ -406,7 +406,7 @@ HTTP 删除后,Cookie、Host 和 Origin 校验也随之删除,但不能简 | `scripts/gui.py` | `wails3 dev` / Wails desktop binary | | `scripts/build_release.py`、`check_release.py`、`verify_locked_agents.py` | `cmd/oneagent-release` 子命令 | | `packaging/generate_notices.py` | Go module/npm/Agent notice 生成器 | -| `scripts/build_release_index.py`、`build_site_catalog.py` | `site/scripts/*.ts` | +| `scripts/build_release_index.py`、`build_site_catalog.py` | Astro 数据模块直接读取 GitHub Release 与仓库 JSON | | `scripts/provider_rc_smoke.py`、`agent_e2e_smoke.py`、`agent_config_adopted_check.py` | Go integration commands/tests | | `scripts/stage_resources.py` | `go:embed` | | `scripts/verify_wheel.py`、`setup.py`、`pyproject.toml`、`.spec` | Wails build/package/smoke | diff --git a/frontend/index.html b/frontend/index.html index dd20cb9e..8b6de84b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,9 @@ - - + + + OneAgent — every agent, one clear lane diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index fcc31ea9..adabf829 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -41,7 +41,7 @@ place-items: center; border: 1px solid var(--border-strong); border-radius: 8px; - background: #ffffff; + background: var(--window-bg); } .brand-lockup > div { @@ -137,7 +137,7 @@ .app-main { min-width: 0; min-height: 0; - background: #ffffff; + background: var(--window-bg); } .page-scaffold { @@ -149,7 +149,7 @@ .page-header { padding: 26px 42px 18px; - border-bottom: 1px solid rgba(60, 60, 67, 0.08); + border-bottom: 1px solid var(--border-faint); } .page-header > div:first-child { @@ -189,7 +189,7 @@ align-items: center; justify-content: space-between; gap: 20px; - background: rgba(250, 250, 251, 0.96); + background: var(--footer-bg); } .page-footer-leading, @@ -254,7 +254,7 @@ place-items: center; border: 1px solid var(--border-strong); border-radius: 50%; - background: #ffffff; + background: var(--window-bg); font-size: 13px; font-weight: 600; } @@ -311,7 +311,7 @@ border: 1px solid var(--border); border-radius: var(--radius-panel); overflow: hidden; - background: #ffffff; + background: var(--window-bg); } .agent-row { @@ -360,10 +360,10 @@ height: 38px; display: grid; place-items: center; - border: 1px solid rgba(60, 60, 67, 0.12); + border: 1px solid var(--icon-border); border-radius: 7px; background: var(--surface-subtle); - color: #3a3a3c; + color: var(--icon-fg); } .agent-copy, @@ -421,7 +421,7 @@ .status-success { color: var(--green); background: var(--green-soft); } .status-warning { color: var(--orange); background: var(--orange-soft); } .status-danger { color: var(--red); background: var(--red-soft); } -.status-info { color: #145da0; background: #eff7ff; } +.status-info { color: var(--info); background: var(--info-soft); } .status-neutral { color: var(--text-secondary); background: var(--surface-pressed); } .agent-selection-body { @@ -519,7 +519,7 @@ grid-template-columns: 42px minmax(0, 1fr) 24px; align-items: center; gap: 14px; - background: #ffffff; + background: var(--window-bg); cursor: pointer; text-align: left; } @@ -555,7 +555,7 @@ min-height: 40px; border: 0; border-right: 1px solid var(--border-strong); - background: #ffffff; + background: var(--window-bg); cursor: pointer; } @@ -686,7 +686,7 @@ align-items: center; justify-content: space-between; gap: 16px; - background: #ffffff; + background: var(--window-bg); cursor: pointer; text-align: left; } @@ -727,7 +727,7 @@ font-size: 13px; } -.review-row + .review-row { border-top: 1px solid rgba(60, 60, 67, 0.1); } +.review-row + .review-row { border-top: 1px solid var(--border-faint); } .review-row svg { color: var(--green); } .review-row > span { color: var(--text-secondary); } .review-row strong { min-width: 0; overflow-wrap: anywhere; font-weight: 500; text-align: right; } @@ -826,7 +826,7 @@ border: 1px solid var(--border); border-radius: var(--radius-control); overflow: auto; - color: #2c2c2e; + color: var(--text-primary); background: var(--surface-subtle); font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; white-space: pre-wrap; @@ -957,7 +957,7 @@ .agent-manage-list { border-radius: var(--radius-panel); - background: #fff; + background: var(--window-bg); } /* Detail page */ @@ -1058,7 +1058,7 @@ padding: 7px 9px; overflow-x: auto; border-radius: 5px; - background: rgba(255, 255, 255, 0.7); + background: var(--overlay-panel); color: var(--text-primary); font-size: 11px; } @@ -1143,7 +1143,7 @@ .provider-user-chip { padding: 2px 8px; border-radius: 999px; - background: #fff; + background: var(--window-bg); color: var(--text-secondary); font-size: 11px; } diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 569b1997..f134226a 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -80,7 +80,7 @@ button:active:not(:disabled) { .button-secondary, .button-compact { - background: #ffffff; + background: var(--window-bg); border-color: var(--border-strong); } @@ -103,7 +103,7 @@ button:active:not(:disabled) { border: 1px solid var(--border-strong); border-radius: var(--radius-control); color: var(--text-primary); - background: #ffffff; + background: var(--window-bg); transition: border-color 140ms ease, box-shadow 140ms ease; } @@ -166,8 +166,8 @@ button:active:not(:disabled) { } .notice-info { - color: #145da0; - background: #eff7ff; + color: var(--info); + background: var(--info-soft); border-color: rgba(0, 122, 255, 0.2); } diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index 2f2a35a8..f2adf268 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -1,5 +1,5 @@ :root { - color-scheme: light; + color-scheme: light dark; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif; font-synthesis: none; text-rendering: optimizeLegibility; @@ -13,6 +13,11 @@ --text-tertiary: #8e8e93; --border: rgba(60, 60, 67, 0.18); --border-strong: rgba(60, 60, 67, 0.28); + --border-faint: rgba(60, 60, 67, 0.08); + --icon-border: rgba(60, 60, 67, 0.12); + --icon-fg: #3a3a3c; + --footer-bg: rgba(250, 250, 251, 0.96); + --overlay-panel: rgba(255, 255, 255, 0.7); --blue: #007aff; --blue-hover: #006ee6; --blue-soft: rgba(0, 122, 255, 0.08); @@ -22,6 +27,8 @@ --orange-soft: #fff3e7; --red: #d70015; --red-soft: #fff0f1; + --info: #145da0; + --info-soft: #eff7ff; --shadow-window: 0 22px 55px rgba(42, 42, 46, 0.16), 0 2px 8px rgba(42, 42, 46, 0.08); --radius-control: 6px; --radius-panel: 8px; @@ -29,3 +36,35 @@ --sidebar-width: 232px; --footer-height: 68px; } + +@media (prefers-color-scheme: dark) { + :root { + --page-bg: #151517; + --window-bg: #1e1e20; + --sidebar-bg: rgba(24, 24, 26, 0.94); + --surface-subtle: #2c2c2e; + --surface-pressed: #3a3a3c; + --text-primary: #f5f5f7; + --text-secondary: #98989d; + --text-tertiary: #6e6e73; + --border: rgba(255, 255, 255, 0.14); + --border-strong: rgba(255, 255, 255, 0.22); + --border-faint: rgba(255, 255, 255, 0.08); + --icon-border: rgba(255, 255, 255, 0.12); + --icon-fg: #d1d1d6; + --footer-bg: rgba(28, 28, 30, 0.96); + --overlay-panel: rgba(0, 0, 0, 0.25); + --blue: #0a84ff; + --blue-hover: #409cff; + --blue-soft: rgba(10, 132, 255, 0.16); + --green: #30d158; + --green-soft: #1e3726; + --orange: #ff9f0a; + --orange-soft: #3a2c12; + --red: #ff453a; + --red-soft: #3a1213; + --info: #7cc4ff; + --info-soft: #12283a; + --shadow-window: 0 22px 55px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.35); + } +} diff --git a/internal/config/testdata/parity/claude.golden b/internal/config/testdata/parity/claude.golden new file mode 100644 index 00000000..04cda8a7 --- /dev/null +++ b/internal/config/testdata/parity/claude.golden @@ -0,0 +1,11 @@ +{ + "keep": true, + "env": { + "CUSTOM": "value", + "ANTHROPIC_MODEL": "model-new", + "ANTHROPIC_BASE_URL": "https://api.ppio.com/anthropic", + "ANTHROPIC_AUTH_TOKEN": "sk-parity", + "ANTHROPIC_SMALL_FAST_MODEL": "model-new" + }, + "other": true +} diff --git a/internal/config/testdata/parity/kilo.golden b/internal/config/testdata/parity/kilo.golden new file mode 100644 index 00000000..5140ce50 --- /dev/null +++ b/internal/config/testdata/parity/kilo.golden @@ -0,0 +1,23 @@ +{ + "provider": { + "other": { + "x": 1 + }, + "oneagent": { + "npm": "@ai-sdk/openai-compatible", + "name": "PPIO", + "options": { + "baseURL": "https://api.ppio.com/openai/v1", + "apiKey": "{env:ONEAGENT_API_KEY_KILO_CLI}" + }, + "models": { + "model-new": { + "name": "model-new" + } + } + } + }, + "keep": true, + "$schema": "https://app.kilo.ai/config.json", + "model": "oneagent/model-new" +} diff --git a/internal/config/testdata/parity/opencode.golden b/internal/config/testdata/parity/opencode.golden new file mode 100644 index 00000000..d64a1c4d --- /dev/null +++ b/internal/config/testdata/parity/opencode.golden @@ -0,0 +1,24 @@ +{ + "keep": true, + "provider": { + "other": { + "x": 1 + }, + "oneagent": { + "npm": "@ai-sdk/openai-compatible", + "name": "PPIO", + "options": { + "baseURL": "https://api.ppio.com/openai/v1", + "apiKey": "{env:ONEAGENT_API_KEY_OPENCODE}" + }, + "models": { + "model-new": { + "name": "model-new" + } + } + } + }, + "theme": "dark", + "$schema": "https://opencode.ai/config.json", + "model": "oneagent/model-new" +} diff --git a/scripts/build_release.py b/scripts/build_release.py index 556ee909..a02c1772 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -84,18 +84,10 @@ def source_files() -> list[Path]: "frontend/src", "frontend/e2e", "frontend/dist", - "site/src", - "site/e2e", - "site/scripts", - "site/public/images", "build/metadata/THIRD_PARTY_NOTICES.md", "build/metadata/licenses", ] - # Directories whose contents a build regenerates. site/src/generated holds - # the public release index, which carries the checksums of one machine's - # artifacts; shipping it inside the source archive would contradict the - # reason ADR-006 generates that file instead of tracking it. - excluded = {"__pycache__", "generated"} + excluded = {"__pycache__"} files: list[Path] = [] for item in roots: path = ROOT / item @@ -119,14 +111,6 @@ def source_files() -> list[Path]: "frontend/vite.config.ts", "frontend/playwright.config.ts", "frontend/index.html", - "site/package.json", - "site/package-lock.json", - "site/tsconfig.json", - "site/astro.config.mjs", - "site/vitest.config.ts", - "site/playwright.config.ts", - "site/public/favicon.svg", - "site/public/_headers", ]: path = ROOT / item if path.exists(): diff --git a/scripts/build_release_index.py b/scripts/build_release_index.py deleted file mode 100755 index 61cb6394..00000000 --- a/scripts/build_release_index.py +++ /dev/null @@ -1,418 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import hashlib -import json -import shutil -from datetime import datetime -from pathlib import Path, PurePosixPath -from typing import Any -from urllib.parse import unquote, urlsplit - - -ROOT = Path(__file__).resolve().parents[1] - - -class ReleaseIndexError(ValueError): - pass - - -PLATFORM_LABELS = {"macos": "macOS", "windows": "Windows", "linux": "Linux"} -ARCH_LABELS = {"arm64": "Apple silicon / ARM64", "x64": "Intel / AMD 64-bit"} -TARGET_STATUSES = {"available", "verification-pending", "planned", "withdrawn"} -CLEANROOM_STATUSES = {"verified", "not-recorded", "failed"} - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as handle: - for block in iter(lambda: handle.read(1024 * 1024), b""): - digest.update(block) - return digest.hexdigest() - - -def load_json(path: Path) -> dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise ReleaseIndexError(f"Cannot read {path}: {exc}") from exc - if not isinstance(value, dict): - raise ReleaseIndexError(f"{path} must contain a JSON object") - return value - - -def parse_checksums(path: Path) -> dict[str, str]: - try: - lines = path.read_text(encoding="utf-8").splitlines() - except OSError as exc: - raise ReleaseIndexError(f"Missing checksum file: {path}") from exc - checksums: dict[str, str] = {} - for line in lines: - if not line.strip(): - continue - parts = line.split(maxsplit=1) - if len(parts) != 2: - raise ReleaseIndexError(f"Malformed checksum line in {path}: {line}") - digest, filename = parts - filename = filename.lstrip("* ") - if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest.lower()): - raise ReleaseIndexError(f"Malformed SHA-256 in {path}: {digest}") - if filename in checksums: - raise ReleaseIndexError(f"Duplicate checksum entry in {path}: {filename}") - checksums[filename] = digest.lower() - return checksums - - -def require_string(value: Any, label: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise ReleaseIndexError(f"{label} must be a non-empty string") - return value - - -def require_timestamp(value: Any, label: str) -> str: - text = require_string(value, label) - try: - parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) - except ValueError as exc: - raise ReleaseIndexError(f"{label} must be an ISO-8601 timestamp") from exc - if parsed.tzinfo is None: - raise ReleaseIndexError(f"{label} must include a timezone") - return text - - -def validate_manifest(path: Path, release_dir: Path) -> dict[str, Any]: - manifest = load_json(path) - if manifest.get("schema_version") != 1: - raise ReleaseIndexError(f"Unsupported release manifest schema in {path}") - for field in ["oneagent_version", "channel", "platform", "arch", "python"]: - require_string(manifest.get(field), f"{path.name}.{field}") - require_timestamp(manifest.get("built_at"), f"{path.name}.built_at") - if not isinstance(manifest.get("unsigned"), bool): - raise ReleaseIndexError(f"{path.name}.unsigned must be a boolean") - if manifest["channel"] == "technical-preview-unsigned" and not manifest["unsigned"]: - raise ReleaseIndexError(f"{path.name} cannot describe a signed technical preview") - if manifest["channel"] == "stable" and manifest["unsigned"]: - raise ReleaseIndexError(f"{path.name} cannot describe an unsigned Stable build") - artifacts = manifest.get("artifacts") - if not isinstance(artifacts, list) or not artifacts: - raise ReleaseIndexError(f"{path.name}.artifacts must not be empty") - if not isinstance(manifest.get("agent_versions"), dict): - raise ReleaseIndexError(f"{path.name}.agent_versions must be an object") - - checksum_path = release_dir / f"SHA256SUMS-{manifest['platform']}-{manifest['arch']}.txt" - checksums = parse_checksums(checksum_path) - manifest_digest = sha256(path) - if checksums.get(path.name) != manifest_digest: - raise ReleaseIndexError(f"Manifest SHA-256 does not match {checksum_path.name}: {path.name}") - - validated_artifacts: list[dict[str, Any]] = [] - for item in artifacts: - if not isinstance(item, dict): - raise ReleaseIndexError(f"{path.name}.artifacts entries must be objects") - filename = require_string(item.get("file"), f"{path.name}.artifacts.file") - if Path(filename).name != filename: - raise ReleaseIndexError(f"Artifact file must be a basename: {filename}") - artifact_path = release_dir / filename - if not artifact_path.is_file(): - raise ReleaseIndexError(f"Missing artifact referenced by manifest: {filename}") - expected_digest = require_string(item.get("sha256"), f"{filename}.sha256").lower() - actual_digest = sha256(artifact_path) - if expected_digest != actual_digest: - raise ReleaseIndexError(f"Artifact SHA-256 does not match manifest: {filename}") - if checksums.get(filename) != actual_digest: - raise ReleaseIndexError(f"Artifact SHA-256 does not match {checksum_path.name}: {filename}") - if item.get("bytes") != artifact_path.stat().st_size: - raise ReleaseIndexError(f"Artifact byte count does not match manifest: {filename}") - validated_artifacts.append({"file": filename, "sha256": actual_digest, "bytes": artifact_path.stat().st_size}) - manifest["artifacts"] = validated_artifacts - return manifest - - -def artifact_kind(filename: str) -> str: - return "source" if filename.endswith("-source.zip") else "binary" - - -def target_id(platform_id: str, arch: str) -> str: - return f"{platform_id}-{arch}" - - -def build_release_index(release_dir: Path, channels_path: Path) -> dict[str, Any]: - release_dir = release_dir.resolve() - channels_config = load_json(channels_path) - if channels_config.get("schema_version") != 1: - raise ReleaseIndexError("Unsupported distribution channel schema") - product = channels_config.get("product") - channels = channels_config.get("channels") - if not isinstance(product, dict) or not isinstance(channels, dict): - raise ReleaseIndexError("Distribution config requires product and channels objects") - - manifests: dict[tuple[str, str, str], dict[str, Any]] = {} - versions: dict[str, set[str]] = {} - for path in sorted(release_dir.glob("release-manifest-*.json")): - manifest = validate_manifest(path, release_dir) - key = (manifest["channel"], manifest["platform"], manifest["arch"]) - if key in manifests: - raise ReleaseIndexError(f"Duplicate release manifest for {'/'.join(key)}") - manifests[key] = manifest - versions.setdefault(manifest["channel"], set()).add(manifest["oneagent_version"]) - - output_channels: list[dict[str, Any]] = [] - latest: dict[str, str | None] = {} - configured_keys: set[tuple[str, str, str]] = set() - for channel_id, channel_config in channels.items(): - if not isinstance(channel_config, dict): - raise ReleaseIndexError(f"Channel {channel_id} must be an object") - label = require_string(channel_config.get("label"), f"channels.{channel_id}.label") - targets = channel_config.get("targets") - if not isinstance(targets, list): - raise ReleaseIndexError(f"channels.{channel_id}.targets must be an array") - channel_versions = versions.get(channel_id, set()) - if len(channel_versions) > 1: - raise ReleaseIndexError(f"Channel {channel_id} contains multiple current versions: {sorted(channel_versions)}") - current_version = next(iter(channel_versions), None) - latest[channel_id] = current_version - output_targets: list[dict[str, Any]] = [] - channel_unsigned: bool | None = None - - for target in targets: - if not isinstance(target, dict): - raise ReleaseIndexError(f"Channel {channel_id} target must be an object") - platform_id = require_string(target.get("platform"), f"channels.{channel_id}.targets.platform") - arch = require_string(target.get("arch"), f"channels.{channel_id}.targets.arch") - status = require_string(target.get("status"), f"{channel_id}/{platform_id}/{arch}.status") - if status not in TARGET_STATUSES: - raise ReleaseIndexError(f"Unsupported target status: {status}") - verification = target.get("verification") - mirrors = target.get("mirrors") - if not isinstance(verification, dict) or not isinstance(mirrors, list): - raise ReleaseIndexError(f"{channel_id}/{platform_id}/{arch} requires verification and mirrors") - cleanroom = verification.get("cleanroom") - if cleanroom not in CLEANROOM_STATUSES: - raise ReleaseIndexError(f"Unsupported cleanroom status for {channel_id}/{platform_id}/{arch}") - key = (channel_id, platform_id, arch) - if key in configured_keys: - raise ReleaseIndexError(f"Duplicate distribution target: {channel_id}/{platform_id}/{arch}") - configured_keys.add(key) - manifest = manifests.get(key) - - validated_mirrors: list[dict[str, Any]] = [] - - if status == "available": - if manifest is None: - raise ReleaseIndexError(f"Available target has no release manifest: {channel_id}/{platform_id}/{arch}") - if verification.get("native_build") is not True: - raise ReleaseIndexError(f"Available target lacks native build evidence: {channel_id}/{platform_id}/{arch}") - if cleanroom != "verified" or not verification.get("evidence"): - raise ReleaseIndexError(f"Available target lacks cleanroom evidence: {channel_id}/{platform_id}/{arch}") - if not mirrors: - raise ReleaseIndexError(f"Available target has no verified download channel: {channel_id}/{platform_id}/{arch}") - mirror_ids: set[str] = set() - for mirror in mirrors: - if not isinstance(mirror, dict): - raise ReleaseIndexError(f"Mirror for {channel_id}/{platform_id}/{arch} must be an object") - mirror_id = require_string(mirror.get("id"), "mirror.id") - if mirror_id in mirror_ids: - raise ReleaseIndexError(f"Duplicate mirror id for {channel_id}/{platform_id}/{arch}: {mirror_id}") - mirror_ids.add(mirror_id) - kind = require_string(mirror.get("kind"), "mirror.kind") - if kind not in {"official", "mirror"}: - raise ReleaseIndexError(f"Unsupported mirror kind: {kind}") - url_template = require_string(mirror.get("url"), "mirror.url") - parsed_url = urlsplit(url_template) - if "{file}" not in url_template: - raise ReleaseIndexError(f"Mirror URL must include {{file}}: {mirror_id}") - if parsed_url.scheme not in {"", "https"} or (not parsed_url.scheme and url_template.startswith("/")): - raise ReleaseIndexError(f"Mirror URL must be relative or HTTPS: {mirror_id}") - if ".." in PurePosixPath(unquote(parsed_url.path)).parts: - raise ReleaseIndexError(f"Mirror URL must not escape its download root: {mirror_id}") - if kind == "mirror" and parsed_url.scheme != "https": - raise ReleaseIndexError(f"External mirror must use HTTPS: {mirror_id}") - if kind == "mirror": - audit = mirror.get("audit") - if not isinstance(audit, dict): - raise ReleaseIndexError(f"External mirror requires an audit record: {mirror_id}") - for field in ["uploaded_by", "withdrawal_owner"]: - require_string(audit.get(field), f"mirror.audit.{field}") - for field in ["uploaded_at", "verified_at"]: - require_timestamp(audit.get(field), f"mirror.audit.{field}") - if not isinstance(audit.get("withdrawn"), bool): - raise ReleaseIndexError(f"mirror.audit.withdrawn must be a boolean: {mirror_id}") - if audit["withdrawn"]: - require_timestamp(audit.get("withdrawn_at"), "mirror.audit.withdrawn_at") - continue - validated_mirrors.append( - { - "id": mirror_id, - "label": require_string(mirror.get("label"), "mirror.label"), - "kind": kind, - "url": url_template, - "primary": bool(mirror.get("primary", False)), - "verified_sha256": mirror.get("verified_sha256"), - } - ) - primary_mirrors = [mirror for mirror in validated_mirrors if mirror["primary"]] - if len(primary_mirrors) != 1 or primary_mirrors[0]["kind"] != "official": - raise ReleaseIndexError( - f"Available target requires exactly one primary official channel: {channel_id}/{platform_id}/{arch}" - ) - if not any(mirror["kind"] == "official" for mirror in validated_mirrors): - raise ReleaseIndexError(f"Available target has no official download channel: {channel_id}/{platform_id}/{arch}") - - artifacts: list[dict[str, Any]] = [] - if manifest is not None: - if current_version != manifest["oneagent_version"]: - raise ReleaseIndexError(f"Manifest version drift for {channel_id}/{platform_id}/{arch}") - channel_unsigned = manifest["unsigned"] if channel_unsigned is None else channel_unsigned - if channel_unsigned != manifest["unsigned"]: - raise ReleaseIndexError(f"Channel {channel_id} mixes signed and unsigned manifests") - for artifact in manifest["artifacts"]: - downloads: list[dict[str, Any]] = [] - if status == "available": - for mirror in validated_mirrors: - verified_sha = mirror["verified_sha256"] - if mirror["kind"] == "mirror" and ( - not isinstance(verified_sha, str) or verified_sha.lower() != artifact["sha256"] - ): - raise ReleaseIndexError(f"External mirror SHA-256 is not verified for {artifact['file']}") - downloads.append( - { - "id": mirror["id"], - "label": mirror["label"], - "kind": mirror["kind"], - "url": mirror["url"].replace("{file}", artifact["file"]), - "primary": mirror["primary"], - } - ) - artifacts.append( - { - **artifact, - "kind": artifact_kind(artifact["file"]), - "downloads": downloads, - } - ) - if status == "available" and not any(item["kind"] == "binary" for item in artifacts): - raise ReleaseIndexError(f"Available target has no binary artifact: {channel_id}/{platform_id}/{arch}") - - output_targets.append( - { - "id": target_id(platform_id, arch), - "platform": platform_id, - "platformLabel": PLATFORM_LABELS.get(platform_id, platform_id), - "arch": arch, - "archLabel": ARCH_LABELS.get(arch, arch), - "status": status, - "verification": { - "native_build": bool(verification.get("native_build", False)), - "cleanroom": cleanroom, - "evidence": verification.get("evidence"), - }, - "python": manifest.get("python") if manifest else None, - "built_at": manifest.get("built_at") if manifest else None, - "agent_versions": manifest.get("agent_versions", {}) if manifest else {}, - "artifacts": artifacts, - } - ) - - published_at = channel_config.get("published_at") - if any(target["status"] == "available" for target in output_targets): - published_at = require_timestamp(published_at, f"channels.{channel_id}.published_at") - - output_channels.append( - { - "channel": channel_id, - "label": label, - "published_at": published_at, - "version": current_version, - "unsigned": channel_unsigned if channel_unsigned is not None else channel_id != "stable", - "status": "available" if any(target["status"] == "available" for target in output_targets) else "unavailable", - "targets": output_targets, - } - ) - - unknown = sorted(set(manifests) - configured_keys) - if unknown: - formatted = ", ".join("/".join(key) for key in unknown) - raise ReleaseIndexError(f"Release manifests have no channel target configuration: {formatted}") - - return { - "schema_version": 1, - "product": product, - "latest": latest, - "channels": output_channels, - } - - -def copy_available_downloads(index: dict[str, Any], release_dir: Path, destination: Path) -> None: - reset_destination(destination) - for channel in index["channels"]: - for target in channel["targets"]: - if target["status"] != "available": - continue - for artifact in target["artifacts"]: - source = release_dir / artifact["file"] - shutil.copy2(source, destination / artifact["file"]) - - -def reset_destination(destination: Path) -> None: - destination.mkdir(parents=True, exist_ok=True) - for child in destination.iterdir(): - if child.is_file() or child.is_symlink(): - child.unlink() - else: - raise ReleaseIndexError(f"Download destination contains an unexpected directory: {child}") - - -def copy_available_release_assets(index: dict[str, Any], release_dir: Path, destination: Path) -> None: - reset_destination(destination) - copied: set[str] = set() - for channel in index["channels"]: - for target in channel["targets"]: - if target["status"] != "available": - continue - metadata = [ - f"release-manifest-{target['platform']}-{target['arch']}.json", - f"SHA256SUMS-{target['platform']}-{target['arch']}.txt", - ] - for filename in metadata: - source = release_dir / filename - if not source.is_file(): - raise ReleaseIndexError(f"Available release metadata is missing: {filename}") - if filename not in copied: - shutil.copy2(source, destination / filename) - copied.add(filename) - for artifact in target["artifacts"]: - filename = artifact["file"] - if filename not in copied: - shutil.copy2(release_dir / filename, destination / filename) - copied.add(filename) - (destination / "release-index.json").write_text( - json.dumps(index, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - - -def main() -> None: - parser = argparse.ArgumentParser(description="Validate native release manifests and build the public release index") - parser.add_argument("--release-dir", type=Path, default=ROOT / "release") - parser.add_argument("--channels", type=Path, default=ROOT / "distribution" / "channels.json") - parser.add_argument("--output", type=Path, default=ROOT / "site" / "src" / "generated" / "release-index.json") - parser.add_argument("--copy-downloads", type=Path) - parser.add_argument("--copy-release-assets", type=Path) - args = parser.parse_args() - - try: - index = build_release_index(args.release_dir, args.channels) - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(index, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - if args.copy_downloads: - copy_available_downloads(index, args.release_dir, args.copy_downloads) - if args.copy_release_assets: - copy_available_release_assets(index, args.release_dir, args.copy_release_assets) - except ReleaseIndexError as exc: - raise SystemExit(str(exc)) from exc - print(args.output) - - -if __name__ == "__main__": - main() diff --git a/scripts/build_site_catalog.py b/scripts/build_site_catalog.py deleted file mode 100755 index 8d9f86ef..00000000 --- a/scripts/build_site_catalog.py +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import Any -from urllib.parse import urlsplit - -ROOT = Path(__file__).resolve().parents[1] -if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) - -from oneagent.catalog import ADAPTER_PROTOCOLS, AGENT_GROUPS - - -class SiteCatalogError(ValueError): - pass - - -PROTOCOL_STATUSES = {"implementation-supported", "release-candidate-required", "verified", "unsupported"} - - -def load_json(path: Path) -> dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise SiteCatalogError(f"Cannot read {path}: {exc}") from exc - if not isinstance(value, dict): - raise SiteCatalogError(f"{path} must contain a JSON object") - return value - - -def require_https_url(value: Any, label: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise SiteCatalogError(f"{label} must be a non-empty HTTPS URL") - normalized = value.strip() - parsed = urlsplit(normalized) - if parsed.scheme != "https" or not parsed.netloc: - raise SiteCatalogError(f"{label} must use HTTPS") - return normalized - - -def build_site_catalog(lock_path: Path, providers_path: Path) -> dict[str, Any]: - lock = load_json(lock_path) - provider_config = load_json(providers_path) - if lock.get("schema_version") != 1 or not isinstance(lock.get("agents"), dict): - raise SiteCatalogError("Unsupported Agent lock schema") - if provider_config.get("schema_version") != 1 or not isinstance(provider_config.get("providers"), dict): - raise SiteCatalogError("Unsupported public Provider schema") - - agents: list[dict[str, Any]] = [] - for agent_id, meta in lock["agents"].items(): - if not isinstance(meta, dict): - raise SiteCatalogError(f"Agent {agent_id} must be an object") - config_mode = meta.get("config_mode") - if config_mode not in {"auto", "guide"}: - raise SiteCatalogError(f"Agent {agent_id} has unsupported config_mode") - package = meta.get("package") if isinstance(meta.get("package"), dict) else None - adapter = str(meta.get("config_adapter") or "") - managed_install = config_mode == "auto" and package is not None - managed_config = config_mode == "auto" and bool(adapter) - agents.append( - { - "id": agent_id, - "name": str(meta.get("name") or agent_id), - "group": str(meta.get("group") or "other"), - "rank": int(meta.get("rank", 99)), - "platforms": [str(value) for value in meta.get("platforms", [])], - "lockedVersion": str(package.get("version")) if package and package.get("version") else None, - "source": str(package.get("source")) if package and package.get("source") else None, - "license": str(package.get("license")) if package and package.get("license") else None, - "licenseUrl": str(package.get("license_url")) if package and package.get("license_url") else None, - "guide": str(meta.get("guide")) if meta.get("guide") else None, - "protocol": ADAPTER_PROTOCOLS.get(adapter) if managed_config else None, - "support": { - "managedInstall": managed_install, - "officialInstallGuide": config_mode == "guide", - "managedConfig": managed_config, - }, - } - ) - agents.sort(key=lambda agent: (agent["rank"], agent["id"])) - - providers: list[dict[str, Any]] = [] - for provider_id, meta in provider_config["providers"].items(): - if not isinstance(meta, dict): - raise SiteCatalogError(f"Provider {provider_id} must be an object") - relationship = meta.get("relationship", "none") - if relationship not in {"none", "referral", "sponsor"}: - raise SiteCatalogError(f"Provider {provider_id} has unsupported relationship") - if relationship != "none" and (not meta.get("disclosure") or not meta.get("referral_url")): - raise SiteCatalogError(f"Provider {provider_id} commercial relationship requires disclosure and referral_url") - if relationship == "none" and (meta.get("disclosure") or meta.get("referral_url")): - raise SiteCatalogError(f"Provider {provider_id} cannot carry undisclosed commercial fields") - protocols = meta.get("protocols", {}) - if not isinstance(protocols, dict): - raise SiteCatalogError(f"Provider {provider_id}.protocols must be an object") - for protocol, status in protocols.items(): - if not isinstance(protocol, str) or status not in PROTOCOL_STATUSES: - raise SiteCatalogError(f"Provider {provider_id} has an unsupported protocol status") - home = require_https_url(meta.get("home"), f"Provider {provider_id}.home") - referral_url = require_https_url(meta.get("referral_url"), f"Provider {provider_id}.referral_url") if relationship != "none" else "" - providers.append( - { - "id": provider_id, - "name": str(meta.get("name") or provider_id), - "home": home, - "relationship": relationship, - "disclosure": str(meta.get("disclosure") or ""), - "referralUrl": referral_url, - "protocols": [{"id": str(key), "status": str(value)} for key, value in protocols.items()], - "order": int(meta.get("order", 99)), - } - ) - providers.sort(key=lambda provider: (provider["order"], provider["name"])) - - return { - "schema_version": 1, - "groups": AGENT_GROUPS, - "agents": agents, - "providers": providers, - } - - -def main() -> None: - parser = argparse.ArgumentParser(description="Project the runtime catalog into public, read-only website data") - parser.add_argument("--lock", type=Path, default=ROOT / "agents.lock.json") - parser.add_argument("--providers", type=Path, default=ROOT / "distribution" / "providers.json") - parser.add_argument("--output", type=Path, default=ROOT / "site" / "src" / "generated" / "catalog.json") - args = parser.parse_args() - try: - catalog = build_site_catalog(args.lock, args.providers) - except SiteCatalogError as exc: - raise SystemExit(str(exc)) from exc - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - print(args.output) - - -if __name__ == "__main__": - main() diff --git a/site/e2e/site.spec.ts b/site/e2e/site.spec.ts index a9c33ed6..11d289c0 100644 --- a/site/e2e/site.spec.ts +++ b/site/e2e/site.spec.ts @@ -19,23 +19,14 @@ test("home states the product boundary and current channel", async ({ page }) => await expect(page.getByRole("heading", { level: 1 })).toContainText("激活你的 AI 开发环境"); await expect(page.getByRole("link", { name: "下载 OneAgent" })).toBeVisible(); await expect(page.getByText("自己的 Key", { exact: true })).toBeVisible(); - await expect(page.locator(".hero-note")).toContainText("未签名技术预览版"); + await expect(page.locator(".hero-note")).toContainText("GitHub"); }); -test("download center recommends an available artifact but keeps manual choices", async ({ page, request }) => { - const index = await (await request.get("/release-index.json")).json(); - const preview = index.channels.find((channel: { channel: string }) => channel.channel === "technical-preview-unsigned"); - const mac = preview.targets.find((target: { id: string }) => target.id === "macos-arm64"); - const expectedSha = mac.artifacts[0].sha256; +test("download center links only to GitHub Releases", async ({ page }) => { await page.goto("/downloads/"); - const select = page.getByLabel("选择平台与架构"); - await expect(select).toBeVisible(); - await select.selectOption("windows-x64"); - await expect(page.getByRole("heading", { name: "这个平台尚未公开发行" })).toBeVisible(); - await select.selectOption("macos-arm64"); - await expect(page.getByRole("link", { name: "下载 macOS 预览版" })).toBeVisible(); - await expect(page.getByText(expectedSha, { exact: true })).toBeVisible(); - await expect(page.getByText("未签名、未公证", { exact: true })).toBeVisible(); + const releaseLink = page.getByRole("link", { name: /查看 GitHub Releases?/ }).first(); + await expect(releaseLink).toBeVisible(); + await expect(releaseLink).toHaveAttribute("href", /^https:\/\/github\.com\/MaimoryLab\/OneAgent\/releases/); }); test("guide-only compatibility remains distinct from managed installation", async ({ page }) => { @@ -45,19 +36,6 @@ test("guide-only compatibility remains distinct from managed installation", asyn await expect(page.getByText("OneAgent 可管理安装", { exact: true })).toHaveCount(0); }); -test("public release index exposes the same verified artifact", async ({ request }) => { - const response = await request.get("/release-index.json"); - expect(response.ok()).toBeTruthy(); - const index = await response.json(); - const preview = index.channels.find((channel: { channel: string }) => channel.channel === "technical-preview-unsigned"); - const mac = preview.targets.find((target: { id: string }) => target.id === "macos-arm64"); - expect(preview.version).toBe("0.2.0-dev"); - expect(preview.unsigned).toBe(true); - expect(mac.verification.cleanroom).toBe("verified"); - expect(mac.artifacts[0].sha256).toMatch(/^[a-f0-9]{64}$/); - expect(mac.artifacts[0].downloads.some((download: { kind: string; primary: boolean }) => download.kind === "official" && download.primary)).toBe(true); -}); - test("serves its own stylesheet and Agent marks rather than 404ing on them", async ({ page }) => { // A base path build (SITE_URL/BASE_PATH, as the Pages job uses) emits an // absolute , and the CSP declares base-uri 'self'. Serving such a diff --git a/site/package-lock.json b/site/package-lock.json index 97199963..d22570a0 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -6,7 +6,6 @@ "packages": { "": { "name": "oneagent-public-site", - "version": "0.2.0-dev", "dependencies": { "@astrojs/sitemap": "3.7.3", "astro": "7.1.4" diff --git a/site/package.json b/site/package.json index 54efab28..bc1fa89b 100644 --- a/site/package.json +++ b/site/package.json @@ -1,12 +1,10 @@ { "name": "oneagent-public-site", - "version": "0.2.0-dev", "private": true, "type": "module", "scripts": { - "prepare:data": "python3.12 ../scripts/build_release_index.py --release-dir ../release --channels ../distribution/channels.json --output src/generated/release-index.json --copy-downloads public/downloads && python3.12 ../scripts/build_site_catalog.py --lock ../agents.lock.json --providers ../distribution/providers.json --output src/generated/catalog.json", - "dev": "npm run prepare:data && astro dev", - "build": "npm run prepare:data && npm run check && astro build && npm run validate", + "dev": "astro dev", + "build": "npm run check && astro build && npm run validate", "check": "astro check", "test": "vitest run", "test:e2e": "playwright test", diff --git a/site/playwright.config.ts b/site/playwright.config.ts index d97c616a..6a5d991d 100644 --- a/site/playwright.config.ts +++ b/site/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ trace: "retain-on-failure", }, webServer: { - command: "npm run build && python3.12 -m http.server 4321 --bind 127.0.0.1 --directory dist", + command: "npm run build && npm run preview -- --host 127.0.0.1 --port 4321", url: "http://127.0.0.1:4321", reuseExistingServer: process.env.CI !== "true", }, diff --git a/site/scripts/validate-build.mjs b/site/scripts/validate-build.mjs index cf9488ec..02f41991 100644 --- a/site/scripts/validate-build.mjs +++ b/site/scripts/validate-build.mjs @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join, relative } from "node:path"; @@ -34,7 +33,7 @@ function localTarget(href) { } walk(rootPath); -for (const required of ["index.html", "downloads/index.html", "quickstart/index.html", "agents/index.html", "providers/index.html", "security/index.html", "release-index.json"]) { +for (const required of ["index.html", "downloads/index.html", "quickstart/index.html", "agents/index.html", "providers/index.html", "security/index.html"]) { if (!existsSync(join(rootPath, required))) failures.push(`Missing required output: ${required}`); } @@ -62,24 +61,8 @@ for (const path of htmlFiles) { } } -const index = JSON.parse(readFileSync(join(rootPath, "release-index.json"), "utf8")); -for (const channel of index.channels) { - for (const target of channel.targets) { - if (target.status !== "available") continue; - for (const artifact of target.artifacts) { - const path = join(rootPath, "downloads", artifact.file); - if (!existsSync(path)) { - failures.push(`Available artifact is missing from site output: ${artifact.file}`); - continue; - } - const digest = createHash("sha256").update(readFileSync(path)).digest("hex"); - if (digest !== artifact.sha256) failures.push(`Published artifact hash drift: ${artifact.file}`); - } - } -} - if (failures.length) { console.error(failures.join("\n")); process.exit(1); } -console.log(`Validated ${htmlFiles.length} HTML pages and all published release artifacts.`); +console.log(`Validated ${htmlFiles.length} HTML pages.`); diff --git a/site/src/components/DownloadSelector.astro b/site/src/components/DownloadSelector.astro index 486966ed..8e07bea3 100644 --- a/site/src/components/DownloadSelector.astro +++ b/site/src/components/DownloadSelector.astro @@ -1,90 +1,76 @@ --- -import releaseIndexData from "../generated/release-index.json"; -import { binaryArtifact, formatBytes, formatDate, primaryDownload, type ReleaseChannel } from "../lib/downloads"; +import { + formatBytes, + formatDate, + getLatestRelease, + releaseTargets, + releasesPageUrl, +} from "../lib/downloads"; -const releaseIndex = releaseIndexData as unknown as { channels: ReleaseChannel[] }; -const channel = releaseIndex.channels.find((item) => item.channel === "technical-preview-unsigned")!; -const defaultTarget = channel.targets.find((target) => target.status === "available") ?? channel.targets[0]; +const release = await getLatestRelease(); +const targets = release ? releaseTargets(release) : []; +const defaultTarget = targets[0] ?? null; ---
- - -
- {channel.targets.map((target) => { - const artifact = binaryArtifact(target); - const download = artifact ? primaryDownload(artifact) : null; - return ( -
-
-
-

{target.platformLabel}

-

{target.archLabel}

-
- {target.status === "available" ? 已验证可下载 : 验证中} + {release && defaultTarget ? ( + <> + - {target.status === "available" && artifact && download ? ( - <> -
-
版本
{channel.version}
-
发行渠道
{channel.label}
-
文件大小
{formatBytes(artifact.bytes)}
-
构建日期
{formatDate(target.built_at)}
-
签名状态
未签名、未公证
-
验证状态
原生构建 + cleanroom 通过
-
- -
- i -
- 下载即得到被校验的官方同包产物 -

任何官网、GitHub Release 或镜像渠道都必须保持相同 SHA-256,禁止二次打包。

-
-
+
+ {targets.map((target) => ( +
+
+

{target.platformLabel}

{target.archLabel}

+ GitHub Release +
+
+
版本
{release.tag_name}
+
发行类型
{release.prerelease ? "预发布版" : "正式版"}
+
文件大小
{formatBytes(target.bytes)}
+
发布日期
{formatDate(release.published_at)}
+
+ + {target.sha256 ? (
-

SHA-256

+

GitHub SHA-256

- {artifact.sha256} - + {target.sha256} +
-
-

macOS 校验命令

-
shasum -a 256 {artifact.file}
-
-

cleanroom 证据只对应此文件的精确 SHA-256;包体变化后必须重新验证。

- - ) : ( -
-

这个平台尚未公开发行

-

构建工作流已经保留,但在原生构建、cleanroom 证据和发行元数据齐备前,不提供空下载按钮,也不把 CI 产物描述为正式可用。

- 查看发行进度 -
- )} -
- ); - })} -
+ ) : target.checksumUrl ? ( +
i
校验和由 Release 提供

查看 SHA256SUMS

+ ) : null} +
+ ))} +
+ + ) : ( +
+

尚无已发布版本

+

下载页只展示 GitHub Release 中实际存在的版本和资产,不使用开发配置或本地构建结果补位。

+ 查看 GitHub Releases +
+ )}
+ + diff --git a/site/src/components/AgentMark.astro b/site/src/components/AgentMark.astro index f021765b..e59ab40f 100644 --- a/site/src/components/AgentMark.astro +++ b/site/src/components/AgentMark.astro @@ -15,9 +15,14 @@ const extensions: Record = { "kilo-cli": "svg", aider: "png", }; +/* These two marks ship as fill="currentColor". Loaded through the keyword + has no page to inherit from and falls back to black, which disappears on a + dark ground — so they get inverted there while the marks carrying their own + brand colours are left alone. */ +const monochrome = new Set(["codex", "opencode"]); const extension = extensions[id]; const imageSource = extension ? `${import.meta.env.BASE_URL}images/agents/${id}.${extension}` : null; --- diff --git a/site/src/components/CompatibilityExplorer.astro b/site/src/components/CompatibilityExplorer.astro new file mode 100644 index 00000000..0a58a6ad --- /dev/null +++ b/site/src/components/CompatibilityExplorer.astro @@ -0,0 +1,609 @@ +--- +import AgentMark from "./AgentMark.astro"; +import { catalog } from "../lib/catalog"; +import { localeFromPath, localePath } from "../i18n"; +import { useCatalogLabels } from "../i18n/catalog"; +import { protocolLabels } from "../lib/content"; +import { demoStateFor } from "../lib/demo-environment"; +import { compatibilityFor, type Compatibility } from "../lib/explorer"; + +const locale = localeFromPath(Astro.url.pathname); +const { agentDescriptions, groupLabels, agentFallbackDescription } = useCatalogLabels(locale); +const technicalHref = (agentId: string) => localePath("zh-CN", `agents/${agentId}/`); +const providerHref = (providerId: string) => localePath("zh-CN", `providers/${providerId}/`); +const copy = locale === "en" + ? { + filters: "Explorer filters", + platform: "Platform", + setup: "Install path", + config: "Configuration", + protocol: "Protocol", + provider: "Provider", + demo: "Demo state", + all: "All", + managedInstall: "Managed install", + officialGuide: "Official guide", + managedConfig: "Managed config", + officialConfig: "Official config", + ready: "Ready", + attention: "Needs attention", + notInstalled: "Not installed", + clear: "Clear filters", + results: "agents shown", + noResults: "No agent matches this combination.", + noResultsBody: "Clear a filter or choose a provider that implements the selected protocol.", + sample: "Demo state", + locked: "Locked", + officialRelease: "Official release", + open: "Open details for", + close: "Close details", + overview: "Environment example", + installState: "Example install state", + configState: "Example config state", + lockedVersion: "Catalog version", + configPath: "Configuration path", + command: "Launch command", + backup: "Example backup", + backupYes: "Available", + backupNo: "None yet", + installed: "Installed", + configured: "Configured", + needsSetup: "Needs setup", + providerCompatibility: "Provider compatibility", + capability: "Activation boundary", + managedBoundary: "OneAgent can manage this agent's configuration and backs up an existing file before a managed write.", + guideBoundary: "This agent stays in its official install, sign-in or extension flow. OneAgent does not write private configuration for it.", + technical: "Technical reference (Chinese)", + source: "Upstream source", + providerReference: "Provider reference (Chinese)", + illustrative: "Illustrative machine state; catalog capabilities are real.", + compatibility: { + verified: "Verified", + supported: "Implementation supported", + "preview-gate": "Release candidate required", + unsupported: "Unsupported", + } as Record, + } + : { + filters: "配置筛选", + platform: "平台", + setup: "安装路径", + config: "配置方式", + protocol: "协议", + provider: "Provider", + demo: "示例状态", + all: "全部", + managedInstall: "托管安装", + officialGuide: "官方引导", + managedConfig: "托管配置", + officialConfig: "官方配置", + ready: "Ready", + attention: "需处理", + notInstalled: "未安装", + clear: "清除筛选", + results: "个 Agent", + noResults: "没有 Agent 匹配这组条件。", + noResultsBody: "清除一个筛选,或选择实现了目标协议的 Provider。", + sample: "示例状态", + locked: "锁定", + officialRelease: "官方发行", + open: "打开详情:", + close: "关闭详情", + overview: "环境示例", + installState: "示例安装状态", + configState: "示例配置状态", + lockedVersion: "目录版本", + configPath: "配置位置", + command: "启动命令", + backup: "示例备份", + backupYes: "已有", + backupNo: "暂无", + installed: "已安装", + configured: "已配置", + needsSetup: "待配置", + providerCompatibility: "Provider 兼容性", + capability: "激活边界", + managedBoundary: "OneAgent 可以管理这个 Agent 的配置,并会在托管写入前备份已有文件。", + guideBoundary: "这个 Agent 保留官方安装、登录或扩展内流程;OneAgent 不写入它的私有配置。", + technical: "技术详情", + source: "上游源码", + providerReference: "Provider 详情", + illustrative: "机器状态属于示例;目录能力来自真实数据。", + compatibility: { + verified: "已验证", + supported: "实现支持", + "preview-gate": "需发布候选验证", + unsupported: "不支持", + } as Record, + }; + +const demoLabel = (status: string) => status === "ready" + ? copy.ready + : status === "attention" + ? copy.attention + : status === "guide-only" + ? copy.officialGuide + : copy.notInstalled; +--- + +
+ + + + + + + +
+ +
+ {catalog.agents.length} + {copy.results} + + {copy.illustrative} +
+ +
+ {catalog.agents.map((agent) => { + const demo = demoStateFor(agent); + const providers = catalog.providers + .filter((provider) => compatibilityFor(agent, provider) !== "unsupported") + .map((provider) => provider.id); + return ( + + ); + })} +
+ + + + + + +
+ +
+
+
+
+ + + + diff --git a/site/src/components/DownloadSelector.astro b/site/src/components/DownloadSelector.astro index 8e07bea3..52935bfe 100644 --- a/site/src/components/DownloadSelector.astro +++ b/site/src/components/DownloadSelector.astro @@ -1,83 +1,203 @@ --- -import { - formatBytes, - formatDate, - getLatestRelease, - releaseTargets, - releasesPageUrl, -} from "../lib/downloads"; +import { bestLocaleFor, localeFromPath, localePath } from "../i18n"; +import { formatBytes, formatDate } from "../lib/downloads"; +import { binaryArtifact, getPreviewChannel, primaryDownload } from "../lib/release-channel"; -const release = await getLatestRelease(); -const targets = release ? releaseTargets(release) : []; -const defaultTarget = targets[0] ?? null; +/* Everything below reads off a channel: the platform picker, the checksum, the + size. With no release published there is nothing to pick between, so the + component renders a single "not published yet" notice instead of four empty + panels that look like a broken page. */ +const channel = await getPreviewChannel(); +const defaultTarget = channel?.targets.find((target) => target.status === "available") ?? channel?.targets[0] ?? null; +const locale = localeFromPath(Astro.url.pathname); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +const c = locale === "en" + ? { + legend: "Choose platform and architecture", + available: "Available", + pending: "Verifying", + detected: "Showing the platform that is currently downloadable; you can switch at any time.", + notStableTitle: "This is not stable", + notStableBody: "The current package is unsigned and unnotarised. OneAgent does not document ways around your operating system's security policy.", + verified: "Verified and downloadable", + version: "Version", + channelLabel: "Release channel", + size: "File size", + built: "Build date", + signing: "Signing status", + unsigned: "Unsigned, unnotarised", + verification: "Verification", + verificationValue: "Native build + cleanroom passed", + download: (platform: string) => `Download the ${platform} preview`, + quickstart: "Read the quickstart", + sameBuildTitle: "You get the verified official build", + sameBuildBody: "This site, GitHub Releases and any mirror must serve the identical SHA-256. Repackaging is not permitted.", + checksumTitle: "macOS verification command", + copy: "Copy", + copied: "Copied", + cleanroomNote: "The cleanroom evidence applies to this file's exact SHA-256; any change to the package requires re-verification.", + unavailableTitle: "This platform has no public release yet", + unavailableBody: "The build workflow is in place, but until the native build, cleanroom evidence and release metadata are all present there is no empty download button and no CI artifact described as generally available.", + progress: "See release progress", + channelValue: "Unsigned technical preview", + fallbackPlatform: "current platform", + detectExact: "Detected as {full}. You can still switch manually.", + detectArchOnly: "Detected as {name} {arch}, but there is no exact build for that architecture here — do not run a build for a mismatched architecture.", + detectPlatformOnly: "Detected as {name}, but the browser cannot reliably tell the chip architecture. Confirm the option before downloading.", + } + : { + legend: "选择平台与架构", + available: "可下载", + pending: "验证中", + detected: "已优先显示当前可下载的平台;你可以随时手动切换。", + notStableTitle: "这不是 Stable", + notStableBody: "当前包未签名、未公证。OneAgent 不提供绕过操作系统安全策略的说明。", + verified: "已验证可下载", + version: "版本", + channelLabel: "发行渠道", + size: "文件大小", + built: "构建日期", + signing: "签名状态", + unsigned: "未签名、未公证", + verification: "验证状态", + verificationValue: "原生构建 + cleanroom 通过", + download: (platform: string) => `下载 ${platform} 预览版`, + quickstart: "查看快速开始", + sameBuildTitle: "下载即得到被校验的官方同包产物", + sameBuildBody: "任何官网、GitHub Release 或镜像渠道都必须保持相同 SHA-256,禁止二次打包。", + checksumTitle: "macOS 校验命令", + copy: "复制", + copied: "已复制", + cleanroomNote: "cleanroom 证据只对应此文件的精确 SHA-256;包体变化后必须重新验证。", + unavailableTitle: "这个平台尚未公开发行", + unavailableBody: "构建工作流已经保留,但在原生构建、cleanroom 证据和发行元数据齐备前,不提供空下载按钮,也不把 CI 产物描述为正式可用。", + progress: "查看发行进度", + channelValue: "未签名技术预览版", + fallbackPlatform: "当前平台", + detectExact: "已识别为 {full};你仍可手动切换。", + detectArchOnly: "已识别为 {name} {arch},当前目录没有完全匹配的构建;请勿运行架构不匹配的包。", + detectPlatformOnly: "已识别为 {name},但浏览器无法可靠判断芯片架构;请确认选项后下载。", + }; --- +{!channel || !defaultTarget ? ( +
+ ! +
+ {c.unavailableTitle} +

{c.unavailableBody}

+
+
+) : (
- {release && defaultTarget ? ( - <> - - + + +
+ {channel.targets.map((target) => { + const artifact = binaryArtifact(target); + const download = artifact ? primaryDownload(artifact) : null; + return ( +
+
+
+

{target.platformLabel}

+

{target.archLabel}

- {target.sha256 ? ( + {target.status === "available" ? {c.verified} : {c.pending}} +
+ + {target.status === "available" && artifact && download ? ( + <> +
+
{c.version}
{channel.version}
+
{c.channelLabel}
{c.channelValue}
+
{c.size}
{formatBytes(artifact.bytes)}
+
{c.built}
{formatDate(target.built_at)}
+
{c.signing}
{c.unsigned}
+
{c.verification}
{c.verificationValue}
+
+ +
+ i +
+ {c.sameBuildTitle} +

{c.sameBuildBody}

+
+
-

GitHub SHA-256

+

SHA-256

- {target.sha256} - + {/* Both this and the command below scroll horizontally on narrow + screens, so they need to be reachable without a pointer + (WCAG 2.1.1). The label names which value has focus. */} + {artifact.sha256} +
- ) : target.checksumUrl ? ( -
i
校验和由 Release 提供

查看 SHA256SUMS

- ) : null} -
- ))} -
- - ) : ( -
-

尚无已发布版本

-

下载页只展示 GitHub Release 中实际存在的版本和资产,不使用开发配置或本地构建结果补位。

- 查看 GitHub Releases -
- )} +
+

{c.checksumTitle}

+
shasum -a 256 {artifact.file}
+
+

{c.cleanroomNote}

+ + ) : ( +
+

{c.unavailableTitle}

+

{c.unavailableBody}

+ {c.progress} +
+ )} + + ); + })} +
+)} diff --git a/site/src/components/Footer.astro b/site/src/components/Footer.astro index f3e0d409..f29178b4 100644 --- a/site/src/components/Footer.astro +++ b/site/src/components/Footer.astro @@ -1,39 +1,69 @@ --- import BrandMark from "./BrandMark.astro"; +import { bestLocaleFor, localeFromPath, localePath, switchesLanguage } from "../i18n"; +import { useTranslations } from "../i18n/ui"; import { releasesPageUrl } from "../lib/downloads"; + const year = new Date().getUTCFullYear(); +const locale = localeFromPath(Astro.url.pathname); +const t = useTranslations(locale); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +/* Column links, grouped as rendered. A `path` is resolved per locale and gets a + hint when the target has no translation; an `href` is an absolute artifact URL + that is the same in every locale. */ +type FooterLink = { path: string; label: string } | { href: string; label: string }; +const columns: { heading: string; links: FooterLink[] }[] = [ + { + heading: t("footer.start"), + links: [ + { path: "downloads/", label: t("footer.downloadCenter") }, + { path: "quickstart/", label: t("nav.quickstart") }, + { path: "changelog/", label: t("nav.changelog") }, + ], + }, + { + heading: t("footer.capability"), + links: [ + { path: "explore/", label: t("nav.explorer") }, + { path: "agents/", label: t("footer.agentCatalog") }, + { path: "providers/", label: t("footer.providerCatalog") }, + ], + }, + { + heading: t("footer.trust"), + links: [ + { path: "support/", label: t("footer.supportFeedback") }, + // A published artifact rather than a page: same URL in every locale, so it + // is exempt from the locale resolution and the hint. + { href: releasesPageUrl, label: t("footer.releaseIndex") }, + ], + }, +]; --- diff --git a/site/src/components/Header.astro b/site/src/components/Header.astro index 1d5a5f31..3d5b162c 100644 --- a/site/src/components/Header.astro +++ b/site/src/components/Header.astro @@ -1,38 +1,56 @@ --- import BrandMark from "./BrandMark.astro"; +import ThemeToggle from "./ThemeToggle.astro"; +import LocaleSwitch from "./LocaleSwitch.astro"; +import { bestLocaleFor, localeFromPath, localePath, routeWithoutLocale, switchesLanguage } from "../i18n"; +import { useTranslations } from "../i18n/ui"; const pathname = Astro.url.pathname; -const basePath = import.meta.env.BASE_URL; -const withBase = (path: string) => `${basePath}${path.replace(/^\/+/, "")}`; +const locale = localeFromPath(pathname); +const t = useTranslations(locale); +const href = (path: string) => localePath(bestLocaleFor(locale, path), path); +/* The security and enterprise pages stay published and linked from the footer, + the release index and the demo's preview-gate result — they are just not + worth a top-level nav slot. */ const nav = [ - { path: "downloads/", label: "下载" }, - { path: "quickstart/", label: "快速开始" }, - { path: "agents/", label: "Agent" }, - { path: "providers/", label: "Provider" }, - { path: "security/", label: "安全" }, + { path: "downloads/", label: t("nav.downloads") }, + { path: "quickstart/", label: t("nav.quickstart") }, + { path: "explore/", label: t("nav.explorer") }, ]; -const active = (path: string) => pathname.includes(`/${path}`); +// Compared on the locale-stripped route so /en/agents/ marks the same item as +// /agents/ rather than matching on a substring of the full path. +const route = routeWithoutLocale(pathname); +const active = (path: string) => + route === path || + (path === "explore/" && (route === "agents/" || route.startsWith("agents/") || route === "providers/" || route.startsWith("providers/"))); ---