feat(config): add recovery and health diagnostics

This commit is contained in:
xiaoxixi 2026-08-20 19:13:34 +08:00
parent 93e74120ac
commit 9ca78864a2
18 changed files with 1471 additions and 134 deletions

View File

@ -21,6 +21,7 @@ This file is the operational contract for coding agents working in this reposito
- Config load order: `~/.picobot/config.json` then fallback to `./config.json` (`Config::load_default` in `src/config/mod.rs`)
- `.env` files use a custom parser, not dotenv: load `<config-dir>/.env`, then `<workspace_dir>/.env`, while pre-existing process variables remain highest priority; config placeholders `<VAR_NAME>` use the merged values
- Config example: `resources/templates/config.example.json` (released to `~/.picobot/` on first run)
- Runtime config loading ignores recoverable unknown/type-mismatched fields and invalid non-core named entries while retaining diagnostics and the raw file revision; malformed JSON, an unusable `default` Agent chain, and unsafe runtime construction remain fatal. WebUI writes are strict, and backend cleanup may remove only diagnosed paths from the same revision
- CLI TUI identity is stored in `~/.picobot/tui_client_id`; it is a non-secret stable chat scope used to restore dialogs across reconnects
- One-shot `run` uses a unique chat scope per invocation; for loopback Gateway URLs it authenticates `/ws` with `~/.picobot/web_admin_token`, while remote URLs use the existing paired CLI token
@ -97,6 +98,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **Context compaction** keeps `messages` append-only and uses one active checkpoint per Session (`summary + first_retained_seq`) for deterministic Provider projection; `/compact`, Turn-boundary auto compaction, and overflow share the same compactor/CAS commit path, Session restoration never derives context from Timeline or calls a Provider, the Model `token_limit` (default 128K) is the hard window ceiling and an optional Agent `token_limit` can only narrow it via `min(agent, model)`, summary input is bounded from that effective window rather than a fixed cap, and the only automatic threshold is `context_tokens > context_window - effective_reserve`
- **AgentCatalog** is immutable per runtime generation; candidate preparation strictly validates trusted Markdown definitions, Provider profiles, tool/Skill allowlists, and delegation edges before activation. A definition that fails per-file validation (bad YAML, unknown provider/profile/model/tool/skill, or an explicit delegate edge to an absent target) is disabled for that generation only and reported via `load_errors` (exposed by `GET /api/agents`), never blocking startup or reload; config- and directory-trust-level failures remain fatal. Sub-Agent orchestration is an intrinsic, always-on mechanism (no feature switch). Named Agents support foreground (single/batch) and Root-initiated single background execution with durable run/inbox delivery; a built-in general-purpose definition is released to `~/.picobot/agents/` on first run. Background batches and nested background remain restricted
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
- **Configuration recovery** builds an effective typed `Config` from a request-local copy and never rewrites the source automatically; diagnostics use raw RFC 6901 paths, array recovery preserves original indexes, ordinary writes reject ignored fields, and cleanup requires the exact source SHA-256 revision before atomically deleting diagnosed paths
- **WebUI styling** uses the local Fluent 2 semantic tokens in `webui/src/styles.css`; page and component styles should consume the aliases instead of introducing independent hard-coded palettes, and must preserve selectable light/dark and brand-color themes, keyboard focus, responsive layout, and reduced-motion behavior. Browser-only appearance settings belong in `lib/theme.js`/`localStorage`, and `public/theme-init.js` must restore them before Svelte mounts
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
- **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source
@ -113,7 +115,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **Skill enable/disable**: skills default to enabled; user-disabled skill names are persisted in `<config_dir>/skills_state.json` (skill files are never modified), and disabled skills are excluded from prompts, listings, and `get_skill` at load time
- **MCP enable/disable**: `mcp.servers[].enabled` defaults to true; disabled servers are skipped at activation (no connection attempt) and by health checks
- **Stateful tools** receive `ToolExecutionContext`; browser calls without `persistent_id` map each PicoBot dialog to an opaque transient agent-browser session, whose idle daemon timeout is controlled by `browser.idle_timeout_secs` (one hour by default). For long-running work an Agent may autonomously create a persistent identity and must pass its validated ID on every related action; persistent browser daemons disable idle auto-close, the same ID shares one agent-browser session and serialization gate across dialogs, and different IDs have independent sessions/gates. `browser_profiles` may create IDs, persist bounded semantic labels, list, or delete only validated IDs beneath the configured profile root. Do not introduce a global persistence mode switch, a default persistent ID, automatic per-dialog persistent Profiles, Fantoccini, ChromeDriver, WebDriver, model-controlled raw agent-browser session IDs, or arbitrary Profile paths
- **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, and `/health`; checks must not install/fix dependencies, call Provider APIs, or expose secrets
- **Health diagnostics** are read-only and share `HealthService` across `picobot health`, the `health` tool, `/health`, and authenticated WebUI `GET /api/health`; the public `GET /health` remains a lightweight liveness/version probe, while full checks run only on demand and must not install/fix dependencies, call Provider APIs, or expose secrets. Treat `fd`/`fdfind` as equivalent preferred backends; browser launch diagnostics must isolate their socket namespace from active agent-browser sessions and must not use `--quick`, which skips the live launch probe
### Concurrency and Lifecycle Invariants

View File

@ -1,11 +1,12 @@
[package]
name = "picobot"
version = "1.20.0"
version = "1.21.0"
edition = "2024"
[dependencies]
reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "multipart"] }
serde = { version = "1.0", features = ["derive"] }
serde_path_to_error = "0.1"
regex = "1.13"
serde_json = "1.0"
serde_yaml = "0.9"

View File

@ -150,7 +150,9 @@ picobot health
picobot health --json
```
缺少核心或当前配置要求的依赖时退出码为 `1``rg` / `fd` 等有回退实现的加速项只会标记为 `DEGRADED`。运行中的 Gateway 也提供 `/health` 斜杠命令Agent 可调用同名 `health` 工具,三者共享同一套只读检查逻辑。
缺少核心或当前配置要求的依赖时退出码为 `1``rg` / `fd` 等有回退实现的加速项只会标记为 `DEGRADED`。运行中的 Gateway 也提供 `/health` 斜杠命令Agent 可调用同名 `health` 工具WebUI 的“配置 → 健康检查”可显示相同的结构化结果并手动复查;这些入口共享同一套只读检查逻辑。
Debian/Ubuntu 将同一个 fd 程序安装为 `fdfind`,两者都视为首选文件搜索后端;只有退回传统 `find` 时才提示性能警告。启用浏览器工具后Health 除了检查 agent-browser 版本和浏览器路径,还会在隔离的临时 socket namespace 中执行完整离线 doctor分别报告浏览器安装、真实 headless 启动和运行环境,因此可发现“文件存在但 Chrome 无法启动”或缺少 Linux 共享库等问题。
### 5.3 使用 WebUI
@ -185,9 +187,10 @@ WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件
- 当前聊天 session 的可展开 Todo 侧栏;计划变化时自动展开,其他 session 的变化显示未读提示。
- Knowledge/Timeline 记忆的分类与全文检索。
- 本地滚动日志的尾部查看、过滤和自动刷新。
- 健康检查结果:按核心与已配置功能展示通过、警告、失败及处理建议。
- `config.json``~/.picobot/USER.md``~/.picobot/AGENTS.md` 编辑。
配置接口会掩码 API Key、secret、password 和 token保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入,保存后可执行 `picobot reload` 或发送 `/reload` 热重载;`USER.md``AGENTS.md` 的修改用于后续构建的 Agent 上下文。
配置接口会掩码 API Key、secret、password 和 token保留 `********` 再保存不会覆盖原密钥。Gateway 加载历史配置时会忽略可恢复的未知字段、类型不匹配字段和不再可用的非核心命名条目并在日志、Health 和 WebUI 配置页显示对应 JSON Pointer原始文件不会被自动修改。WebUI 的“一键清除”由后端按配置 revision 删除这些已忽略项文件在展示后发生变化时会拒绝覆盖普通保存仍严格拒绝包含无效项的新配置。JSON 语法错误、不可用的 `default` Agent 链路以及无法安全构造 Gateway 的错误仍会阻止启动或重载。运行配置采用原子写入,保存或清理后可执行 `picobot reload` 或发送 `/reload` 热重载;`USER.md``AGENTS.md` 的修改用于后续构建的 Agent 上下文。
WebUI 默认启用设备配对鉴权,管理 API 与 `/ws` 都拒绝未配对客户端;静态配对页、公开健康检查和配对提交接口除外。唯一的 WebSocket 例外是本机 `picobot run`:请求必须同时来自真实回环对端并持有权限为 `0600``~/.picobot/web_admin_token`,该管理令牌不能绕过任何管理 API 的设备鉴权。配对令牌只以 SHA-256 哈希写入 `~/.picobot/web_auth.json`。鉴权不提供传输加密;如果通过 `--host 0.0.0.0`、反向代理或端口转发暴露 Gateway仍必须使用 TLS。可通过 `gateway.require_pairing=false` 显式关闭配对,但不建议在非隔离环境使用。

View File

@ -157,7 +157,7 @@ agent-browser install
## 8. 使用
1. 浏览器工具默认启用;从旧配置删除 `webdriver_url``chrome_path`,仅在需要改变持久目录位置时配置 `persistence.profile_dir`。没有持久化模式开关。缺少依赖不会阻止 Gateway 启动,只会让 health 和实际浏览器调用失败。
2. 运行 `picobot health`;应看到 agent-browser CLI 版本和 offline quick doctor 通过。
2. 运行 `picobot health`;应看到 agent-browser CLI 版本、浏览器安装、隔离的 offline headless 启动和环境检查通过。
3. 启动或重载 Gateway。
4. 对 Agent 说“使用浏览器打开 …”。模型的推荐动作序列是:
@ -193,7 +193,7 @@ browser_profiles(delete, id=picobot-profile-...)
- `/health`:当前 Gateway 配置的聊天入口。
- `health` ToolAgent 可调用的只读入口,支持 `json=true`
检查项包括 workspace、Bash、内容/文件搜索后端、可选 systemctl、配置中的 stdio MCP 命令,以及浏览器启用时的持久 Profile 可用性、agent-browser 版本、显式浏览器路径和 `doctor --offline --quick --json`。检查不创建或删除 Profile、不安装软件、不执行 `doctor --fix`、不访问 Provider API也不输出配置密钥。
检查项包括 workspace、Bash、内容/文件搜索后端、可选 systemctl、配置中的 stdio MCP 命令,以及浏览器启用时的持久 Profile 可用性、agent-browser 版本、显式浏览器路径和完整的 offline doctor。Health 为 doctor 创建临时 socket 目录并使用专用 namespace不读取或清理活动 daemon socket不使用会跳过真实启动测试的 `--quick`。结构化结果分别展示浏览器安装、headless launch 和其余环境诊断。检查不创建或删除 PicoBot 持久 Profile、不安装软件、不执行 `doctor --fix`、不访问 Provider API也不输出配置密钥。
## 10. 迁移和故障处理

View File

@ -266,13 +266,14 @@ MCP 发现的工具在 `ToolRegistry` 中使用 `mcp_<server-name>_<tool-name>`
`browser` 是有状态工具适配器:`BrowserTool` 保持模型侧 action schema`BrowserManager` 按每次调用是否带 `persistent_id` 分流。省略 ID 时把 PicoBot dialog 映射到随机临时 agent-browser session并用每 session mutex 保证同一页面串行、不同 dialog 并发;临时 daemon 按 `browser.idle_timeout_secs`默认一小时自动退出Manager 在容量检查时惰性回收对应空闲条目。长期工作需要保持浏览器进程或保留登录和站点状态时Agent 可自主创建持久身份并在后续相关 action 中持续传入同一个 ID。Manager 按持久 ID 保存 agent-browser session 和 mutex持久 daemon 的空闲超时固定为禁用;同一 ID 跨 dialog 共享且串行,不同 ID 相互独立并可并发Gateway 重启或显式关闭浏览器后仍可继续使用原 Profile没有全局持久化开关、默认 ID 或按 dialog 隐式选择。`browser_profiles` 在受控根目录下创建、设置语义化标签、列出或删除格式合法的 ID标签只负责识别选择仍使用不可变 ID删除活动 ID 时先等待其 action 并关闭浏览器。`AgentBrowserRunner` 以 argv 和 `--json` 调用外部原生 CLI设置硬超时、输出/content boundaries/domain allowlist并在持久调用中传入受控 `--profile` 路径,底层 daemon 通过 Chrome CDP 工作。PicoBot 不链接 agent-browser 内部 crate、不直接暴露其 MCP、不使用 Fantoccini/ChromeDriver/WebDriver。持久 Profile 与 `allowed_domains` 因上游安全边界互斥;设置域名限制时临时浏览器仍可用,持久调用会被拒绝。截图只能写入配置的 artifact directory并作为 `ModelAndUser` 产物返回,默认附到最终用户回复;仅当调用显式设置 `present_to_user=false` 时才作为模型内部观察。完整边界见 [AGENT_BROWSER_INTEGRATION.md](AGENT_BROWSER_INTEGRATION.md)。
`HealthService` 是依赖检查的唯一实现。CLI `picobot health`、只读 `health` 工具`/health` 斜杠命令必须复用它;检查可探测命令、版本、配置路径和 agent-browser offline quick doctor不能安装/修复软件、连接模型 API 或泄漏配置秘密。
`HealthService` 是依赖检查的唯一实现。CLI `picobot health`、只读 `health` 工具`/health` 斜杠命令和 WebUI“配置 → 健康检查”必须复用它;受鉴权的 `GET /api/health` 按需返回 `HealthReport`,公开 `GET /health` 仍只承担轻量在线与版本探测,避免页面常驻轮询反复执行外部诊断命令。检查可探测命令、版本和配置路径;`fd` 与 Debian/Ubuntu 的同程序命令名 `fdfind` 都是首选后端,传统 `find` 才是降级回退。浏览器检查使用临时 socket 目录和专用 namespace 运行完整的 agent-browser offline doctor将浏览器安装、真实 headless 启动和其余环境问题拆分报告,既不接触活动 daemon socket也不跳过 launch probe。Health 不能安装/修复软件、连接模型 API 或泄漏配置秘密。
`AuthManager` 默认保护所有管理 API 和 `/ws`。静态资源、`/health``/api/auth/status``/api/auth/pair` 保持公开,使未配对浏览器只能加载配对界面。`picobot pair` 使用权限为 `0600` 的本机管理密钥调用仅接受真实回环连接的 `/api/auth/code`;反向代理即使从回环连接也无法在没有该密钥时签发代码。本机 `picobot run` 可用同一个管理密钥直接认证 `/ws`,但中间件必须同时验证请求路径严格等于 `/ws``ConnectInfo` 中的真实 TCP 对端为回环地址;这一身份不能访问管理 API。远程 `run` 与 TUI 一样使用已配对的 Bearer token。配对码为 8 位、5 分钟有效、单次消费,并按来源实施失败锁定。浏览器收到 HttpOnly、SameSite=Strict CookieCLI 使用 Bearer token服务端仅持久化 SHA-256 哈希。`--revoke-all` 的持久化成功后才清空内存令牌,活动 WebSocket 每 5 秒复核身份并回收已撤销连接。
同源 `/api/*` 管理接口只提供显式白名单能力:
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。`POST /api/config/reload` 通过同一重载控制器校验并切换 Gateway 运行代,`GET /api/config/reload/status` 查询 generation、相位与最近错误。
- `GET /api/health` 返回当前运行代 `HealthService` 的完整只读报告;它只在用户进入健康检查页或手动刷新时运行,不属于 Gateway 在线探测轮询。
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。配置加载器在不修改原始文件的前提下,从请求内副本移除可恢复的未知字段、类型/枚举错误和失效的非核心命名条目,生成有效 `Config`、RFC 6901 诊断路径和原始文件 SHA-256 revision数组元素另外保留原始索引映射避免恢复过程中索引移动导致清理错位。`models.*``flatten` Provider 扩展参数以及 MCP 的 `env`/`headers`/`tool_settings` 动态键属于显式扩展面不作为未知项。JSON 损坏、不可用的 `default` Agent Provider/Model 链路和候选运行代无法安全构造仍是致命错误。普通 `PUT /api/config` 保持严格,不允许写入会被忽略的新配置;`POST /api/config/cleanup-invalid` 只按同一加载器报告的路径清理,并在 revision 不匹配时返回冲突。`POST /api/config/reload` 通过同一重载控制器校验并切换 Gateway 运行代,`GET /api/config/reload/status` 查询 generation、相位与最近错误。
- `USER.md``AGENTS.md` 只允许固定文件名,不接受任意路径。
- 日志、记忆、任务和运行记录均限制单次返回数量;日志目录固定为 `~/.picobot/logs`
- 任务与记忆读取复用 Storage API不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。
@ -285,7 +286,7 @@ MCP 发现的工具在 `ToolRegistry` 中使用 `mcp_<server-name>_<tool-name>`
### 启动
1. 解析配置路径,加载配置目录 `.env`,据此定位 workspace再加载 workspace `.env`;既有进程环境保持最高优先级,合并后重新解析配置。
1. 解析配置路径,加载配置目录 `.env`,据此定位 workspace再加载 workspace `.env`;既有进程环境保持最高优先级,合并后通过统一容错加载器重新解析配置。可恢复项只影响有效运行时投影并产生诊断,原始 `config.json` 保持不变。
2. 初始化日志,创建并切换到 workspace初始化 WebUI 配对存储与本机管理密钥。
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManagerScheduler 启用时幂等创建默认日常维护巡检。
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。

View File

@ -150,8 +150,8 @@ sequenceDiagram
准备阶段在旧运行代继续提供服务时执行:
1. `load_candidate()` 重新读取当前 Gateway 启动时确定的配置文件。
2. 使用启动环境快照和启动 cwd 解析 `.env`、占位符与相对 workspace。
3. 校验 default agent 能解析为完整 `LLMProviderConfig`
2. 使用启动环境快照和启动 cwd 解析 `.env`、占位符与相对 workspace;统一配置加载器在请求内副本上忽略可恢复的未知字段、类型错误和失效非核心条目,并保留原始 JSON Pointer 诊断,不改写磁盘文件
3. 校验 default agent 能解析为完整 `LLMProviderConfig`;核心链路不可恢复时仍拒绝候选
4. 若飞书启用,校验 `app_id``app_secret` 非空。
5. 比较不可热变更字段。
6. 调用 `GatewayState::from_config()` 构造候选运行代。
@ -271,7 +271,7 @@ Gateway 在首次调用 `Config::load_from()` 前保存:
| 失败阶段 | 行为 |
|----------|------|
| 已有 pending 重载/控制器关闭 | 分别返回 409/503不读取配置 |
| JSON、`.env`、占位符或默认 Agent 校验失败 | 返回错误,旧运行代保持不变 |
| JSON、`.env`、占位符或默认 Agent 校验失败 | 返回错误,旧运行代保持不变;可恢复的历史字段问题只产生诊断,不进入此失败分支 |
| 不可热变更字段发生变化 | 返回 restart-required 错误,旧运行代保持不变 |
| `GatewayState::from_config()` 构造失败 | 返回错误;候选被丢弃,其 TaskSupervisor 随对象释放取消;旧请求处理运行代保持不变 |
| 单个 MCP Server 连接或工具发现失败 | 记录 MCP 失败状态,候选继续构造且不注册该 Server 的工具;这不视为整体 reload 失败 |
@ -282,7 +282,7 @@ Gateway 在首次调用 `Config::load_from()` 前保存:
准备阶段成功后才向调用者返回 accepted。激活阶段仍可能遇到运行时错误因此调用者不应把 accepted 当作健康检查;可使用 `GET /api/config/reload/status` 等待相同 generation 进入 `active`,并结合 `/health` 与客户端重连确认。
WebUI `PUT /api/config` 只负责原子写文件、恢复被掩码的 secret 并返回 `restart_required: true`;它不会隐式触发重载。显式的 `POST /api/config/reload` 将文件写入与运行代切换解耦,使用户可以批量编辑后主动决定生效时机。
WebUI `PUT /api/config` 只负责严格校验、原子写文件、恢复被掩码的 secret 并返回 `restart_required: true`;它不会允许新提交内容依赖容错忽略,也不会隐式触发重载。`GET /api/config` 返回原始文件 SHA-256 revision 和可恢复诊断;`POST /api/config/cleanup-invalid` 必须提交相同 revision后端才会在共享配置写锁下原子删除已诊断路径revision 变化返回 409。显式的 `POST /api/config/reload` 将文件写入与运行代切换解耦,使用户可以批量编辑或清理后主动决定生效时机。
## 10. 并发与生命周期不变量
@ -320,6 +320,7 @@ WebUI `PUT /api/config` 只负责原子写文件、恢复被掩码的 secret 并
当前回归测试覆盖:
- 候选配置允许 Provider/模型等运行时字段变化。
- 可恢复未知字段、类型错误和失效非核心条目不阻止候选,并保留原始数组索引诊断;严格 WebUI 写入拒绝同样内容,清理只删除诊断路径。
- 相对 `workspace_dir` 按启动 cwd 正确解析。
- workspace 变化被拒绝且返回明确错误。
- `None` 与显式指向同一有效数据库路径(默认 `{config_dir}/data/picobot.db`)时允许重载。

View File

@ -32,7 +32,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
| `observability` | Observer 模式agent/工具遥测事件 |
| `protocol` | WebSocket 协议消息定义 |
| `config` | 配置加载、环境变量替换、路径解析 |
| `health` | CLI、工具和斜杠命令共用的只读运行依赖检查 |
| `health` | CLI、工具、斜杠命令和 WebUI 共用的只读运行依赖检查 |
| `memory` | 长期记忆存储与检索 |
| `mcp` | MCPModel Context Protocol工具集成 |
| `task_supervisor` | Gateway 后台任务注册、取消、限时等待和强制回收 |

View File

@ -3,7 +3,7 @@
配置文件加载顺序:`~/.picobot/config.json` → 当前目录 `./config.json`
占位符 `<VAR_NAME>` 从启动环境替换。PicoBot 依次加载 `config.json` 同目录的 `.env``workspace_dir/.env`最后保留启动进程已有环境变量作为最高优先级workspace 层覆盖配置目录层。合并值也会进入进程环境,供 MCP 和工具子进程继承。workspace `.env` 不能修改用于定位自身的 `workspace_dir`
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。运行配置保存后可执行 `picobot reload`、发送 `/reload`,或由根交互 Agent 在用户明确要求时调用 `reload_config` 工具。Gateway 会先校验候选配置,停止接收新工作并等待交互 Turn、Scheduler job 和后台子 Agent 到达安全边界后切换;失败时继续使用旧配置。`GET /api/config/reload/status` 可查询 generation、相位与最近错误。`gateway.host``gateway.port``workspace_dir``gateway.session_db_path` 的有效路径必须通过完整重启变更。`USER.md``AGENTS.md` 的修改用于后续构建的 Agent 上下文。
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。Gateway 会忽略可恢复的历史未知字段、类型不匹配字段和失效的非核心命名条目,在配置页按 JSON Pointer 显示诊断,但不会自动改写原始文件;“一键清除”由后端校验文件 revision 后原子删除已诊断项普通保存仍严格拒绝无效配置。JSON 损坏、不可用的 `default` Agent 链路和无法安全构造运行代的错误不会被忽略。运行配置保存或清理后可执行 `picobot reload`、发送 `/reload`,或由根交互 Agent 在用户明确要求时调用 `reload_config` 工具。Gateway 会先校验候选配置,停止接收新工作并等待交互 Turn、Scheduler job 和后台子 Agent 到达安全边界后切换;失败时继续使用旧配置。`GET /api/config/reload/status` 可查询 generation、相位与最近错误。`gateway.host``gateway.port``workspace_dir``gateway.session_db_path` 的有效路径必须通过完整重启变更。`USER.md``AGENTS.md` 的修改用于后续构建的 Agent 上下文。
## config.json 结构
@ -198,7 +198,7 @@ MCP 服务器单条配置:
agent-browser 0.33.0 不允许持久 Profile 与 `allowed_domains` 同时使用。配置域名限制后普通临时浏览器仍可用创建或使用持久身份会被拒绝health 会提示该可选能力受限。Profile 包含登录凭据,应把目录视为敏感数据,不得提交到版本控制、跨用户共享或放在不受信任的网络文件系统中。
旧字段 `webdriver_url``chrome_path` 不再接受。推荐安装 `agent-browser@0.33.0` 后运行 `agent-browser install`Linux 可运行 `agent-browser install --with-deps`。使用前用 `picobot health` 检查 CLI 与 Chrome 环境。
旧字段 `webdriver_url``chrome_path` 不再接受。推荐安装 `agent-browser@0.33.0` 后运行 `agent-browser install`Linux 可运行 `agent-browser install --with-deps`。使用前用 `picobot health` 检查 CLI、浏览器安装和真实 headless 启动环境。
### 浏览器依赖故障处置

View File

@ -189,7 +189,7 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
## health — 依赖检查
无参数时返回可读报告;`json=true` 返回结构化报告。核心必需项、当前配置启用后必需的依赖、可选功能分别标记。该工具只读,与 CLI `picobot health [--json]``/health` 斜杠命令复用同一个 `HealthService`
无参数时返回可读报告;`json=true` 返回结构化报告。核心必需项、当前配置启用后必需的依赖、可选功能分别标记。`fd` 与 Debian/Ubuntu 的 `fdfind` 是同一个首选文件搜索程序;只有传统 `find` 会产生降级警告。浏览器启用时会检查 CLI、可执行路径并通过隔离的完整 offline doctor 分别验证浏览器安装、真实 headless 启动和环境。该工具只读,与 CLI `picobot health [--json]``/health` 斜杠命令以及 WebUI“配置 → 健康检查”复用同一个 `HealthService`
---

View File

@ -1,5 +1,7 @@
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::env;
use std::fs;
@ -68,6 +70,44 @@ pub struct Config {
pub agent_orchestration: AgentOrchestrationConfig,
#[serde(default)]
pub context_compaction: ContextCompactionConfig,
/// Recoverable source problems ignored while constructing this effective
/// runtime configuration. These are never serialized back into config.json.
#[serde(skip)]
pub diagnostics: Vec<ConfigDiagnostic>,
/// SHA-256 revision of the raw config.json bytes used for this load.
#[serde(skip)]
pub source_revision: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ConfigDiagnostic {
/// RFC 6901 pointer into the raw config.json document.
pub path: String,
pub kind: ConfigDiagnosticKind,
pub reason: String,
pub action: ConfigRecoveryAction,
/// Exact raw-document path removed by the cleanup API.
pub cleanup_path: String,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConfigDiagnosticKind {
UnknownField,
InvalidValue,
InvalidEntry,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConfigRecoveryAction {
Ignored,
}
#[derive(Debug, Clone)]
pub(crate) struct ConfigLoadContext {
pub process_env: HashMap<String, String>,
pub startup_cwd: PathBuf,
}
fn default_workspace_dir() -> String {
@ -204,7 +244,7 @@ pub(crate) fn resolve_token_limit(
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
#[serde(default)]
pub struct ContextCompactionConfig {
/// Controls proactive Turn-boundary compaction only. Manual compaction and
/// overflow recovery remain available when this is false.
@ -226,7 +266,7 @@ impl Default for ContextCompactionConfig {
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
#[serde(default)]
pub struct AgentOrchestrationConfig {
pub definitions_dir: String,
pub max_tree_depth: u16,
@ -645,7 +685,6 @@ fn default_mcp_tool_timeout_secs() -> u64 {
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BrowserConfig {
#[serde(default = "default_true")]
pub enabled: bool,
@ -676,7 +715,6 @@ pub struct BrowserConfig {
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BrowserPersistenceConfig {
#[serde(default = "default_browser_profile_dir")]
pub profile_dir: String,
@ -808,7 +846,470 @@ pub fn resolve_default_config_path() -> PathBuf {
}
}
const MAX_CONFIG_RECOVERIES: usize = 256;
#[derive(Debug, Clone)]
enum ConfigPathSegment {
Key(String),
Index(usize),
}
#[derive(Debug, Clone)]
struct OriginNode {
pointer: String,
children: OriginChildren,
}
#[derive(Debug, Clone)]
enum OriginChildren {
Scalar,
Object(HashMap<String, OriginNode>),
Array(Vec<OriginNode>),
}
impl OriginNode {
fn from_value(value: &Value, pointer: String) -> Self {
let children = match value {
Value::Object(object) => OriginChildren::Object(
object
.iter()
.map(|(key, value)| {
let child_pointer = join_json_pointer(&pointer, key);
(key.clone(), Self::from_value(value, child_pointer))
})
.collect(),
),
Value::Array(array) => OriginChildren::Array(
array
.iter()
.enumerate()
.map(|(index, value)| {
Self::from_value(value, join_json_pointer(&pointer, &index.to_string()))
})
.collect(),
),
_ => OriginChildren::Scalar,
};
Self { pointer, children }
}
fn at<'a>(&'a self, path: &[ConfigPathSegment]) -> Option<&'a Self> {
let mut current = self;
for segment in path {
current = match (&current.children, segment) {
(OriginChildren::Object(children), ConfigPathSegment::Key(key)) => {
children.get(key)?
}
(OriginChildren::Array(children), ConfigPathSegment::Index(index)) => {
children.get(*index)?
}
_ => return None,
};
}
Some(current)
}
}
fn escape_json_pointer_segment(segment: &str) -> String {
segment.replace('~', "~0").replace('/', "~1")
}
fn join_json_pointer(parent: &str, segment: &str) -> String {
format!("{parent}/{}", escape_json_pointer_segment(segment))
}
fn error_path_segments(path: &serde_path_to_error::Path) -> Option<Vec<ConfigPathSegment>> {
path.iter()
.map(|segment| match segment {
serde_path_to_error::Segment::Map { key } => Some(ConfigPathSegment::Key(key.clone())),
serde_path_to_error::Segment::Seq { index } => Some(ConfigPathSegment::Index(*index)),
serde_path_to_error::Segment::Enum { variant } => {
Some(ConfigPathSegment::Key(variant.clone()))
}
serde_path_to_error::Segment::Unknown => None,
})
.collect()
}
fn remove_config_path(
value: &mut Value,
origin: &mut OriginNode,
path: &[ConfigPathSegment],
) -> bool {
let Some((head, tail)) = path.split_first() else {
return false;
};
if tail.is_empty() {
return match (value, &mut origin.children, head) {
(
Value::Object(object),
OriginChildren::Object(origins),
ConfigPathSegment::Key(key),
) => {
let removed = object.remove(key).is_some();
origins.remove(key);
removed
}
(
Value::Array(array),
OriginChildren::Array(origins),
ConfigPathSegment::Index(index),
) if *index < array.len() && *index < origins.len() => {
array.remove(*index);
origins.remove(*index);
true
}
_ => false,
};
}
match (value, &mut origin.children, head) {
(Value::Object(object), OriginChildren::Object(origins), ConfigPathSegment::Key(key)) => {
let Some(child) = object.get_mut(key) else {
return false;
};
let Some(child_origin) = origins.get_mut(key) else {
return false;
};
remove_config_path(child, child_origin, tail)
}
(Value::Array(array), OriginChildren::Array(origins), ConfigPathSegment::Index(index))
if *index < array.len() && *index < origins.len() =>
{
remove_config_path(&mut array[*index], &mut origins[*index], tail)
}
_ => false,
}
}
fn diagnostic_reason(error: &serde_json::Error) -> (&'static str, ConfigDiagnosticKind) {
let message = error.to_string();
if message.starts_with("missing field") {
(
"配置条目缺少必填字段,整个条目已忽略",
ConfigDiagnosticKind::InvalidEntry,
)
} else if message.starts_with("unknown variant") {
(
"配置值不是受支持的枚举选项,已忽略",
ConfigDiagnosticKind::InvalidValue,
)
} else if message.starts_with("invalid type") {
(
"配置值类型不匹配,已忽略",
ConfigDiagnosticKind::InvalidValue,
)
} else {
("配置值无法解析,已忽略", ConfigDiagnosticKind::InvalidValue)
}
}
fn collect_unknown_fields(
source: &Value,
origin: &OriginNode,
effective: &Value,
diagnostics: &mut Vec<ConfigDiagnostic>,
) {
match (source, &origin.children, effective) {
(Value::Object(source), OriginChildren::Object(origins), Value::Object(effective)) => {
for (key, source_value) in source {
let Some(child_origin) = origins.get(key) else {
continue;
};
if let Some(effective_value) = effective.get(key) {
collect_unknown_fields(
source_value,
child_origin,
effective_value,
diagnostics,
);
} else {
diagnostics.push(ConfigDiagnostic {
path: child_origin.pointer.clone(),
kind: ConfigDiagnosticKind::UnknownField,
reason: "当前版本不识别此配置项,已忽略".to_string(),
action: ConfigRecoveryAction::Ignored,
cleanup_path: child_origin.pointer.clone(),
});
}
}
}
(Value::Array(source), OriginChildren::Array(origins), Value::Array(effective)) => {
for ((source_value, child_origin), effective_value) in
source.iter().zip(origins).zip(effective)
{
collect_unknown_fields(source_value, child_origin, effective_value, diagnostics);
}
}
_ => {}
}
}
pub(crate) fn source_revision(raw: &str) -> String {
let digest: String = Sha256::digest(raw.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
format!("sha256:{digest}")
}
fn decode_json_pointer(pointer: &str) -> Option<Vec<String>> {
if pointer.is_empty() {
return Some(Vec::new());
}
pointer.strip_prefix('/').map(|rest| {
rest.split('/')
.map(|segment| segment.replace("~1", "/").replace("~0", "~"))
.collect()
})
}
fn is_pointer_ancestor(ancestor: &str, descendant: &str) -> bool {
descendant
.strip_prefix(ancestor)
.is_some_and(|suffix| suffix.starts_with('/'))
}
fn remove_raw_pointer(value: &mut Value, segments: &[String]) -> bool {
let Some((head, tail)) = segments.split_first() else {
return false;
};
if tail.is_empty() {
return match value {
Value::Object(object) => object.remove(head).is_some(),
Value::Array(array) => head
.parse::<usize>()
.ok()
.filter(|index| *index < array.len())
.map(|index| {
array.remove(index);
})
.is_some(),
_ => false,
};
}
match value {
Value::Object(object) => object
.get_mut(head)
.is_some_and(|child| remove_raw_pointer(child, tail)),
Value::Array(array) => head
.parse::<usize>()
.ok()
.and_then(|index| array.get_mut(index))
.is_some_and(|child| remove_raw_pointer(child, tail)),
_ => false,
}
}
/// Remove only paths previously diagnosed against this exact raw revision.
/// Ancestors subsume descendants, and sibling array indexes are deleted from
/// highest to lowest so paths remain stable during the operation.
pub(crate) fn cleanup_invalid_config(value: &mut Value, diagnostics: &[ConfigDiagnostic]) -> usize {
let mut paths = diagnostics
.iter()
.map(|diagnostic| diagnostic.cleanup_path.clone())
.collect::<Vec<_>>();
paths.sort_by_key(|path| decode_json_pointer(path).map_or(usize::MAX, |parts| parts.len()));
let mut coalesced: Vec<String> = Vec::new();
for path in paths {
if !coalesced
.iter()
.any(|ancestor| ancestor == &path || is_pointer_ancestor(ancestor, &path))
{
coalesced.push(path);
}
}
coalesced.sort_by(|left, right| {
let left_parts = decode_json_pointer(left).unwrap_or_default();
let right_parts = decode_json_pointer(right).unwrap_or_default();
let left_parent = &left_parts[..left_parts.len().saturating_sub(1)];
let right_parent = &right_parts[..right_parts.len().saturating_sub(1)];
if left_parent == right_parent {
let left_index = left_parts
.last()
.and_then(|part| part.parse::<usize>().ok());
let right_index = right_parts
.last()
.and_then(|part| part.parse::<usize>().ok());
if let (Some(left_index), Some(right_index)) = (left_index, right_index) {
return right_index.cmp(&left_index);
}
}
right_parts
.len()
.cmp(&left_parts.len())
.then_with(|| right.cmp(left))
});
coalesced
.iter()
.filter_map(|path| decode_json_pointer(path))
.filter(|segments| remove_raw_pointer(value, segments))
.count()
}
fn parse_config_tolerant(content: &str) -> Result<Config, Box<dyn std::error::Error>> {
let mut candidate: Value = serde_json::from_str(content)?;
let mut origins = OriginNode::from_value(&candidate, String::new());
let mut diagnostics = Vec::new();
let mut config = None;
for _ in 0..=MAX_CONFIG_RECOVERIES {
let serialized = serde_json::to_string(&candidate)?;
let mut deserializer = serde_json::Deserializer::from_str(&serialized);
match serde_path_to_error::deserialize::<_, Config>(&mut deserializer) {
Ok(parsed) => {
config = Some(parsed);
break;
}
Err(error) => {
let Some(path) = error_path_segments(error.path()) else {
return Err(error.into());
};
if path.is_empty() {
return Err(error.into());
}
let Some(original) = origins.at(&path).map(|node| node.pointer.clone()) else {
return Err(error.into());
};
let (reason, kind) = diagnostic_reason(error.inner());
if !remove_config_path(&mut candidate, &mut origins, &path) {
return Err(error.into());
}
diagnostics.push(ConfigDiagnostic {
path: original.clone(),
kind,
reason: reason.to_string(),
action: ConfigRecoveryAction::Ignored,
cleanup_path: original,
});
}
}
}
let Some(mut config) = config else {
return Err(format!(
"configuration contains more than {MAX_CONFIG_RECOVERIES} recoverable errors"
)
.into());
};
let effective = serde_json::to_value(&config)?;
collect_unknown_fields(&candidate, &origins, &effective, &mut diagnostics);
let unsupported_channels = config
.channels
.keys()
.filter(|name| name.as_str() != "feishu")
.cloned()
.collect::<Vec<_>>();
for name in unsupported_channels {
config.channels.remove(&name);
let pointer = join_json_pointer("/channels", &name);
diagnostics.push(ConfigDiagnostic {
path: pointer.clone(),
kind: ConfigDiagnosticKind::InvalidEntry,
reason: "当前版本不支持此渠道配置,整个条目已忽略".to_string(),
action: ConfigRecoveryAction::Ignored,
cleanup_path: pointer,
});
}
let invalid_agents = config
.agents
.iter()
.filter(|(name, agent)| {
name.as_str() != "default"
&& (!config.providers.contains_key(&agent.provider)
|| !config.models.contains_key(&agent.model))
})
.map(|(name, _)| name.clone())
.collect::<Vec<_>>();
for name in invalid_agents {
config.agents.remove(&name);
let pointer = join_json_pointer("/agents", &name);
diagnostics.push(ConfigDiagnostic {
path: pointer.clone(),
kind: ConfigDiagnosticKind::InvalidEntry,
reason: "Agent 引用了不存在的 Provider 或 Model整个条目已忽略".to_string(),
action: ConfigRecoveryAction::Ignored,
cleanup_path: pointer,
});
}
for channel in config.channels.values_mut() {
if !channel.agent.is_empty() && !config.agents.contains_key(&channel.agent) {
channel.agent.clear();
let pointer = "/channels/feishu/agent".to_string();
diagnostics.push(ConfigDiagnostic {
path: pointer.clone(),
kind: ConfigDiagnosticKind::InvalidValue,
reason: "渠道引用了不存在的 Agent已回退到 default".to_string(),
action: ConfigRecoveryAction::Ignored,
cleanup_path: pointer,
});
}
}
if config
.memory
.consolidation_provider
.as_ref()
.is_some_and(|provider| !config.providers.contains_key(provider))
{
config.memory.consolidation_provider = None;
let pointer = "/memory/consolidation_provider".to_string();
diagnostics.push(ConfigDiagnostic {
path: pointer.clone(),
kind: ConfigDiagnosticKind::InvalidValue,
reason: "记忆配置引用了不存在的 Provider已回退到 default".to_string(),
action: ConfigRecoveryAction::Ignored,
cleanup_path: pointer,
});
}
if config
.memory
.consolidation_model
.as_ref()
.is_some_and(|model| !config.models.contains_key(model))
{
config.memory.consolidation_model = None;
let pointer = "/memory/consolidation_model".to_string();
diagnostics.push(ConfigDiagnostic {
path: pointer.clone(),
kind: ConfigDiagnosticKind::InvalidValue,
reason: "记忆配置引用了不存在的 Model已回退到 default".to_string(),
action: ConfigRecoveryAction::Ignored,
cleanup_path: pointer,
});
}
diagnostics.sort_by(|left, right| {
let left_depth = decode_json_pointer(&left.path).map_or(usize::MAX, |parts| parts.len());
let right_depth = decode_json_pointer(&right.path).map_or(usize::MAX, |parts| parts.len());
left_depth
.cmp(&right_depth)
.then_with(|| left.path.cmp(&right.path))
});
let mut coalesced: Vec<ConfigDiagnostic> = Vec::new();
for diagnostic in diagnostics {
if !coalesced.iter().any(|existing| {
existing.path == diagnostic.path
|| is_pointer_ancestor(&existing.path, &diagnostic.path)
}) {
coalesced.push(diagnostic);
}
}
coalesced.sort_by(|left, right| left.path.cmp(&right.path));
config.diagnostics = coalesced;
Ok(config)
}
impl Config {
pub(crate) fn load_context() -> ConfigLoadContext {
ConfigLoadContext {
process_env: collect_process_env(),
startup_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
}
}
pub fn load(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
Self::load_from(Path::new(path))
}
@ -819,8 +1320,20 @@ impl Config {
}
pub(crate) fn load_from(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let process_env = collect_process_env();
Self::load_from_with_process_env(path, &process_env, true, None)
let context = Self::load_context();
Self::load_for_startup(path, &context)
}
pub(crate) fn load_for_startup(
path: &Path,
context: &ConfigLoadContext,
) -> Result<Self, Box<dyn std::error::Error>> {
Self::load_from_with_process_env(
path,
&context.process_env,
true,
Some(&context.startup_cwd),
)
}
/// Reload configuration without mutating the process environment. The
@ -835,8 +1348,16 @@ impl Config {
Self::load_from_with_process_env(path, startup_process_env, false, Some(startup_cwd))
}
pub(crate) fn startup_process_env() -> HashMap<String, String> {
collect_process_env()
/// Strict management-API validation. Runtime loading is deliberately
/// tolerant of recoverable historical fields, while WebUI writes must not
/// introduce new ignored configuration.
pub(crate) fn from_value_strict(value: Value) -> Result<Self, String> {
let content = serde_json::to_string(&value).map_err(|error| error.to_string())?;
let config = parse_config_tolerant(&content).map_err(|error| error.to_string())?;
if let Some(diagnostic) = config.diagnostics.first() {
return Err(format!("{}: {}", diagnostic.path, diagnostic.reason));
}
Ok(config)
}
fn load_from_with_process_env(
@ -871,7 +1392,7 @@ impl Config {
// layer first would be circular because its location comes from config.json.
let initial_env = merge_env_layers(&config_env, &HashMap::new(), process_env);
let initial_content = resolve_env_placeholders(&content, &initial_env);
let initial_config: Config = serde_json::from_str(&initial_content)?;
let initial_config = parse_config_tolerant(&initial_content)?;
let mut workspace_path = expand_path(&initial_config.workspace_dir);
if workspace_path.is_relative()
&& let Some(base) = workspace_base
@ -883,7 +1404,7 @@ impl Config {
let effective_env = merge_env_layers(&config_env, &workspace_env, process_env);
let resolved_content = resolve_env_placeholders(&content, &effective_env);
let config: Config = serde_json::from_str(&resolved_content)?;
let mut config = parse_config_tolerant(&resolved_content)?;
if config.workspace_dir != initial_config.workspace_dir {
return Err(format!(
"workspace .env cannot change workspace_dir (selected {}, resolved {})",
@ -895,6 +1416,21 @@ impl Config {
if apply_to_process {
apply_env_layers(&config_env, &workspace_env, process_env);
}
config.source_revision = source_revision(&content);
if !config.diagnostics.is_empty() {
let paths = config
.diagnostics
.iter()
.take(8)
.map(|diagnostic| diagnostic.path.as_str())
.collect::<Vec<_>>()
.join(", ");
tracing::warn!(
count = config.diagnostics.len(),
paths,
"Recoverable configuration entries were ignored"
);
}
tracing::info!(
path = %config_path.display(),
config_env = %config_env_path.display(),
@ -1271,12 +1807,12 @@ mod tests {
}
#[test]
fn context_compaction_config_is_strict_and_defaults_are_stable() {
fn context_compaction_defaults_are_stable() {
let config: ContextCompactionConfig = serde_json::from_str("{}").unwrap();
assert!(config.enabled);
assert_eq!(config.reserve_tokens, 16_384);
assert_eq!(config.keep_recent_tokens, 20_000);
assert!(serde_json::from_str::<ContextCompactionConfig>(r#"{"unknown":1}"#).is_err());
assert!(serde_json::from_str::<ContextCompactionConfig>(r#"{"unknown":1}"#).is_ok());
}
#[test]
@ -1295,7 +1831,7 @@ mod tests {
}
#[test]
fn browser_persistence_config_is_strict_and_explicit() {
fn browser_persistence_config_is_explicit() {
let browser: BrowserConfig = serde_json::from_str(
r#"{
"persistence": {
@ -1312,10 +1848,129 @@ mod tests {
serde_json::from_str::<BrowserConfig>(
r#"{"persistence":{"profile_dir":"/tmp/profiles","unknown":1}}"#
)
.is_err()
.is_ok()
);
}
#[test]
fn tolerant_load_reports_unknown_and_invalid_optional_fields() {
let file = write_test_config();
let mut value: Value =
serde_json::from_str(&fs::read_to_string(file.path()).unwrap()).unwrap();
value["browser"] = serde_json::json!({
"idle_timeout_secs": "old-value",
"legacy_auto_close": true
});
fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert_eq!(config.browser.idle_timeout_secs, 60 * 60);
assert_eq!(
config
.diagnostics
.iter()
.map(|diagnostic| diagnostic.path.as_str())
.collect::<Vec<_>>(),
vec!["/browser/idle_timeout_secs", "/browser/legacy_auto_close"]
);
assert!(Config::from_value_strict(value).is_err());
}
#[test]
fn tolerant_load_drops_broken_unused_named_entry_but_not_default_chain() {
let file = write_test_config();
let mut value: Value =
serde_json::from_str(&fs::read_to_string(file.path()).unwrap()).unwrap();
value["models"]["broken"] = serde_json::json!({ "model_id": 42 });
fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert!(!config.models.contains_key("broken"));
assert!(config.get_provider_config("default").is_ok());
assert!(
config
.diagnostics
.iter()
.any(|diagnostic| diagnostic.path == "/models/broken")
);
value["models"]["qwen-plus"]["model_id"] = serde_json::json!(42);
fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert!(config.get_provider_config("default").is_err());
}
#[test]
fn cleanup_uses_original_array_indexes_and_preserves_valid_entries() {
let file = write_test_config();
let mut value: Value =
serde_json::from_str(&fs::read_to_string(file.path()).unwrap()).unwrap();
value["mcp"] = serde_json::json!({
"servers": [
{ "name": 42 },
{ "name": "kept-one", "legacy": true },
{ "name": "kept-two", "transport": "retired-transport" },
{ "name": false }
]
});
fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
assert_eq!(
config
.mcp
.servers
.iter()
.map(|server| server.name.as_str())
.collect::<Vec<_>>(),
vec!["kept-one", "kept-two"]
);
assert!(
config
.diagnostics
.iter()
.any(|diagnostic| diagnostic.path == "/mcp/servers/1/legacy")
);
assert!(
config
.diagnostics
.iter()
.any(|diagnostic| diagnostic.path == "/mcp/servers/2/transport")
);
assert_eq!(cleanup_invalid_config(&mut value, &config.diagnostics), 4);
let servers = value["mcp"]["servers"].as_array().unwrap();
assert_eq!(servers.len(), 2);
assert_eq!(servers[0]["name"], "kept-one");
assert!(servers[0].get("legacy").is_none());
assert_eq!(servers[1]["name"], "kept-two");
assert!(servers[1].get("transport").is_none());
}
#[test]
fn diagnostic_and_cleanup_paths_escape_dynamic_map_keys() {
let file = write_test_config();
let mut value: Value =
serde_json::from_str(&fs::read_to_string(file.path()).unwrap()).unwrap();
value["agents"]["retired/name~old"] = serde_json::json!({
"provider": "missing",
"model": "qwen-plus"
});
fs::write(file.path(), serde_json::to_vec_pretty(&value).unwrap()).unwrap();
let config = Config::load(file.path().to_str().unwrap()).unwrap();
let pointer = "/agents/retired~1name~0old";
assert!(
config
.diagnostics
.iter()
.any(|diagnostic| diagnostic.path == pointer)
);
assert_eq!(cleanup_invalid_config(&mut value, &config.diagnostics), 1);
assert!(value["agents"].get("retired/name~old").is_none());
}
#[test]
fn mcp_tool_settings_default_to_conservative_and_derive_concurrency() {
let server: McpServerConfig = serde_json::from_str(

View File

@ -1,5 +1,5 @@
use super::GatewayState;
use crate::config::Config;
use crate::config::{Config, ConfigDiagnostic, cleanup_invalid_config, source_revision};
use crate::memory::MemoryCategory;
use axum::Json;
use axum::body::Body;
@ -44,6 +44,12 @@ pub async fn health() -> Json<HealthResponse> {
})
}
pub async fn health_report(
State(state): State<Arc<GatewayState>>,
) -> Json<crate::health::HealthReport> {
Json(state.health.check().await)
}
fn static_response(content_type: &'static str, content: &'static str) -> Response {
Response::builder()
.header(header::CONTENT_TYPE, content_type)
@ -407,9 +413,16 @@ impl IntoResponse for ApiError {
pub struct ConfigResponse {
config: Value,
path: String,
revision: String,
diagnostics: Vec<ConfigDiagnostic>,
restart_required: bool,
}
#[derive(Deserialize)]
pub struct CleanupInvalidConfigRequest {
revision: String,
}
#[derive(Serialize)]
pub struct ReloadResponse {
generation: u64,
@ -443,14 +456,32 @@ pub async fn reload_status(
pub async fn get_config(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<ConfigResponse>, ApiError> {
let _write_guard = state.config_write_lock.lock().await;
let raw = tokio::fs::read_to_string(&state.config_path)
.await
.map_err(ApiError::internal)?;
let revision = source_revision(&raw);
let path = state.config_path.clone();
let context = state.config_load_context.clone();
let inspected = tokio::task::spawn_blocking(move || {
Config::load_for_reload(&path, &context.process_env, &context.startup_cwd)
.map_err(|error| error.to_string())
})
.await
.map_err(ApiError::internal)?
.map_err(ApiError::bad_request)?;
if inspected.source_revision != revision {
return Err(ApiError::conflict(
"config.json changed while it was being inspected; retry",
));
}
let mut value: Value = serde_json::from_str(&raw).map_err(ApiError::internal)?;
redact_secrets(&mut value);
Ok(Json(ConfigResponse {
config: value,
path: state.config_path.display().to_string(),
revision,
diagnostics: inspected.diagnostics,
restart_required: false,
}))
}
@ -459,6 +490,7 @@ pub async fn put_config(
State(state): State<Arc<GatewayState>>,
Json(mut incoming): Json<Value>,
) -> Result<Json<ConfigResponse>, ApiError> {
let _write_guard = state.config_write_lock.lock().await;
if incoming.get("config").is_some() {
incoming = incoming
.get_mut("config")
@ -477,7 +509,7 @@ pub async fn put_config(
.map_err(ApiError::internal)?;
let current: Value = serde_json::from_str(&current_raw).map_err(ApiError::internal)?;
restore_redacted_secrets(&mut incoming, &current);
let parsed: Config = serde_json::from_value(incoming.clone())
let parsed = Config::from_value_strict(incoming.clone())
.map_err(|error| ApiError::bad_request(format!("invalid config: {error}")))?;
parsed
.get_provider_config("default")
@ -492,10 +524,78 @@ pub async fn put_config(
Ok(Json(ConfigResponse {
config: response,
path: state.config_path.display().to_string(),
revision: source_revision(&pretty),
diagnostics: Vec::new(),
restart_required: true,
}))
}
pub async fn cleanup_invalid_config_entries(
State(state): State<Arc<GatewayState>>,
Json(request): Json<CleanupInvalidConfigRequest>,
) -> Result<Json<ConfigResponse>, ApiError> {
let _write_guard = state.config_write_lock.lock().await;
let raw = tokio::fs::read_to_string(&state.config_path)
.await
.map_err(ApiError::internal)?;
let current_revision = source_revision(&raw);
if request.revision != current_revision {
return Err(ApiError::conflict(
"config.json changed after it was displayed; reload the page before cleaning",
));
}
let path = state.config_path.clone();
let context = state.config_load_context.clone();
let inspected = tokio::task::spawn_blocking(move || {
Config::load_for_reload(&path, &context.process_env, &context.startup_cwd)
.map_err(|error| error.to_string())
})
.await
.map_err(ApiError::internal)?
.map_err(ApiError::bad_request)?;
if inspected.source_revision != current_revision {
return Err(ApiError::conflict(
"config.json changed while it was being inspected; retry",
));
}
let mut value: Value = serde_json::from_str(&raw).map_err(ApiError::internal)?;
let removed = cleanup_invalid_config(&mut value, &inspected.diagnostics);
let pretty = serde_json::to_string_pretty(&value).map_err(ApiError::internal)? + "\n";
if removed > 0 {
atomic_write(&state.config_path, pretty.as_bytes()).await?;
tracing::info!(
path = %state.config_path.display(),
removed,
"Invalid configuration entries removed from WebUI"
);
}
let post_cleanup = if removed > 0 {
let path = state.config_path.clone();
let context = state.config_load_context.clone();
tokio::task::spawn_blocking(move || {
Config::load_for_reload(&path, &context.process_env, &context.startup_cwd)
.map_err(|error| error.to_string())
})
.await
.map_err(ApiError::internal)?
.map_err(ApiError::bad_request)?
} else {
inspected
};
let mut response = value;
redact_secrets(&mut response);
Ok(Json(ConfigResponse {
config: response,
path: state.config_path.display().to_string(),
revision: post_cleanup.source_revision,
diagnostics: post_cleanup.diagnostics,
restart_required: removed > 0,
}))
}
fn is_secret_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
key.contains("api_key")

View File

@ -13,7 +13,7 @@ use tokio::net::TcpListener;
use crate::bus::{MessageBus, OutboundDispatcher};
use crate::channels::{ChannelManager, CliChatChannel};
use crate::config::{Config, ensure_workspace_dir, expand_path};
use crate::config::{Config, ConfigLoadContext, ensure_workspace_dir, expand_path};
use crate::delivery::{ConversationWriteLocks, DeliveryCoordinator, TurnDeliveryService};
use crate::logging;
use crate::mcp;
@ -37,7 +37,10 @@ pub fn process_uptime_secs() -> u64 {
pub struct GatewayState {
pub config: Config,
pub config_path: std::path::PathBuf,
pub(crate) config_load_context: Arc<ConfigLoadContext>,
pub(crate) config_write_lock: Arc<tokio::sync::Mutex<()>>,
pub workspace_dir: std::path::PathBuf,
pub(crate) health: Arc<crate::health::HealthService>,
pub session_manager: Arc<SessionManager>,
pub channel_manager: ChannelManager,
pub storage: Arc<crate::storage::Storage>,
@ -62,10 +65,13 @@ impl GatewayState {
/// when the state is owned by [`run`], which owns the generation loop.
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
let config_path = crate::config::resolve_default_config_path();
let config = Config::load_from(&config_path)?;
let config_load_context = Arc::new(Config::load_context());
let config = Config::load_for_startup(&config_path, &config_load_context)?;
Self::from_config(
config,
config_path,
config_load_context,
Arc::new(tokio::sync::Mutex::new(())),
reload::ReloadHandle::unavailable(),
true,
1,
@ -76,6 +82,8 @@ impl GatewayState {
async fn from_config(
config: Config,
config_path: std::path::PathBuf,
config_load_context: Arc<ConfigLoadContext>,
config_write_lock: Arc<tokio::sync::Mutex<()>>,
reload: reload::ReloadHandle,
initialize_process: bool,
runtime_generation: u64,
@ -232,7 +240,7 @@ impl GatewayState {
)
.with_admission(admission.clone()),
browser_config,
health,
health.clone(),
)?;
let session_manager = Arc::new(session_manager);
session_manager.bind_inbox_wake();
@ -292,7 +300,10 @@ impl GatewayState {
Ok(Self {
config,
config_path,
config_load_context,
config_write_lock,
workspace_dir: workspace_path,
health,
session_manager: session_manager.clone(),
channel_manager,
storage,
@ -459,19 +470,38 @@ pub async fn run(
) -> Result<(), Box<dyn std::error::Error>> {
STARTED.get_or_init(std::time::Instant::now);
let config_path = crate::config::resolve_default_config_path();
let startup_process_env = Config::startup_process_env();
let startup_cwd = std::env::current_dir()?;
let config = Config::load_from(&config_path)?;
let config_load_context = Arc::new(Config::load_context());
let config = Config::load_for_startup(&config_path, &config_load_context)?;
// Initialize logging
logging::init_logging();
tracing::info!(config_path = %config_path.display(), "Starting PicoBot Gateway");
if !config.diagnostics.is_empty() {
let paths = config
.diagnostics
.iter()
.take(8)
.map(|diagnostic| diagnostic.path.as_str())
.collect::<Vec<_>>()
.join(", ");
tracing::warn!(
count = config.diagnostics.len(),
paths,
"Gateway started with recoverable configuration entries ignored"
);
}
let mut reload_controller = reload::ReloadController::new(startup_process_env, startup_cwd);
let mut reload_controller = reload::ReloadController::new(
config_load_context.process_env.clone(),
config_load_context.startup_cwd.clone(),
);
let config_write_lock = Arc::new(tokio::sync::Mutex::new(()));
let mut state = Arc::new(
GatewayState::from_config(
config,
config_path.clone(),
config_load_context.clone(),
config_write_lock.clone(),
reload_controller.handle.clone(),
true,
1,
@ -536,13 +566,17 @@ pub async fn run(
};
let requested_generation = request.generation;
reload_controller.set_phase(requested_generation, reload::ReloadPhase::Preparing);
let candidate = match reload::load_candidate(
&config_path,
&reload_controller.startup_process_env,
&reload_controller.startup_cwd,
&state.config,
&state.workspace_dir,
) {
let candidate_result = {
let _write_guard = state.config_write_lock.lock().await;
reload::load_candidate(
&config_path,
&reload_controller.startup_process_env,
&reload_controller.startup_cwd,
&state.config,
&state.workspace_dir,
)
};
let candidate = match candidate_result {
Ok(candidate) => candidate,
Err(error) => {
reload_controller.set_failed(requested_generation, error.to_string());
@ -553,6 +587,8 @@ pub async fn run(
let preparation = GatewayState::from_config(
candidate,
config_path.clone(),
config_load_context.clone(),
config_write_lock.clone(),
reload_controller.handle.clone(),
false,
requested_generation,
@ -675,11 +711,15 @@ pub async fn run(
fn build_router(state: Arc<GatewayState>) -> Router {
let protected = Router::new()
.route("/api/health", routing::get(http::health))
.route("/api/health", routing::get(http::health_report))
.route(
"/api/config",
routing::get(http::get_config).put(http::put_config),
)
.route(
"/api/config/cleanup-invalid",
routing::post(http::cleanup_invalid_config_entries),
)
.route("/api/config/reload", routing::post(http::reload_config))
.route(
"/api/config/reload/status",

View File

@ -1,9 +1,9 @@
use std::collections::HashSet;
use std::path::Path;
use std::process::Stdio;
use std::process::{Output, Stdio};
use std::time::Duration;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use crate::config::{Config, McpTransport, expand_path};
@ -126,14 +126,15 @@ impl HealthService {
pub async fn check(&self) -> HealthReport {
let mut checks = vec![
check_configuration_recovery(&self.config),
check_workspace(&self.config),
check_required_binary(
"bash",
"core",
"Install Bash and make it available on PATH.",
),
check_search_backend("content search", &["rg", "grep"], "rg"),
check_search_backend("file search", &["fd", "fdfind", "find"], "fd"),
check_search_backend("content search", &["rg", "grep"], &["rg"]),
check_search_backend("file search", &["fd", "fdfind", "find"], &["fd", "fdfind"]),
check_optional_binary("systemd service management", "systemctl", "service"),
];
checks.extend(self.check_mcp_commands());
@ -241,7 +242,7 @@ impl HealthService {
let version = command_output(
&browser.command,
&["--version"],
None,
&[],
Duration::from_secs(5),
)
.await;
@ -287,7 +288,7 @@ impl HealthService {
} else {
expand_path(&self.config.workspace_dir).join(path)
};
let installed = path.is_file();
let installed = executable_file(&path);
checks.push(HealthCheck {
name: "configured browser executable".to_string(),
category: "configured".to_string(),
@ -298,13 +299,12 @@ impl HealthService {
HealthStatus::Fail
},
detail: if installed {
format!("found at {}", path.display())
format!("executable file found at {}", path.display())
} else {
format!("not found at {}", path.display())
format!("missing or not executable at {}", path.display())
},
remediation: (!installed).then(|| {
"Fix browser.browser_executable_path or run `agent-browser install`."
.to_string()
"Fix browser.browser_executable_path and its execute permissions, or run `agent-browser install`.".to_string()
}),
});
}
@ -321,41 +321,83 @@ impl HealthService {
}
})
.map(|path| path.to_string_lossy().into_owned());
let doctor_env = doctor_executable
let doctor_executable_env = doctor_executable
.as_deref()
.map(|path| ("AGENT_BROWSER_EXECUTABLE_PATH", path));
let doctor = command_output(
let doctor_socket_dir = match tempfile::Builder::new()
.prefix("picobot-health-agent-browser-")
.tempdir()
{
Ok(directory) => directory,
Err(error) => {
checks.push(agent_browser_doctor_failure(format!(
"failed to create isolated doctor directory: {error}"
)));
return checks;
}
};
let socket_dir = doctor_socket_dir.path().to_string_lossy().into_owned();
let mut doctor_env = vec![("AGENT_BROWSER_SOCKET_DIR", socket_dir.as_str())];
if let Some(env) = doctor_executable_env {
doctor_env.push(env);
}
let doctor = capture_command(
&browser.command,
&["doctor", "--offline", "--quick", "--json"],
doctor_env,
Duration::from_secs(15),
&[
"--namespace",
"picobot-health",
"doctor",
"--offline",
"--json",
],
&doctor_env,
Duration::from_secs(30),
)
.await;
checks.push(match doctor {
Ok(output) => HealthCheck {
name: "agent-browser runtime".to_string(),
category: "configured".to_string(),
required: true,
status: HealthStatus::Pass,
detail: summarize_output(&output),
remediation: None,
match doctor {
Ok(output) => match parse_agent_browser_doctor(&output) {
Ok(report) => checks.extend(agent_browser_doctor_checks(&report)),
Err(error) => checks.push(agent_browser_doctor_failure(error)),
},
Err(error) => HealthCheck {
name: "agent-browser runtime".to_string(),
category: "configured".to_string(),
required: true,
status: HealthStatus::Fail,
detail: error,
remediation: Some(
"Run `agent-browser doctor`, then `agent-browser install --with-deps` on Linux or `agent-browser install` on other platforms."
.to_string(),
),
},
});
Err(error) => checks.push(agent_browser_doctor_failure(error)),
}
checks
}
}
fn check_configuration_recovery(config: &Config) -> HealthCheck {
if config.diagnostics.is_empty() {
return HealthCheck {
name: "configuration compatibility".to_string(),
category: "core".to_string(),
required: false,
status: HealthStatus::Pass,
detail: "no recoverable configuration problems detected".to_string(),
remediation: None,
};
}
let paths = config
.diagnostics
.iter()
.take(5)
.map(|diagnostic| diagnostic.path.as_str())
.collect::<Vec<_>>()
.join(", ");
HealthCheck {
name: "configuration compatibility".to_string(),
category: "core".to_string(),
required: false,
status: HealthStatus::Warning,
detail: format!(
"ignored {} recoverable configuration item(s): {paths}",
config.diagnostics.len()
),
remediation: Some(
"Review and clean invalid entries in WebUI Settings → config.json.".to_string(),
),
}
}
fn check_workspace(config: &Config) -> HealthCheck {
let workspace = expand_path(&config.workspace_dir);
let exists = workspace.is_dir();
@ -398,25 +440,9 @@ fn check_required_binary(name: &str, category: &str, remediation: &str) -> Healt
}
}
fn check_search_backend(name: &str, candidates: &[&str], preferred: &str) -> HealthCheck {
fn check_search_backend(name: &str, candidates: &[&str], preferred: &[&str]) -> HealthCheck {
let found = candidates.iter().copied().find(|name| command_exists(name));
let (status, detail, remediation) = match found {
Some(found) if found == preferred => (
HealthStatus::Pass,
format!("using preferred backend {found}"),
None,
),
Some(found) => (
HealthStatus::Warning,
format!("using fallback backend {found}"),
Some(format!("Install {preferred} for faster searches.")),
),
None => (
HealthStatus::Fail,
"no supported backend found".to_string(),
Some(format!("Install one of: {}.", candidates.join(", "))),
),
};
let (status, detail, remediation) = search_backend_result(found, candidates, preferred);
HealthCheck {
name: name.to_string(),
category: "core".to_string(),
@ -427,6 +453,33 @@ fn check_search_backend(name: &str, candidates: &[&str], preferred: &str) -> Hea
}
}
fn search_backend_result(
found: Option<&str>,
candidates: &[&str],
preferred: &[&str],
) -> (HealthStatus, String, Option<String>) {
match found {
Some(found) if preferred.contains(&found) => (
HealthStatus::Pass,
format!("using preferred backend {found}"),
None,
),
Some(found) => (
HealthStatus::Warning,
format!("using fallback backend {found}"),
Some(format!(
"Install {} for faster searches.",
preferred.join(" or ")
)),
),
None => (
HealthStatus::Fail,
"no supported backend found".to_string(),
Some(format!("Install one of: {}.", candidates.join(", "))),
),
}
}
fn check_optional_binary(name: &str, binary: &str, category: &str) -> HealthCheck {
let installed = command_exists(binary);
HealthCheck {
@ -445,32 +498,57 @@ fn check_optional_binary(name: &str, binary: &str, category: &str) -> HealthChec
fn command_exists(command: &str) -> bool {
if command.contains(std::path::MAIN_SEPARATOR) {
Path::new(command).is_file()
executable_file(Path::new(command))
} else {
which::which(command).is_ok()
}
}
async fn command_output(
fn executable_file(path: &Path) -> bool {
let Ok(metadata) = path.metadata() else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
async fn capture_command(
command: &str,
args: &[&str],
env: Option<(&str, &str)>,
env: &[(&str, &str)],
timeout: Duration,
) -> Result<String, String> {
) -> Result<Output, String> {
let mut process = Command::new(command);
process
.args(args)
.envs(env.iter().copied())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some((key, value)) = env {
process.env(key, value);
}
let output = tokio::time::timeout(timeout, process.output())
tokio::time::timeout(timeout, process.output())
.await
.map_err(|_| format!("command timed out after {} seconds", timeout.as_secs()))?
.map_err(|error| format!("failed to start: {error}"))?;
.map_err(|error| format!("failed to start: {error}"))
}
async fn command_output(
command: &str,
args: &[&str],
env: &[(&str, &str)],
timeout: Duration,
) -> Result<String, String> {
let output = capture_command(command, args, env, timeout).await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
@ -493,6 +571,171 @@ async fn command_output(
Ok(truncate(combined, 4_000))
}
#[derive(Debug, Deserialize)]
struct AgentBrowserDoctorReport {
#[serde(default)]
checks: Vec<AgentBrowserDoctorCheck>,
#[serde(default)]
success: bool,
}
#[derive(Debug, Deserialize)]
struct AgentBrowserDoctorCheck {
id: String,
message: String,
status: String,
}
fn parse_agent_browser_doctor(output: &Output) -> Result<AgentBrowserDoctorReport, String> {
const MAX_DOCTOR_OUTPUT_BYTES: usize = 64 * 1024;
if output.stdout.len() > MAX_DOCTOR_OUTPUT_BYTES {
return Err(format!(
"agent-browser doctor returned more than {MAX_DOCTOR_OUTPUT_BYTES} bytes"
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
match serde_json::from_str::<AgentBrowserDoctorReport>(stdout.trim()) {
Ok(report) => Ok(report),
Err(error) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
};
Err(format!(
"agent-browser doctor returned invalid JSON ({error}): {}",
truncate(detail, 1_000)
))
}
}
}
fn agent_browser_doctor_checks(report: &AgentBrowserDoctorReport) -> Vec<HealthCheck> {
let mut installation = doctor_named_check(
report,
"browser installation",
|check| check.id == "chrome.installed",
"Run `agent-browser install`, or configure browser.browser_executable_path.",
);
let launch = doctor_named_check(
report,
"browser headless launch",
|check| check.id.starts_with("launch."),
"Run `agent-browser doctor --debug`, then `agent-browser install --with-deps` on Linux or `agent-browser install` on other platforms.",
);
if installation.status == HealthStatus::Fail && launch.status == HealthStatus::Pass {
installation.status = HealthStatus::Pass;
installation.detail =
"headless launch confirmed an available configured or system browser".to_string();
installation.remediation = None;
}
let other_issues = report
.checks
.iter()
.filter(|check| {
check.id != "chrome.installed"
&& !check.id.starts_with("launch.")
&& matches!(check.status.as_str(), "warn" | "warning" | "fail")
})
.collect::<Vec<_>>();
let unexplained_failure = !report.success
&& !report
.checks
.iter()
.any(|check| check.status.as_str() == "fail");
let runtime_status = if other_issues
.iter()
.any(|check| check.status.as_str() == "fail")
|| unexplained_failure
{
HealthStatus::Fail
} else if other_issues.is_empty() {
HealthStatus::Pass
} else {
HealthStatus::Warning
};
let runtime_detail = if other_issues.is_empty() {
if runtime_status == HealthStatus::Pass {
"isolated offline doctor completed without environment warnings".to_string()
} else {
"doctor reported failure without a structured failing check".to_string()
}
} else {
let details = other_issues
.iter()
.take(3)
.map(|check| format!("{}: {}", check.id, check.message))
.collect::<Vec<_>>()
.join("; ");
truncate(
&format!("{} environment issue(s): {details}", other_issues.len()),
1_000,
)
};
let runtime = HealthCheck {
name: "agent-browser environment".to_string(),
category: "configured".to_string(),
required: true,
status: runtime_status,
detail: runtime_detail,
remediation: (runtime_status != HealthStatus::Pass).then(|| {
"Run `agent-browser doctor --debug` to inspect the reported environment checks."
.to_string()
}),
};
vec![installation, launch, runtime]
}
fn doctor_named_check(
report: &AgentBrowserDoctorReport,
name: &str,
predicate: impl Fn(&AgentBrowserDoctorCheck) -> bool,
remediation: &str,
) -> HealthCheck {
let found = report.checks.iter().find(|check| predicate(check));
let (status, detail) = match found {
Some(check) => (doctor_status(&check.status), check.message.clone()),
None => (
HealthStatus::Fail,
format!("agent-browser doctor did not report {name}"),
),
};
HealthCheck {
name: name.to_string(),
category: "configured".to_string(),
required: true,
status,
detail,
remediation: (status != HealthStatus::Pass).then(|| remediation.to_string()),
}
}
fn doctor_status(status: &str) -> HealthStatus {
match status {
"pass" | "info" => HealthStatus::Pass,
"warn" | "warning" => HealthStatus::Warning,
"fail" => HealthStatus::Fail,
_ => HealthStatus::Warning,
}
}
fn agent_browser_doctor_failure(detail: String) -> HealthCheck {
HealthCheck {
name: "agent-browser runtime".to_string(),
category: "configured".to_string(),
required: true,
status: HealthStatus::Fail,
detail,
remediation: Some(
"Run `agent-browser doctor --debug`, then `agent-browser install --with-deps` on Linux or `agent-browser install` on other platforms."
.to_string(),
),
}
}
fn extract_version(output: &str) -> Option<String> {
output
.split_whitespace()
@ -508,22 +751,6 @@ fn extract_version(output: &str) -> Option<String> {
.map(str::to_string)
}
fn summarize_output(output: &str) -> String {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(output)
&& let Some(summary) = json
.get("summary")
.and_then(serde_json::Value::as_str)
.or_else(|| json.get("message").and_then(serde_json::Value::as_str))
{
return truncate(summary, 500);
}
let first_line = output
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("ok");
truncate(first_line, 500)
}
fn truncate(value: &str, max: usize) -> String {
if value.len() <= max {
value.to_string()
@ -550,6 +777,103 @@ mod tests {
assert!(!report.is_usable());
}
#[test]
fn report_serializes_the_management_api_contract() {
let report = HealthReport::from_checks(vec![HealthCheck {
name: "content search".into(),
category: "core".into(),
required: true,
status: HealthStatus::Warning,
detail: "using fallback backend".into(),
remediation: Some("Install rg.".into()),
}]);
let value = serde_json::to_value(report).unwrap();
assert_eq!(value["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(value["overall"], "degraded");
assert_eq!(value["checks"][0]["status"], "warning");
assert_eq!(value["checks"][0]["required"], true);
assert_eq!(value["checks"][0]["remediation"], "Install rg.");
}
#[test]
fn fdfind_is_a_preferred_file_search_backend() {
let (status, detail, remediation) =
search_backend_result(Some("fdfind"), &["fd", "fdfind", "find"], &["fd", "fdfind"]);
assert_eq!(status, HealthStatus::Pass);
assert_eq!(detail, "using preferred backend fdfind");
assert_eq!(remediation, None);
let (status, _, remediation) =
search_backend_result(Some("find"), &["fd", "fdfind", "find"], &["fd", "fdfind"]);
assert_eq!(status, HealthStatus::Warning);
assert_eq!(
remediation.as_deref(),
Some("Install fd or fdfind for faster searches.")
);
}
#[test]
fn doctor_report_exposes_install_launch_and_environment_checks() {
let report: AgentBrowserDoctorReport = serde_json::from_value(serde_json::json!({
"success": true,
"checks": [
{"id": "env.version", "message": "CLI version 0.33.0", "status": "pass"},
{"id": "chrome.installed", "message": "Chromium found", "status": "pass"},
{"id": "launch.elapsed", "message": "Headless launch in 1.2s", "status": "pass"}
]
}))
.unwrap();
let checks = agent_browser_doctor_checks(&report);
assert_eq!(checks.len(), 3);
assert_eq!(checks[0].name, "browser installation");
assert_eq!(checks[0].status, HealthStatus::Pass);
assert_eq!(checks[1].name, "browser headless launch");
assert_eq!(checks[1].status, HealthStatus::Pass);
assert_eq!(checks[2].name, "agent-browser environment");
assert_eq!(checks[2].status, HealthStatus::Pass);
}
#[test]
fn doctor_launch_failure_is_required_and_actionable() {
let report: AgentBrowserDoctorReport = serde_json::from_value(serde_json::json!({
"success": false,
"checks": [
{"id": "env.disk_free", "message": "low disk", "status": "warn"},
{"id": "chrome.installed", "message": "Chromium found", "status": "pass"},
{"id": "launch.daemon", "message": "shared library missing", "status": "fail"}
]
}))
.unwrap();
let checks = agent_browser_doctor_checks(&report);
assert_eq!(checks[1].status, HealthStatus::Fail);
assert!(checks[1].required);
assert!(checks[1].detail.contains("shared library missing"));
assert!(checks[1].remediation.is_some());
assert_eq!(checks[2].status, HealthStatus::Warning);
}
#[test]
fn successful_launch_accepts_a_configured_browser_without_bundled_chrome() {
let report: AgentBrowserDoctorReport = serde_json::from_value(serde_json::json!({
"success": false,
"checks": [
{"id": "chrome.installed", "message": "Chrome for Testing missing", "status": "fail"},
{"id": "launch.elapsed", "message": "Headless launch in 0.8s", "status": "pass"}
]
}))
.unwrap();
let checks = agent_browser_doctor_checks(&report);
assert_eq!(checks[0].status, HealthStatus::Pass);
assert!(checks[0].detail.contains("launch confirmed"));
assert_eq!(checks[1].status, HealthStatus::Pass);
assert_eq!(checks[2].status, HealthStatus::Pass);
}
#[test]
fn extracts_agent_browser_version() {
assert_eq!(

View File

@ -1,12 +1,12 @@
{
"name": "picobot-webui",
"version": "1.20.0",
"version": "1.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picobot-webui",
"version": "1.20.0",
"version": "1.21.0",
"dependencies": {
"bits-ui": "^2.0.0",
"dompurify": "^3.4.12",

View File

@ -1,7 +1,7 @@
{
"name": "picobot-webui",
"private": true,
"version": "1.20.0",
"version": "1.21.0",
"type": "module",
"engines": {
"node": ">=20"

View File

@ -49,7 +49,7 @@
async function health() {
try {
const result = await api("/api/health");
const result = await api("/health");
online = true;
version = `Gateway v${result.version}`;
} catch {

View File

@ -0,0 +1,146 @@
<script>
import { onMount } from "svelte";
import { api, formatTime } from "../api.js";
let report = $state(null);
let error = $state("");
let loading = $state(false);
let checkedAt = $state(null);
const counts = $derived.by(() => {
const result = { pass: 0, warning: 0, fail: 0 };
for (const check of report?.checks || []) {
if (check.status in result) result[check.status] += 1;
}
return result;
});
const overallLabel = $derived(
report?.overall === "healthy" ? "健康" : report?.overall === "degraded" ? "存在警告" : "检查失败"
);
const statusLabels = {
pass: "通过",
warning: "警告",
fail: "失败"
};
const categoryLabels = {
core: "核心",
configured: "已配置功能",
service: "系统服务"
};
async function runCheck() {
if (loading) return;
loading = true;
error = "";
try {
report = await api("/api/health");
checkedAt = Date.now();
} catch (caught) {
error = caught.message;
} finally {
loading = false;
}
}
onMount(runCheck);
</script>
<div class="health-pane" aria-busy={loading}>
<div class="panel health-summary" class:healthy={report?.overall === "healthy"} class:degraded={report?.overall === "degraded"} class:unhealthy={report?.overall === "unhealthy"}>
<div class="summary-main">
<span class="summary-mark" aria-hidden="true">{report?.overall === "healthy" ? "✓" : report?.overall === "degraded" ? "!" : report ? "×" : "…"}</span>
<div>
<span class="label-caps">Gateway 健康状态</span>
<strong>{loading && !report ? "正在检查…" : report ? overallLabel : "尚未完成检查"}</strong>
<small>
{#if report}PicoBot v{report.version} · {report.checks?.length || 0} 项检查{:else}复用 picobot health 的只读诊断逻辑{/if}
</small>
</div>
</div>
<div class="summary-actions">
{#if report}
<div class="count-row" aria-label="检查结果统计">
<span class="count pass">{counts.pass} 通过</span>
<span class="count warning">{counts.warning} 警告</span>
<span class="count fail">{counts.fail} 失败</span>
</div>
{/if}
<button class="primary" onclick={runCheck} disabled={loading}>{loading ? "检查中…" : "重新检查"}</button>
</div>
</div>
{#if error}
<div class="health-error" role="alert">无法完成健康检查:{error}</div>
{/if}
{#if report}
<div class="check-list" aria-live="polite">
{#each report.checks || [] as check}
<article class="panel check-card {check.status}">
<span class="check-mark" aria-hidden="true">{check.status === "pass" ? "✓" : check.status === "warning" ? "!" : "×"}</span>
<div class="check-content">
<div class="check-heading">
<strong>{check.name}</strong>
<span class="badge {check.status === "pass" ? "ok" : check.status === "warning" ? "run" : "fail"}">{statusLabels[check.status] || check.status}</span>
<span class="badge">{categoryLabels[check.category] || check.category}</span>
<span class="requirement">{check.required ? "必需" : "可选"}</span>
</div>
<p>{check.detail}</p>
{#if check.remediation}
<div class="remediation"><b>建议</b><span>{check.remediation}</span></div>
{/if}
</div>
</article>
{/each}
</div>
<p class="health-footnote">上次检查:{formatTime(checkedAt)}。检查不会安装依赖、修改配置或连接模型服务。</p>
{:else if !loading && !error}
<div class="panel empty-health">点击“重新检查”获取当前运行环境的诊断结果。</div>
{/if}
</div>
<style>
.health-pane { min-width: 0; display: grid; gap: 14px; }
.health-summary { min-height: 112px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 18px 20px; border-left: 4px solid var(--line-strong); }
.health-summary.healthy { border-left-color: var(--signal); }
.health-summary.degraded { border-left-color: var(--warning); }
.health-summary.unhealthy { border-left-color: var(--danger); }
.summary-main { min-width: 0; display: flex; align-items: center; gap: 14px; }
.summary-mark { width: 42px; height: 42px; flex: 0 0 42px; display: grid; place-items: center; border-radius: 50%; color: var(--text-soft); background: var(--panel-2); font-size: 23px; font-weight: 700; }
.healthy .summary-mark { color: var(--signal); background: var(--signal-soft); }
.degraded .summary-mark { color: var(--warning); background: var(--warning-soft); }
.unhealthy .summary-mark { color: var(--danger); background: var(--danger-soft); }
.summary-main strong, .summary-main small { display: block; }
.summary-main strong { margin-top: 4px; font-size: 20px; line-height: 28px; }
.summary-main small { margin-top: 2px; color: var(--muted); font-size: 12px; }
.summary-actions { display: flex; align-items: flex-end; flex-direction: column; gap: 12px; }
.count-row { display: flex; gap: 12px; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
.count.pass { color: var(--signal); }
.count.warning { color: var(--warning); }
.count.fail { color: var(--danger); }
.health-error { padding: 11px 13px; border: 1px solid var(--danger-border); border-radius: var(--radius); color: var(--danger); background: var(--danger-soft); font-size: 13px; }
.check-list { display: grid; gap: 9px; }
.check-card { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 11px; padding: 14px 16px; border-left: 3px solid var(--line-strong); }
.check-card.warning { border-left-color: var(--warning); }
.check-card.fail { border-left-color: var(--danger); }
.check-mark { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; color: var(--signal); background: var(--signal-soft); font-weight: 700; }
.warning .check-mark { color: var(--warning); background: var(--warning-soft); }
.fail .check-mark { color: var(--danger); background: var(--danger-soft); }
.check-content { min-width: 0; }
.check-heading { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.check-heading strong { margin-right: 3px; font-size: 14px; overflow-wrap: anywhere; }
.requirement { color: var(--muted); font-size: 11px; }
.check-content p { margin: 7px 0 0; color: var(--text-soft); font-size: 13px; line-height: 20px; white-space: pre-wrap; overflow-wrap: anywhere; }
.remediation { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; margin-top: 9px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; color: var(--text-soft); background: var(--panel-2); font-size: 12px; line-height: 18px; }
.remediation b { color: var(--accent); }
.health-footnote { margin: 0; color: var(--muted); font-size: 11px; text-align: right; }
.empty-health { padding: 28px; color: var(--muted); text-align: center; }
@media (max-width: 760px) {
.health-summary { align-items: stretch; flex-direction: column; }
.summary-actions { align-items: stretch; }
.count-row { justify-content: space-between; }
}
</style>

View File

@ -3,6 +3,7 @@
import { Tabs } from "bits-ui";
import { api } from "../lib/api.js";
import AppearanceSettings from "../lib/components/AppearanceSettings.svelte";
import HealthChecks from "../lib/components/HealthChecks.svelte";
import SubAgentDefinitions from "../lib/components/SubAgentDefinitions.svelte";
let { notify } = $props();
@ -12,11 +13,15 @@
let path = $state("");
let loading = $state(true);
let saving = $state(false);
let cleaning = $state(false);
let reloadStatus = $state(null);
let diagnostics = $state([]);
let revision = $state("");
let pollTimer = null;
const isConfig = $derived(tab === "config");
const isAppearance = $derived(tab === "appearance");
const isHealth = $derived(tab === "health");
const isSubagents = $derived(tab === "subagents");
const title = $derived(tab === "config" ? "运行配置" : tab === "user" ? "用户档案" : tab === "agents" ? "Agent 行为准则" : "页面外观");
const isDirty = $derived(content !== original);
@ -36,13 +41,15 @@
async function load() {
loading = true;
if (isAppearance || isSubagents) { loading = false; return; }
if (isAppearance || isHealth || isSubagents) { loading = false; return; }
try {
if (isConfig) {
const result = await api("/api/config");
content = JSON.stringify(result.config, null, 2);
original = content;
path = result.path;
diagnostics = result.diagnostics || [];
revision = result.revision || "";
} else {
const result = await api(`/api/profiles/${tab}`);
content = result.content;
@ -62,6 +69,8 @@
const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config }) });
content = JSON.stringify(result.config, null, 2);
original = content;
diagnostics = result.diagnostics || [];
revision = result.revision || revision;
notify("配置已保存");
} else {
await api(`/api/profiles/${tab}`, { method: "PUT", body: JSON.stringify({ content }) });
@ -80,6 +89,8 @@
const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config }) });
content = JSON.stringify(result.config, null, 2);
original = content;
diagnostics = result.diagnostics || [];
revision = result.revision || revision;
const reload = await api("/api/config/reload", { method: "POST" });
notify(reload.message || "配置已保存并触发热重载");
pollReloadStatus();
@ -89,6 +100,24 @@
function discard() { content = original; }
async function cleanupInvalid() {
if (isDirty || !revision || !diagnostics.length) return;
cleaning = true;
const count = diagnostics.length;
try {
const result = await api("/api/config/cleanup-invalid", {
method: "POST",
body: JSON.stringify({ revision })
});
content = JSON.stringify(result.config, null, 2);
original = content;
diagnostics = result.diagnostics || [];
revision = result.revision || revision;
notify(`已清除 ${count} 个无效配置项;请热重载以同步磁盘配置`);
} catch (caught) { notify(caught.message, true); }
finally { cleaning = false; }
}
async function pollReloadStatus() {
try { reloadStatus = await api("/api/config/reload/status"); } catch {}
}
@ -121,12 +150,14 @@
<div class="settings-grid">
<Tabs.Root value={tab} onValueChange={changeTab} orientation="vertical">
<Tabs.List class="settings-nav" aria-label="设置分类">
<Tabs.Trigger value="appearance">外观</Tabs.Trigger><Tabs.Trigger value="config">config.json</Tabs.Trigger><Tabs.Trigger value="user">USER.md</Tabs.Trigger><Tabs.Trigger value="agents">AGENTS.md</Tabs.Trigger><Tabs.Trigger value="subagents">子代理</Tabs.Trigger>
<Tabs.Trigger value="appearance">外观</Tabs.Trigger><Tabs.Trigger value="config">config.json</Tabs.Trigger><Tabs.Trigger value="health">健康检查</Tabs.Trigger><Tabs.Trigger value="user">USER.md</Tabs.Trigger><Tabs.Trigger value="agents">AGENTS.md</Tabs.Trigger><Tabs.Trigger value="subagents">子代理</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
{#if isAppearance}
<AppearanceSettings />
{:else if isHealth}
<HealthChecks />
{:else if isSubagents}
<div class="subagents-pane">
<SubAgentDefinitions {notify} />
@ -139,8 +170,8 @@
<div class="editor-actions">
{#if isDirty}<span class="dirty-badge">未保存</span>{/if}
<button class="secondary" onclick={discard} disabled={!isDirty || loading || saving}>放弃修改</button>
<button class="secondary" onclick={saveAndReload} disabled={loading || saving || !!jsonError}>保存并热重载</button>
<button class="primary" onclick={save} disabled={loading || saving || !!jsonError}>保存</button>
<button class="secondary" onclick={saveAndReload} disabled={loading || saving || cleaning || !!jsonError}>保存并热重载</button>
<button class="primary" onclick={save} disabled={loading || saving || cleaning || !!jsonError}>保存</button>
</div>
</div>
<textarea bind:value={content} spellcheck="false" disabled={loading}></textarea>
@ -165,6 +196,32 @@
{/if}
</div>
<div class="panel sidebar-panel">
<div class="diagnostic-head">
<span class="label-caps">无效配置项</span>
{#if diagnostics.length}<span class="badge fail">{diagnostics.length}</span>{/if}
</div>
{#if diagnostics.length}
<ul class="diagnostic-list">
{#each diagnostics as diagnostic}
<li>
<code>{diagnostic.path}</code>
<span>{diagnostic.reason}</span>
</li>
{/each}
</ul>
<button
class="secondary cleanup-button"
onclick={cleanupInvalid}
disabled={isDirty || loading || saving || cleaning}
title={isDirty ? "请先保存或放弃编辑器中的修改" : "从 config.json 删除所有已被运行时忽略的配置项"}
>{cleaning ? "清理中…" : "一键清除"}</button>
{#if isDirty}<p class="cleanup-hint">存在未保存修改,暂不能清理。</p>{/if}
{:else}
<p class="sidebar-empty">未发现被忽略的配置项</p>
{/if}
</div>
<div class="panel sidebar-panel">
<span class="label-caps">热重载状态</span>
{#if reloadStatus}
@ -202,6 +259,13 @@
.outline-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 7px; }
.outline-list li { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--text-soft); }
.outline-note { font-size: 11px; color: var(--muted); white-space: nowrap; }
.diagnostic-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.diagnostic-list { list-style: none; margin: 10px 0 12px; padding: 0; display: grid; gap: 9px; max-height: 280px; overflow: auto; }
.diagnostic-list li { display: grid; gap: 3px; padding-bottom: 9px; border-bottom: 1px solid var(--line); }
.diagnostic-list code { overflow-wrap: anywhere; color: var(--danger); font-size: 11px; }
.diagnostic-list span { color: var(--text-soft); font-size: 12px; line-height: 1.45; }
.cleanup-button { width: 100%; justify-content: center; }
.cleanup-hint { margin: 7px 0 0; color: var(--muted); font-size: 11px; }
.reload-row { display: flex; justify-content: space-between; align-items: center; padding: 7px 0; font-size: 13px; color: var(--text-soft); }
.reload-row + .reload-row { border-top: 1px solid var(--line); }
.reload-error { margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: var(--danger-soft); color: var(--danger); font-size: 12px; line-height: 1.5; }