diff --git a/AGENTS.md b/AGENTS.md index 1642065..3255cd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `/.env`, then `/.env`, while pre-existing process variables remain highest priority; config placeholders `` 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 `/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 diff --git a/Cargo.toml b/Cargo.toml index 13cb9f4..46807d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 18ab5a0..ef5d757 100644 --- a/README.md +++ b/README.md @@ -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` 显式关闭配对,但不建议在非隔离环境使用。 diff --git a/docs/AGENT_BROWSER_INTEGRATION.md b/docs/AGENT_BROWSER_INTEGRATION.md index fa942c3..0383302 100644 --- a/docs/AGENT_BROWSER_INTEGRATION.md +++ b/docs/AGENT_BROWSER_INTEGRATION.md @@ -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` Tool:Agent 可调用的只读入口,支持 `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. 迁移和故障处理 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2704bee..fe4e9ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -266,13 +266,14 @@ MCP 发现的工具在 `ToolRegistry` 中使用 `mcp__` `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 Cookie,CLI 使用 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__` ### 启动 -1. 解析配置路径,加载配置目录 `.env`,据此定位 workspace,再加载 workspace `.env`;既有进程环境保持最高优先级,合并后重新解析配置。 +1. 解析配置路径,加载配置目录 `.env`,据此定位 workspace,再加载 workspace `.env`;既有进程环境保持最高优先级,合并后通过统一容错加载器重新解析配置。可恢复项只影响有效运行时投影并产生诊断,原始 `config.json` 保持不变。 2. 初始化日志,创建并切换到 workspace,初始化 WebUI 配对存储与本机管理密钥。 3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager;Scheduler 启用时幂等创建默认日常维护巡检。 4. 注册内置工具、渠道、MCP 工具和 Cron 工具。 diff --git a/docs/CONFIG_HOT_RELOAD_DESIGN.md b/docs/CONFIG_HOT_RELOAD_DESIGN.md index 3a39d73..f9c1fcf 100644 --- a/docs/CONFIG_HOT_RELOAD_DESIGN.md +++ b/docs/CONFIG_HOT_RELOAD_DESIGN.md @@ -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`)时允许重载。 diff --git a/resources/skills/about-picobot/references/architecture.md b/resources/skills/about-picobot/references/architecture.md index 8c51147..6610143 100644 --- a/resources/skills/about-picobot/references/architecture.md +++ b/resources/skills/about-picobot/references/architecture.md @@ -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` | MCP(Model Context Protocol)工具集成 | | `task_supervisor` | Gateway 后台任务注册、取消、限时等待和强制回收 | diff --git a/resources/skills/about-picobot/references/config.md b/resources/skills/about-picobot/references/config.md index 17c3687..092becc 100644 --- a/resources/skills/about-picobot/references/config.md +++ b/resources/skills/about-picobot/references/config.md @@ -3,7 +3,7 @@ 配置文件加载顺序:`~/.picobot/config.json` → 当前目录 `./config.json`。 占位符 `` 从启动环境替换。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 启动环境。 ### 浏览器依赖故障处置 diff --git a/resources/skills/about-picobot/references/tools.md b/resources/skills/about-picobot/references/tools.md index b9680a4..52da78c 100644 --- a/resources/skills/about-picobot/references/tools.md +++ b/resources/skills/about-picobot/references/tools.md @@ -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`。 --- diff --git a/src/config/mod.rs b/src/config/mod.rs index 4736cf7..9c1bb88 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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, + /// 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, + 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), + Array(Vec), +} + +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 (¤t.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> { + 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, +) { + 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> { + 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::() + .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::() + .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::>(); + paths.sort_by_key(|path| decode_json_pointer(path).map_or(usize::MAX, |parts| parts.len())); + let mut coalesced: Vec = 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::().ok()); + let right_index = right_parts + .last() + .and_then(|part| part.parse::().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> { + 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::>(); + 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::>(); + 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 = 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::load_from(Path::new(path)) } @@ -819,8 +1320,20 @@ impl Config { } pub(crate) fn load_from(path: &Path) -> Result> { - 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::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 { - 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 { + 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::>() + .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::(r#"{"unknown":1}"#).is_err()); + assert!(serde_json::from_str::(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::( 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!["/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!["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( diff --git a/src/gateway/http.rs b/src/gateway/http.rs index 86aa3d2..5153a8c 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -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 { }) } +pub async fn health_report( + State(state): State>, +) -> Json { + 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, 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>, ) -> Result, 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>, Json(mut incoming): Json, ) -> Result, 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(¤t_raw).map_err(ApiError::internal)?; restore_redacted_secrets(&mut incoming, ¤t); - 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>, + Json(request): Json, +) -> Result, 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") diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index bec8466..c2ea2cb 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -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, + pub(crate) config_write_lock: Arc>, pub workspace_dir: std::path::PathBuf, + pub(crate) health: Arc, pub session_manager: Arc, pub channel_manager: ChannelManager, pub storage: Arc, @@ -62,10 +65,13 @@ impl GatewayState { /// when the state is owned by [`run`], which owns the generation loop. pub async fn new() -> Result> { 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, + config_write_lock: Arc>, 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> { 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::>() + .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) -> 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", diff --git a/src/health.rs b/src/health.rs index 7197a7e..b76a8de 100644 --- a/src/health.rs +++ b/src/health.rs @@ -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::>() + .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) { + 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 { +) -> Result { 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 { + 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, + #[serde(default)] + success: bool, +} + +#[derive(Debug, Deserialize)] +struct AgentBrowserDoctorCheck { + id: String, + message: String, + status: String, +} + +fn parse_agent_browser_doctor(output: &Output) -> Result { + 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::(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 { + 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::>(); + 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::>() + .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 { output .split_whitespace() @@ -508,22 +751,6 @@ fn extract_version(output: &str) -> Option { .map(str::to_string) } -fn summarize_output(output: &str) -> String { - if let Ok(json) = serde_json::from_str::(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!( diff --git a/webui/package-lock.json b/webui/package-lock.json index 4e03945..1320a3c 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -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", diff --git a/webui/package.json b/webui/package.json index 9e319eb..a05b1d4 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,7 +1,7 @@ { "name": "picobot-webui", "private": true, - "version": "1.20.0", + "version": "1.21.0", "type": "module", "engines": { "node": ">=20" diff --git a/webui/src/App.svelte b/webui/src/App.svelte index bf09bba..95fa605 100644 --- a/webui/src/App.svelte +++ b/webui/src/App.svelte @@ -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 { diff --git a/webui/src/lib/components/HealthChecks.svelte b/webui/src/lib/components/HealthChecks.svelte new file mode 100644 index 0000000..0fd7169 --- /dev/null +++ b/webui/src/lib/components/HealthChecks.svelte @@ -0,0 +1,146 @@ + + +
+
+
+ +
+ Gateway 健康状态 + {loading && !report ? "正在检查…" : report ? overallLabel : "尚未完成检查"} + + {#if report}PicoBot v{report.version} · {report.checks?.length || 0} 项检查{:else}复用 picobot health 的只读诊断逻辑{/if} + +
+
+
+ {#if report} +
+ {counts.pass} 通过 + {counts.warning} 警告 + {counts.fail} 失败 +
+ {/if} + +
+
+ + {#if error} + + {/if} + + {#if report} +
+ {#each report.checks || [] as check} +
+ +
+
+ {check.name} + {statusLabels[check.status] || check.status} + {categoryLabels[check.category] || check.category} + {check.required ? "必需" : "可选"} +
+

{check.detail}

+ {#if check.remediation} +
建议{check.remediation}
+ {/if} +
+
+ {/each} +
+

上次检查:{formatTime(checkedAt)}。检查不会安装依赖、修改配置或连接模型服务。

+ {:else if !loading && !error} +
点击“重新检查”获取当前运行环境的诊断结果。
+ {/if} +
+ + diff --git a/webui/src/pages/SettingsPage.svelte b/webui/src/pages/SettingsPage.svelte index dfa55ec..da636d5 100644 --- a/webui/src/pages/SettingsPage.svelte +++ b/webui/src/pages/SettingsPage.svelte @@ -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 @@
- 外观config.jsonUSER.mdAGENTS.md子代理 + 外观config.json健康检查USER.mdAGENTS.md子代理 {#if isAppearance} + {:else if isHealth} + {:else if isSubagents}
@@ -139,8 +170,8 @@
{#if isDirty}未保存{/if} - - + +
@@ -165,6 +196,32 @@ {/if}
+ +