diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 2911dd3..74ce1ad 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -480,6 +480,15 @@ modules: note: "T1-T9 全部闭环;通用 CI 模板优先消费项目 `.nvmrc` / `packageManager`,声明缺失时兼容旧项目回退。T9 经 PR #16 合并进入 master。" position: [2500, 950] + - id: agent-access-reliability + name: Agent 访问可靠性 + area: infra + status: in_progress + progress: 89 + updated: 2026-09-23 + note: "A1-A8 已闭环;A9 已通过双项目 scoped HTTP、越界拒绝及完全退出 unavailable 验证,等待关闭窗口托盘、新任务、UI 与正式安装版 autostart human_verify。" + position: [2750, 950] + edges: # 前端依赖 - from: workspace-mgmt @@ -790,3 +799,13 @@ edges: - from: agent-client-capability-packs to: toolchain-capability-pack type: related + # Agent 访问可靠性:复用单一 MCP、接入包和客户端能力包,补齐运行生命周期与项目作用域 + - from: mcp-server + to: agent-access-reliability + type: dependency + - from: mcp-config-inject + to: agent-access-reliability + type: dependency + - from: agent-client-capability-packs + to: agent-access-reliability + type: dependency diff --git a/.blueprint/modules/agent-access-reliability.md b/.blueprint/modules/agent-access-reliability.md new file mode 100644 index 0000000..59e5418 --- /dev/null +++ b/.blueprint/modules/agent-access-reliability.md @@ -0,0 +1,160 @@ +--- +id: agent-access-reliability +name: Agent 访问可靠性 +status: in_progress +progress: 89 +--- + +## 设计决策:Agent Access Reliability v1 + +### 背景 + +炼境已经具备项目级 `.codex/config.toml`、canonical `.lian-jing/context.json`、Codex Skill、30 余个 MCP tools、工具链契约及 PR/CI 安全门禁,但“配置存在”仍不等于“当前 Agent 可用”。2026-09-23 实测项目配置正确,`127.0.0.1:27190` 却拒绝连接,当前 Codex 会话也未发现 `lian-jing` 工具;现有 `verify_onboarding` 只检查配置文本,无法区分服务未运行、握手失败、工具缺失或客户端需要重载。 + +同时,MCP 当前监听 `0.0.0.0`,健康检查仍使用 `localhost`,所有项目共享无连接作用域的工具目录并由调用方自由传 `project_id`。这对单人本机使用足够方便,但扩大了局域网暴露、跨项目误操作和 prompt injection 的影响面。 + +### 方案选择 + +| 方案 | 优点 | 代价 / 风险 | 结论 | +| --- | --- | --- | --- | +| 保持现状,仅在 Skill 提醒先启动炼境 | 零实现成本 | 配置假绿、MCP 不可用时诊断工具也不可用,人工启动继续成为瓶颈 | 否决 | +| 立即拆独立 Windows Service,并由插件内置完整 MCP | 可用性最高 | 安装、升级、日志、双实现漂移和权限模型成本过高 | 后置 | +| loopback 单一 MCP + 分层自检 + 可选 Windows 自启动 + 逻辑项目作用域 | 保留现有本体,优先解决真实高频故障;不复制凭证和业务逻辑 | 不能抵御同一 OS 用户主动伪造其他项目 URL,属于单用户威胁模型下的误操作隔离 | **v1 选定** | +| 为每个项目写入持久 secret/token | 能形成更强隔离 | secret 易进入受版本控制的 `.codex/config.toml`,还需安全存储与轮换 | v1 否决,威胁模型变化后再评估 | + +### 系统边界变更 + +```text +Codex / Claude 项目 +├─ Skill:工作流与降级规则 +├─ .lian-jing/context.json:唯一 project_id +└─ .codex/config.toml:指向 127.0.0.1 的项目作用域 MCP URL + │ + ▼ +炼境单一 Tauri 进程 +├─ loopback MCP listener +├─ runtime readiness:configured / reachable / handshake / tools_ready / reload_required +├─ scoped route:连接绑定 project_id,工具参数不得越界 +├─ read-only 与 write/destructive 元数据 +└─ 现有 SQLite / Gitea / 工具链 / 交付能力本体 +``` + +- MCP、健康检查和 SSE callback 全部使用 `127.0.0.1`;v1 不对局域网开放 MCP。 +- Gitea webhook 若未来确需入站访问,必须与 MCP listener 分离并单独认证;不得以 webhook 为由继续让 MCP 监听 `0.0.0.0`。 +- `verify_onboarding` 保留静态接入检查,并新增运行态 readiness;不能把配置存在描述成当前客户端已加载。 +- 增加项目作用域路由,例如 `/mcp/projects/:project_id`。服务端从连接取得 scope;若 tool 参数携带不同 `project_id` 必须拒绝。旧 `/mcp` 仅作兼容迁移,不扩展新的项目写能力。 +- v1 的项目作用域是单用户本机的误操作隔离,不宣称是多租户认证。不得把 token 写入受版本控制的项目文件。 +- MCP `initialize` 返回精简 server instructions;核心工具增加 read-only / destructive / idempotent 元数据,并为状态类工具同时返回文本摘要与结构化结果。 +- 炼境继续使用现有托盘常驻。Windows 登录自启动仅服务正式安装版:默认关闭、debug/target 构建拒绝启用,复用 single-instance;不拆独立服务。updater 保持独立模块。 +- 插件 v1 继续保持 Skill-only,不复制 MCP 能力、项目身份或凭证;只同步 readiness 和降级指引。 + +### 受影响文件 + +- 修改:`src-tauri/src/mcp_server.rs` — loopback 监听、项目作用域路由、initialize instructions 与握手测试。 +- 修改:`src-tauri/src/commands/settings.rs` — 统一 `127.0.0.1`、运行态 readiness 与正式版自启动状态。 +- 修改:`src-tauri/src/commands/onboarding.rs` — 分层 onboarding/runtime 自检和作用域配置校验。 +- 修改:`src-tauri/src/mcp_inject.rs` — 项目作用域 URL 的兼容迁移与保留用户配置。 +- 修改:`src-tauri/src/mcp_tools.rs` — scope 校验、核心工具 metadata 与 structured result。 +- 修改:`src-tauri/src/lib.rs`、`src/lib/commands.ts` — readiness 与正式版自启动的 Tauri 投影。 +- 修改:`src/components/settings/SettingsPage.tsx` — Agent 连接状态与自启动 UI。 +- 修改:`src-tauri/Cargo.toml`、`src-tauri/Cargo.lock` — 官方 autostart plugin,仅 Rust 侧使用,不增加前端依赖或 pnpm lock 变更。 +- 修改:`src-tauri/resources/onboarding/`、`plugins/lian-jing-codex/skills/lian-jing/SKILL.md` — readiness/重载/降级说明。 +- 修改:`docs/ai-context/project-context.yaml`、`docs/ai-context/agent-button-system.md` — 外部契约与稳定边界。 + +### 验证路径 + +- 载体:Rust 纯函数与 HTTP MCP 握手测试;Tauri command 做运行态聚合;UI 仅做人眼终验。 +- 示例:`[cargo test] mcp_bind_addr(27190) → 127.0.0.1:27190,绝不返回 0.0.0.0 或 ::。` +- 示例:`[HTTP MCP] POST /mcp/projects/p1 initialize → server instructions 存在;tools/list 只返回允许集合;tools/call 携带 project_id=p2 → 明确拒绝且零副作用。` +- 示例:`[Tauri command] verify_onboarding(p1) 在配置存在但端口未监听时 → configured=ready、reachable=unavailable,不得整体假报 ready。` +- 示例:`[Tauri command] get_agent_runtime_status() → 返回 configured、listener、handshake、core_tools 与 reload_required 的可行动状态。` +- human_verify:关闭窗口后托盘进程仍提供 MCP;打开新的 Codex 项目任务可发现核心只读工具。 + +### 风险点 + +- **自检调用自身形成死锁**:运行态探针必须是 Tauri command/本地 HTTP 的独立路径,不能只通过 MCP `get_diagnostics` 暴露。 +- **自检拖慢 UI 或任务启动**:健康探针使用短超时并区分 unreachable/timeout/protocol_error;不得阻塞主线程。 +- **旧客户端兼容**:现有 `/mcp` 和旧项目配置采用版本化迁移;迁移期只给出明确 legacy 状态,不静默覆盖手工同名 server。 +- **作用域被误当成强认证**:文档明确 v1 只防误路由;服务仍依赖 loopback 与本机用户边界。 +- **工具 metadata 漂移**:metadata 与 dispatch 使用同一工具定义来源或配套完整性测试,避免只改 schema 未改执行边界。 +- **范围膨胀**:本模块不做 production 部署、SSH、migration、公开插件或 staging;production 双证据继续由 `project-capability-control-plane` P3-B~E 承担。 + +## 任务卡 + +### ✅ A1. MCP loopback 与 Windows 地址一致性 +- status: done +- complexity: S +- files: src-tauri/src/mcp_server.rs, src-tauri/src/commands/settings.rs +- acceptance: "[cargo test] MCP bind/health URL 构造 → listener=127.0.0.1:、health=http://127.0.0.1:/health、SSE callback 同源;源码中 MCP 健康路径不再出现 localhost 或 0.0.0.0。" +- done_note: "MCP listener、health 与 SSE callback 已统一到 127.0.0.1;定向测试 2/2 与 cargo test --lib 164/164 通过,git diff --check 通过。真实 HTTP/SSE 连接留待 A9 运行态验收。" + +### ✅ A2. 分层 Agent runtime readiness 本体 +- status: done +- complexity: M +- depends: A1. MCP loopback 与 Windows 地址一致性 +- files: src-tauri/src/mcp_server.rs, src-tauri/src/commands/settings.rs, src-tauri/src/commands/onboarding.rs, src-tauri/src/lib.rs, src/lib/commands.ts +- acceptance: "[Tauri command] get_agent_runtime_status(project_id) 对 configured/reachable/handshake/tools_ready/reload_required 五层 fixture → 分别返回 ready|missing|unavailable|protocol_error 与可行动 detail;端口拒绝时不得把静态配置误报为整体 ready。" +- done_note: "新增独立 Tauri readiness command、五层状态与 TS 契约;端口拒绝、协议错误、核心工具缺失、需重载和全就绪 fixture 均通过。cargo test --lib 167/167、mise pnpm 9.15.0 typecheck 通过。MCP 不可用,M 级复盘待恢复后写入 append_project_note。" + +### ✅ A3. Onboarding 自检接入运行态证据 +- status: done +- complexity: M +- depends: A2. 分层 Agent runtime readiness 本体 +- files: src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, docs/ai-context/project-context.yaml +- acceptance: "[cargo test] verify_onboarding(project_id) → 保留现有静态检查并附加 runtime readiness;配置正确但 MCP 不可达时 summary.pass=false 且检查项明确为 unavailable;握手成功且核心工具齐全时为 ready。" +- done_note: "verify_onboarding 报告已同时包含静态 checks 与五层 runtime;配置正确但 connection refused fixture 明确失败,当前 MCP 调用以内层连接事实避免 HTTP 自调用。cargo test --lib 168/168 通过;MCP 不可用,M 级复盘待恢复后写入。" + +### ✅ A4. MCP instructions、工具风险元数据与核心结构化结果 +- status: done +- complexity: M +- depends: A1. MCP loopback 与 Windows 地址一致性 +- files: src-tauri/src/mcp_server.rs, src-tauri/src/mcp_tools.rs +- acceptance: "[HTTP MCP] initialize → instructions 前 512 字包含先读状态、项目边界和写操作确认;tools/list → 核心工具具有 readOnly/destructive/idempotent 元数据;get_delivery_status/get_toolchain_status/verify_onboarding → 同时返回结构化数据与可读摘要。" +- done_note: "initialize instructions 已声明先读状态、项目边界与写操作确认;全工具 annotations 与核心 structuredContent/摘要测试通过。cargo test --lib 170/170 通过;MCP 复盘待服务恢复后写入。" + +### ✅ A5. 项目作用域 MCP 路由与越界拒绝 +- status: done +- complexity: M +- depends: A4. MCP instructions、工具风险元数据与核心结构化结果 +- files: src-tauri/src/mcp_server.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/mcp_inject.rs +- acceptance: "[HTTP MCP] /mcp/projects/p1 的 tools/call(get_delivery_status) → 服务端固定使用 p1;显式传 p2 或调用全局管理写工具 → 返回 scope_violation 且零副作用;旧 /mcp 仍可握手并报告 legacy/upgrade_hint。" +- done_note: "新增 scoped HTTP/SSE 路由、连接作用域工具目录、project_id 自动绑定与越界前置拒绝;旧 /mcp 初始化返回 legacy/upgrade_hint,新注入 URL 指向项目 scope。cargo test --lib 174/174 通过;真实 HTTP 留待 A9。" + +### ✅ A6. Windows 登录自启动后端能力(正式安装版限定) +- status: done +- complexity: M +- depends: A2. 分层 Agent runtime readiness 本体 +- files: src-tauri/Cargo.toml, src-tauri/Cargo.lock, src-tauri/src/lib.rs, src-tauri/src/commands/settings.rs +- acceptance: "[Tauri command] set_agent_autostart(enabled) → 默认 false,可启用、查询并撤销;重复调用幂等;不启动第二个炼境实例,不修改项目文件或 pnpm-lock.yaml。" +- decision_note: "用户于 2026-09-23 明确恢复 A6,但限定正式安装版:默认关闭,debug/target 构建拒绝启用,查询/启停幂等,复用 single-instance;updater 单独建设。" +- done_note: "接入官方 tauri-plugin-autostart,保留既有 single-instance;query/set command 对重复调用幂等,debug 与 target/release 路径拒绝启用,未改 pnpm-lock。路径 fixture、cargo test --lib 176/176 与 cargo check --release 通过;正式安装后的注册表效果留待安装包 human_verify。" + +### ✅ A7. Agent 连接状态与自启动 UI 投影 +- status: done +- complexity: M +- depends: A2. 分层 Agent runtime readiness 本体, A6. Windows 登录自启动后端能力 +- files: src/lib/commands.ts, src/components/settings/SettingsPage.tsx, src/components/settings/SettingsPage.test.ts +- acceptance: "agent_verify: [pnpm typecheck] Agent runtime/autostart 类型与调用无错误;[Tauri command fixture] unavailable、reload_required、ready 分别渲染准确状态和唯一建议动作。human_verify: 设置页能区分未配置、炼境未运行、需重启 Codex、已就绪,并仅在正式安装版允许启停登录自启动。" +- done_note: "设置页新增项目级 Agent readiness 与正式版自启动投影;五类状态映射各自产生唯一行动。Vitest 5/5、mise pnpm typecheck 与 production frontend build 通过;视觉与正式安装版开关留待 human_verify。" + +### ✅ A8. 项目作用域配置迁移与 Skill 降级指引 +- status: done +- complexity: M +- depends: A3. Onboarding 自检接入运行态证据, A5. 项目作用域 MCP 路由与越界拒绝 +- files: src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md, plugins/lian-jing-codex/skills/lian-jing/SKILL.md, src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_inject.rs +- acceptance: "[cargo test] 旧 unscoped Codex 配置重跑 onboarding → 报 legacy/upgrade_hint 且不覆盖手工同名 server;新配置指向当前 project_id 的 scoped URL;[Skill] MCP 缺失时按 configured→reachable→reload 顺序降级,不猜测状态或要求复制凭证。" +- done_note: "verify_onboarding 已区分 scoped ready、旧 /mcp legacy 与错误项目;炼境托管块可幂等迁移到 scoped URL,手工同名 server 仍拒绝覆盖。接入包与私有插件 Skill 均采用 configured→reachable→reload;相关回归及 Rust 全量 176/176 通过。" + +### 🔵 A9. 炼境与 Enterprise 消费者端到端验收 +- status: in_progress +- complexity: M +- depends: A7. Agent 连接状态与自启动 UI 投影, A8. 项目作用域配置迁移与 Skill 降级指引 +- files: .blueprint/modules/agent-access-reliability.md, .blueprint/manifest.yaml, docs/ai-context/agent-button-system.md +- acceptance: "agent_verify: [HTTP MCP] 炼境与 Enterprise scoped endpoint 分别握手 → 只读取本项目状态,跨项目调用被拒绝;关闭窗口保留托盘后仍可调用,完全退出后 readiness=unavailable;新 Codex 任务重载后核心工具可见。human_verify: 用户在两个项目各打开一次新任务,均无需手工提供 project_id 或凭证即可读取自身状态。" +- progress_note: "真实开发实例已只监听 127.0.0.1;炼境与 Enterprise scoped endpoint 均完成 initialize、tools/list、get_delivery_status,返回各自 project_id;跨项目读取和 scoped 全局写工具均返回 scope_violation。两项目 managed config 已经各自 scoped inject_mcp 迁移;完全退出后确认 27190 无 LISTENING。待新任务、关闭窗口保留托盘、UI 与正式安装版 autostart human_verify。" + +## 执行顺序 + +A1 → A2 → A3;A1 → A4 → A5;稳定正式安装包链路 → A6 → A7;A3 + A5 → A8;A7 + A8 → A9。updater 不纳入 A6。 + +v1 完成后观测 2~4 周:若“服务未运行/客户端未加载”仍占 Agent 阻塞的主要比例,再新建立项评估插件 lifecycle hook、stdio bootstrap 或独立 daemon;不得在本模块内提前扩张。 diff --git a/.claude/settings.json b/.claude/settings.json index 2e00b38..df8adef 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -15,7 +15,7 @@ "mcpServers": { "lian-jing": { "type": "sse", - "url": "http://127.0.0.1:27190/mcp" + "url": "http://127.0.0.1:27190/mcp/projects/dev-manager-tauri" } }, "permissions": { diff --git a/.codex/config.toml b/.codex/config.toml index e883406..aa1cdde 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,4 +1,4 @@ # LIANJING_MCP_MANAGED_START [mcp_servers.lian-jing] -url = "http://127.0.0.1:27190/mcp" +url = "http://127.0.0.1:27190/mcp/projects/dev-manager-tauri" # LIANJING_MCP_MANAGED_END diff --git a/docs/ai-context/agent-button-system.md b/docs/ai-context/agent-button-system.md index a9256f1..8b294ea 100644 --- a/docs/ai-context/agent-button-system.md +++ b/docs/ai-context/agent-button-system.md @@ -1,6 +1,6 @@ --- -version: 1.0.0 -updated: 2026-07-03 +version: 1.1.0 +updated: 2026-09-23 usage: 炼境 agent 按钮体系的完整设计文档,供 Claude 理解体系全貌和设计意图;也是分发给子项目的方针来源 --- @@ -124,6 +124,19 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言: - 实现者角色:炼境开发角度(导入新项目、跑通接入包分发流程) - 消费者角色:被管理项目角度(接入包能否真的在子项目里用起来) +## 本地 Agent 控制面的稳定边界 + +炼境对 Codex、Claude 等客户端提供同一份本地能力本体,客户端插件与 UI 只是不同投影: + +- MCP 仅监听 IPv4 loopback `127.0.0.1`,不以 Agent 易用性为由暴露到局域网。 +- 项目使用 `/mcp/projects/` scoped endpoint;服务端以连接 scope 为准,拒绝跨项目参数和全局管理写工具。旧 `/mcp` 仅用于兼容迁移,不扩展新写能力。 +- readiness 分为 `configured → reachable → handshake → tools_ready → reload_required`。配置存在不等于当前任务已加载;停在哪层,就只修哪一层。 +- `.lian-jing/context.json` 是项目身份唯一来源;Skill 和插件不复制项目身份、Gitea 凭证或 MCP 实现。 +- Windows 登录自启动只服务正式安装版,默认关闭且由用户主动启用;debug 和 `target/release` 直接运行明确拒绝注册。single-instance 继续保证同一时间只有一个炼境实例。 +- updater 是独立的发布能力,不属于自启动或 readiness 的完成条件。 + +这套 scope 是单用户本机的误操作隔离,不是多租户认证。威胁模型变化前,不向项目配置写持久 token。 + ## 三条设计原则 辩证结论,缺一会让体系在现实中碎裂。 diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 3c692be..ff512d2 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "路由变更需同步 API 文档" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。旧 /mcp 只作兼容迁移。路由变更需同步 Agent 按钮体系文档。" - label: MCP 工具接口 paths: @@ -15,7 +15,7 @@ contracts: - "src-tauri/src/commands/toolchain.rs" - "src-tauri/src/commands/cicd.rs" - "src-tauri/src/commands/gitea.rs" - doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。" + doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。" - label: 被管理项目身份上下文 paths: @@ -30,10 +30,11 @@ contracts: paths: - "src-tauri/src/commands/**/*.rs" - "src/lib/commands.ts" - doc: "新增 command 时前后端必须同步" + doc: "新增 command 时前后端必须同步。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。" architecture_docs: - "AGENTS.md" + - "docs/ai-context/agent-button-system.md" agent_docs: - "AGENTS.md" diff --git a/plugins/lian-jing-codex/skills/lian-jing/SKILL.md b/plugins/lian-jing-codex/skills/lian-jing/SKILL.md index 20309a7..1e8ef70 100644 --- a/plugins/lian-jing-codex/skills/lian-jing/SKILL.md +++ b/plugins/lian-jing-codex/skills/lian-jing/SKILL.md @@ -9,11 +9,11 @@ description: Inspect an already onboarded Lian Jing project, use its existing MC 1. 先读取项目根目录的 `AGENTS.md`、`.blueprint/` 规则和 `.lian-jing/context.json`。 2. 仅将 `.lian-jing/context.json` 中的 `project_id` 用作炼境 MCP 调用参数。`.claude/liangjing.json` 只用于识别旧项目并提示迁移,不能作为长期身份来源。 -3. 当项目已有 `lian-jing` MCP 时,可调用 `get_diagnostics`、`verify_onboarding`、`get_toolchain_status` 和 `get_delivery_status` 获取可观察信号;所有调用显式传入 `project_id`。 +3. 当项目已有 `lian-jing` MCP 时,可调用 `get_diagnostics`、`verify_onboarding`、`get_toolchain_status` 和 `get_delivery_status` 获取可观察信号。scoped 连接已绑定当前项目,可省略 `project_id`;仅在 initialize 报告 legacy 时显式传 canonical `project_id`,不得越界。 4. Node/pnpm 项目运行包管理命令前先读取工具链状态;`ready_managed` 时使用 `mise exec -- pnpm ...`。只有用户明确确认下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配 Codex bundled runtime 修改项目版本或 lockfile。 5. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。 6. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。 -7. 若缺少 canonical context、MCP 或项目登记,不得猜测 project_id、MCP URL、凭证或状态。说明缺失项,并建议通过炼境接入或 `inject_mcp` 修复。 +7. 若缺少 MCP,依次判断 configured(scoped URL)→ reachable(炼境 loopback)→ reload(新建任务/重载客户端);停在哪层就报告哪层。缺少 canonical context 或项目登记时不得猜测 project_id、MCP URL、凭证或状态,并建议通过炼境接入或 `inject_mcp` 修复。 8. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。 本插件不包含 MCP server、MCP URL、项目 ID、凭证、部署命令或客户端项目 UI 映射;这些都属于项目接入与炼境控制面。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a41e98e..97743d1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -228,6 +228,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -899,6 +910,7 @@ dependencies = [ "sha2", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-http", "tauri-plugin-opener", @@ -924,13 +936,33 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -941,7 +973,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1062,7 +1094,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -3606,6 +3638,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4648,7 +4691,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -4699,7 +4742,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4771,6 +4814,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-dialog" version = "2.6.0" @@ -4910,7 +4967,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 6.0.0", "flate2", "futures-util", "http", @@ -5416,7 +5473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -6409,6 +6466,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" @@ -6523,7 +6589,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dom_query", "dpi", "dunce", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4c406b6..7d8e020 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,7 @@ tauri-plugin-http = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" tauri-plugin-single-instance = "2" +tauri-plugin-autostart = "2" axum = "0.7" tokio = { version = "1", features = ["net", "time"] } tokio-stream = "0.1" @@ -49,4 +50,3 @@ sha2 = "0.10" [dev-dependencies] tokio = { version = "1", features = ["macros", "rt"] } - diff --git a/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md index ce173ff..3c43f24 100644 --- a/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md +++ b/src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md @@ -9,7 +9,7 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管 如果当前客户端已发现 `lian-jing` MCP: -1. 所有 MCP 调用显式传入 `project_id`。 +1. scoped MCP 连接已由 URL 绑定当前 `project_id`,项目级调用可省略 `project_id`;若 server initialize 明确报告 legacy,迁移前才显式传入 canonical `project_id`,绝不传其他项目 ID。 2. 优先调用 `get_diagnostics`,将其作为当前项目健康状态的事实来源。 3. 在 Node/pnpm 项目运行包管理命令前调用 `get_toolchain_status(project_id)`;`ready_managed` 时通过 `mise exec -- pnpm ...` 执行,其他状态先报告实际版本与 executable 证据。 4. 只有用户明确确认本次下载安装后,才可调用 `prepare_toolchain(project_id, confirmed=true)`;不得为适配客户端 bundled runtime 而修改项目版本或 lockfile。 @@ -17,4 +17,9 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管 6. 合并 PR 前调用 `get_delivery_status(project_id)`,向用户展示 PR 编号、完整 head SHA、CI 与 review。只有当前用户明确确认该 PR 与 SHA 后,才调用 `merge_pull_request(project_id, pr_number, expected_head_sha, confirmed=true)`;合并后再次读取交付状态。不得自行推断确认。 7. 生成 CI workflow 时先调用 `plan_cicd_workflow(project_id, workflow_type)`,向用户展示目标文件、变更类型、`content_digest`、工具链来源与所需 secret 名称;只有用户明确确认这份摘要后,才以同一 digest 调用 `apply_cicd_workflow(..., confirmed=true)`。该流程只写项目 workflow,不配置 secret、不触发 CI 或部署;不得通过 MCP 传服务器地址、命令、路径或 secret 值,部署类配置先由用户在炼境 UI 保存。 -如果 MCP 未出现,说明 Codex 项目配置尚未被加载、项目未被信任,或炼境未运行。说明该限制后继续完成不依赖 MCP 的本地工作;不得伪造诊断结果。 +如果 MCP 未出现,按以下顺序降级,不猜测状态: + +1. `configured`:读取 `.codex/config.toml`,确认 `lian-jing` 指向 `http://127.0.0.1:/mcp/projects/`;缺失或 legacy 时通过炼境重新 `inject_mcp`,手工同名 server 不覆盖。 +2. `reachable`:配置正确后检查 loopback 服务;连接拒绝表示炼境未运行,先启动正式安装版并保持托盘进程。 +3. `reload`:服务可达但当前任务看不到工具时,新建 Codex 任务或重载客户端;不要反复改配置或复制凭证。 +4. 仍不可用时,明确报告停在哪一层,继续完成不依赖 MCP 的本地工作;不得伪造诊断、项目 ID、凭证或远端状态。 diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 2e755f3..76a4f8d 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -350,6 +350,35 @@ pub struct VerifyCheck { pub detail: String, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OnboardingVerification { + pub project_id: String, + pub pass: bool, + pub summary: String, + pub checks: Vec, + pub runtime: crate::commands::settings::AgentRuntimeStatus, +} + +pub fn summarize_onboarding( + project_id: &str, + checks: Vec, + runtime: crate::commands::settings::AgentRuntimeStatus, +) -> OnboardingVerification { + let passed = checks.iter().filter(|check| check.pass).count(); + let pass = passed == checks.len() && runtime.ready; + OnboardingVerification { + project_id: project_id.to_string(), + pass, + summary: format!( + "静态接入 {passed}/{} 项通过;Agent runtime {}", + checks.len(), + if runtime.ready { "ready" } else { "未就绪" } + ), + checks, + runtime, + } +} + /// 接入检查的可行动状态。只有 ready 才表示检查项通过;其余状态必须显式说明原因。 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -593,23 +622,60 @@ fn url_host(url: &str) -> Option { } } -fn check_codex_mcp(content: Option<&str>) -> (bool, String) { - match content { - Some(content) - if content.contains("[mcp_servers.lian-jing]") - && content.contains("url = \"http://127.0.0.1:") - && content.contains("/mcp\"") => - { - (true, "lian-jing 指向本地 MCP 服务".into()) +/// 读取 Codex MCP 静态配置。运行态可用性由 settings::get_agent_runtime_status 独立探测。 +fn codex_mcp_server_url(content: &str) -> Option<&str> { + let mut in_lian_jing = false; + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') { + in_lian_jing = trimmed == "[mcp_servers.lian-jing]"; + continue; } - Some(_) => ( - false, - "未找到炼境的 Codex MCP 配置——调用 inject_mcp 修复".into(), - ), - None => ( - false, - ".codex/config.toml 缺失——调用 inject_mcp 后生成".into(), - ), + if in_lian_jing { + let Some(raw_url) = trimmed.strip_prefix("url =") else { + continue; + }; + return Some(raw_url.trim().trim_matches('"')); + } + } + None +} + +pub(crate) fn check_codex_mcp( + content: Option<&str>, + expected_project_id: &str, + expected_port: u16, +) -> VerifyCheck { + let Some(content) = content else { + return VerifyCheck::with_state( + "Codex MCP 已配置", + VerifyState::Missing, + ".codex/config.toml 缺失——调用 inject_mcp 后生成", + ); + }; + let expected_url = crate::mcp_server::scoped_mcp_url(expected_port, expected_project_id); + let configured_url = codex_mcp_server_url(content); + if configured_url == Some(expected_url.as_str()) { + VerifyCheck::with_state( + "Codex MCP 已配置", + VerifyState::Ready, + format!("lian-jing 已绑定 project_id={expected_project_id} 的 scoped MCP URL"), + ) + } else if configured_url + .map(|url| url.starts_with("http://127.0.0.1:") && url.ends_with("/mcp")) + .unwrap_or(false) + { + VerifyCheck::with_state( + "Codex MCP 已配置", + VerifyState::Legacy, + "仍使用旧 /mcp 全局 URL——调用 inject_mcp 升级;手工同名 server 不会被覆盖", + ) + } else { + VerifyCheck::with_state( + "Codex MCP 已配置", + VerifyState::Missing, + "未找到当前项目的炼境 scoped MCP 配置——调用 inject_mcp 修复", + ) } } @@ -784,8 +850,11 @@ pub fn verify_onboarding( let (pass, detail) = check_skill(read(".agents/skills/lian-jing/SKILL.md").as_deref()); checks.push(VerifyCheck::from_pass("Codex 炼境技能已分发", pass, detail)); - let (pass, detail) = check_codex_mcp(read(".codex/config.toml").as_deref()); - checks.push(VerifyCheck::from_pass("Codex MCP 已配置", pass, detail)); + checks.push(check_codex_mcp( + read(".codex/config.toml").as_deref(), + project_id, + crate::mcp_server::read_port_from_conn(conn), + )); // 3 lefthook hook 真挂在 .git/hooks let (pass, detail) = match read(".git/hooks/pre-commit") { @@ -1135,6 +1204,67 @@ mod tests { assert!(outdated_pack.detail.contains("重跑接入包可升级")); } + fn runtime_layer( + state: crate::commands::settings::AgentRuntimeState, + detail: &str, + ) -> crate::commands::settings::AgentRuntimeLayer { + crate::commands::settings::AgentRuntimeLayer { + state, + detail: detail.to_string(), + required: None, + } + } + + #[test] + fn onboarding_summary_requires_runtime_readiness() { + use crate::commands::settings::{AgentRuntimeLayer, AgentRuntimeState, AgentRuntimeStatus}; + + let checks = vec![VerifyCheck::from_pass( + "Codex MCP 已配置", + true, + "静态配置存在", + )]; + let unavailable = AgentRuntimeStatus { + project_id: "p1".to_string(), + ready: false, + configured: runtime_layer(AgentRuntimeState::Ready, "静态配置存在"), + reachable: runtime_layer(AgentRuntimeState::Unavailable, "connection refused"), + handshake: runtime_layer(AgentRuntimeState::Unavailable, "未执行 initialize"), + tools_ready: runtime_layer(AgentRuntimeState::Unavailable, "未执行 tools/list"), + reload_required: AgentRuntimeLayer { + state: AgentRuntimeState::Ready, + detail: "无重载证据".to_string(), + required: Some(false), + }, + }; + + let report = summarize_onboarding("p1", checks.clone(), unavailable); + assert!(!report.pass, "配置存在但端口拒绝时不得整体假报 ready"); + assert!(report.summary.contains("runtime 未就绪")); + assert_eq!( + report.runtime.reachable.state, + AgentRuntimeState::Unavailable + ); + + let ready_layer = || runtime_layer(AgentRuntimeState::Ready, "ready"); + let ready = AgentRuntimeStatus { + project_id: "p1".to_string(), + ready: true, + configured: ready_layer(), + reachable: ready_layer(), + handshake: ready_layer(), + tools_ready: ready_layer(), + reload_required: AgentRuntimeLayer { + state: AgentRuntimeState::Ready, + detail: "无需重载".to_string(), + required: Some(false), + }, + }; + let report = summarize_onboarding("p1", checks, ready); + assert!(report.pass); + assert!(report.summary.contains("runtime ready")); + } + #[test] fn verify_onboarding_full_chain() { let conn = crate::db::conn_with_schema(); @@ -1159,7 +1289,7 @@ mod tests { std::fs::create_dir_all(base.join(".codex")).unwrap(); std::fs::write( base.join(".codex/config.toml"), - "[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp\"\n", + "[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp/projects/p1\"\n", ) .unwrap(); std::fs::create_dir_all(base.join(".git/hooks")).unwrap(); @@ -1230,6 +1360,60 @@ mod tests { check_skill(Some(TMPL_LIANJING_SKILL)).0, "当前接入包 skill 必须使用 canonical context" ); + for instruction in ["configured", "reachable", "reload"] { + assert!( + TMPL_LIANJING_SKILL.contains(instruction), + "Skill 必须包含 {instruction} 降级层" + ); + } + assert!( + TMPL_LIANJING_SKILL.contains("/mcp/projects/"), + "Skill 必须声明 scoped MCP URL" + ); + assert!( + TMPL_LIANJING_SKILL.contains("不要") && TMPL_LIANJING_SKILL.contains("复制凭证"), + "Skill 必须阻止通过复制凭证绕过 readiness" + ); + } + + #[test] + fn codex_mcp_check_distinguishes_scoped_legacy_and_wrong_project() { + let scoped = check_codex_mcp( + Some("[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp/projects/p%201\"\n"), + "p 1", + 27190, + ); + assert_eq!(scoped.state, VerifyState::Ready); + + let legacy = check_codex_mcp( + Some("[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp\"\n"), + "p1", + 27190, + ); + assert_eq!(legacy.state, VerifyState::Legacy); + assert!(legacy.detail.contains("inject_mcp")); + assert!(legacy.detail.contains("不会被覆盖")); + + let wrong_project = check_codex_mcp( + Some("[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp/projects/p2\"\n"), + "p1", + 27190, + ); + assert_eq!(wrong_project.state, VerifyState::Missing); + + let prefix_collision = check_codex_mcp( + Some("[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp/projects/p10\"\n"), + "p1", + 27190, + ); + assert_eq!(prefix_collision.state, VerifyState::Missing); + + let wrong_port = check_codex_mcp( + Some("[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:28000/mcp/projects/p1\"\n"), + "p1", + 27190, + ); + assert_eq!(wrong_port.state, VerifyState::Missing); } /// 回归:空路径登记曾把接入包写进炼境进程 cwd(src-tauri/,2026-07-15 批量实调实锤)。 diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 06a8537..eb29ffd 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -3,6 +3,15 @@ use rusqlite::params; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tauri_plugin_autostart::ManagerExt; + +const CORE_AGENT_TOOLS: &[&str] = &[ + "get_diagnostics", + "get_delivery_status", + "get_toolchain_status", + "verify_onboarding", +]; /// 全局 key(不按空间隔离,属于整个应用/机器级别配置) const GLOBAL_KEYS: &[&str] = &[ @@ -241,7 +250,7 @@ pub fn search_editor_path(cmd: String) -> Result, String> { #[tauri::command] pub fn check_mcp_health() -> Result { let port = crate::mcp_server::read_port(); - let url = format!("http://localhost:{}/health", port); + let url = crate::mcp_server::mcp_health_url(port); let start = std::time::Instant::now(); match ureq::builder() .timeout(std::time::Duration::from_secs(2)) @@ -257,6 +266,448 @@ pub fn check_mcp_health() -> Result { } } +// ── Agent runtime readiness ────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentRuntimeState { + Ready, + Missing, + Unavailable, + ProtocolError, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct AgentRuntimeLayer { + pub state: AgentRuntimeState, + pub detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option, +} + +impl AgentRuntimeLayer { + fn new(state: AgentRuntimeState, detail: impl Into) -> Self { + Self { + state, + detail: detail.into(), + required: None, + } + } + + fn reload(required: bool, detail: impl Into) -> Self { + Self { + state: if required { + AgentRuntimeState::Missing + } else { + AgentRuntimeState::Ready + }, + detail: detail.into(), + required: Some(required), + } + } + + fn is_ready(&self) -> bool { + self.state == AgentRuntimeState::Ready + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct AgentRuntimeStatus { + pub project_id: String, + pub ready: bool, + pub configured: AgentRuntimeLayer, + pub reachable: AgentRuntimeLayer, + pub handshake: AgentRuntimeLayer, + pub tools_ready: AgentRuntimeLayer, + pub reload_required: AgentRuntimeLayer, +} + +#[derive(Debug, Clone)] +struct AgentRuntimeEvidence { + configured: AgentRuntimeLayer, + reachable: Result, + handshake: Result<(), String>, + tool_names: Result, String>, + reload_required: bool, +} + +fn build_agent_runtime_status( + project_id: &str, + evidence: AgentRuntimeEvidence, +) -> AgentRuntimeStatus { + let reachable = match evidence.reachable { + Ok(detail) => AgentRuntimeLayer::new(AgentRuntimeState::Ready, detail), + Err(detail) => AgentRuntimeLayer::new(AgentRuntimeState::Unavailable, detail), + }; + let handshake = if !reachable.is_ready() { + AgentRuntimeLayer::new( + AgentRuntimeState::Unavailable, + "MCP 端口不可达,尚未执行 initialize;请先启动或重启炼境", + ) + } else { + match evidence.handshake { + Ok(()) => AgentRuntimeLayer::new(AgentRuntimeState::Ready, "MCP initialize 握手成功"), + Err(detail) => AgentRuntimeLayer::new(AgentRuntimeState::ProtocolError, detail), + } + }; + let tools_ready = if !handshake.is_ready() { + AgentRuntimeLayer::new( + AgentRuntimeState::Unavailable, + "initialize 未就绪,尚未读取 tools/list", + ) + } else { + match evidence.tool_names { + Ok(names) => { + let missing = CORE_AGENT_TOOLS + .iter() + .filter(|required| !names.iter().any(|name| name == **required)) + .copied() + .collect::>(); + if missing.is_empty() { + AgentRuntimeLayer::new( + AgentRuntimeState::Ready, + format!("核心工具已就绪:{}", CORE_AGENT_TOOLS.join(", ")), + ) + } else { + AgentRuntimeLayer::new( + AgentRuntimeState::Missing, + format!( + "MCP 可握手,但缺少核心工具:{};请升级或重启炼境", + missing.join(", ") + ), + ) + } + } + Err(detail) => AgentRuntimeLayer::new(AgentRuntimeState::ProtocolError, detail), + } + }; + let reload_required = AgentRuntimeLayer::reload( + evidence.reload_required, + if evidence.reload_required { + "项目 MCP 配置已变化;请新建 Codex 任务或重载客户端后再验证工具可见性" + } else { + "服务端已可接受新连接;若当前任务仍看不到工具,请新建任务或重载客户端" + }, + ); + let ready = evidence.configured.is_ready() + && reachable.is_ready() + && handshake.is_ready() + && tools_ready.is_ready() + && !evidence.reload_required; + + AgentRuntimeStatus { + project_id: project_id.to_string(), + ready, + configured: evidence.configured, + reachable, + handshake, + tools_ready, + reload_required, + } +} + +fn resolve_project_root(conn: &rusqlite::Connection, project_id: &str) -> Result { + if project_id.trim().is_empty() { + return Err("project_id 不能为空".to_string()); + } + let win_path: Option = conn + .query_row( + "SELECT win_path FROM project_workspaces WHERE id = ?1", + params![project_id], + |row| row.get(0), + ) + .map_err(|_| format!("项目 {project_id} 不存在"))?; + let configured = + win_path.ok_or_else(|| format!("项目 {project_id} 未配置 Windows 本地路径"))?; + let root = PathBuf::from(&configured); + if !root.is_absolute() || !root.is_dir() { + return Err(format!("项目 Windows 路径不可访问:{configured}")); + } + Ok(root) +} + +fn configured_layer(root: &Path) -> AgentRuntimeLayer { + let config = std::fs::read_to_string(root.join(".codex/config.toml")).ok(); + let project_id = std::fs::read_to_string(root.join(".lian-jing/context.json")) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .and_then(|value| { + value + .get("project_id") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_default(); + let check = crate::commands::onboarding::check_codex_mcp( + config.as_deref(), + &project_id, + crate::mcp_server::read_port(), + ); + AgentRuntimeLayer::new( + if check.state == crate::commands::onboarding::VerifyState::Ready { + AgentRuntimeState::Ready + } else { + AgentRuntimeState::Missing + }, + check.detail, + ) +} + +fn parse_rpc_result(response: ureq::Response, operation: &str) -> Result { + let body: Value = response + .into_json() + .map_err(|error| format!("{operation} 返回非 JSON:{error}"))?; + if let Some(error) = body.get("error") { + return Err(format!("{operation} 返回 JSON-RPC error:{error}")); + } + body.get("result") + .cloned() + .ok_or_else(|| format!("{operation} 响应缺少 result")) +} + +fn collect_runtime_evidence( + project_id: &str, + configured: AgentRuntimeLayer, +) -> AgentRuntimeEvidence { + let port = crate::mcp_server::read_port(); + let health_url = crate::mcp_server::mcp_health_url(port); + let rpc_url = crate::mcp_server::scoped_mcp_url(port, project_id); + let agent = ureq::builder() + .timeout(std::time::Duration::from_secs(2)) + .build(); + let reachable = agent + .get(&health_url) + .call() + .map(|_| format!("MCP loopback {health_url} 可达")) + .map_err(|error| format!("MCP loopback {health_url} 不可达:{error}")); + + if reachable.is_err() { + return AgentRuntimeEvidence { + configured, + reachable, + handshake: Err("未执行 initialize".to_string()), + tool_names: Err("未执行 tools/list".to_string()), + reload_required: false, + }; + } + + let initialize = agent + .post(&rpc_url) + .send_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "lian-jing-readiness", "version": env!("CARGO_PKG_VERSION") } + } + })) + .map_err(|error| format!("MCP initialize 请求失败:{error}")) + .and_then(|response| parse_rpc_result(response, "MCP initialize")) + .and_then(|result| { + if result.get("serverInfo").and_then(|value| value.get("name")).is_some() { + Ok(()) + } else { + Err("MCP initialize 响应缺少 serverInfo.name".to_string()) + } + }); + + if initialize.is_err() { + return AgentRuntimeEvidence { + configured, + reachable, + handshake: initialize, + tool_names: Err("initialize 失败,未执行 tools/list".to_string()), + reload_required: false, + }; + } + + let tool_names = agent + .post(&rpc_url) + .send_json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} + })) + .map_err(|error| format!("MCP tools/list 请求失败:{error}")) + .and_then(|response| parse_rpc_result(response, "MCP tools/list")) + .and_then(|result| { + result + .get("tools") + .and_then(Value::as_array) + .ok_or_else(|| "MCP tools/list 响应缺少 tools 数组".to_string()) + .map(|tools| { + tools + .iter() + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) + .map(str::to_string) + .collect() + }) + }); + + AgentRuntimeEvidence { + configured, + reachable, + handshake: initialize, + tool_names, + reload_required: !crate::mcp_server::has_initialized_project_client(project_id), + } +} + +/// MCP 内部调用 verify_onboarding 时,以当前成功连接作为运行态证据,避免 HTTP 自调用。 +pub(crate) fn server_observed_runtime_status(project_id: &str, root: &Path) -> AgentRuntimeStatus { + let names = crate::mcp_tools::tools_list_result() + .get("tools") + .and_then(Value::as_array) + .map(|tools| { + tools + .iter() + .filter_map(|tool| tool.get("name").and_then(Value::as_str)) + .map(str::to_string) + .collect::>() + }) + .ok_or_else(|| "内部 tools/list 缺少 tools 数组".to_string()); + + build_agent_runtime_status( + project_id, + AgentRuntimeEvidence { + configured: configured_layer(root), + reachable: Ok("当前 MCP 请求已到达 loopback listener".to_string()), + handshake: Ok(()), + tool_names: names, + reload_required: false, + }, + ) +} + +/// 独立于 MCP 工具目录的运行态自检;即使 MCP 不可达,Tauri UI 仍可给出分层证据。 +#[tauri::command] +pub fn get_agent_runtime_status(project_id: String) -> Result { + let conn = pool().get().map_err(|error| error.to_string())?; + let root = resolve_project_root(&conn, &project_id)?; + let configured = configured_layer(&root); + Ok(build_agent_runtime_status( + &project_id, + collect_runtime_evidence(&project_id, configured), + )) +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct AgentAutostartStatus { + pub supported: bool, + pub eligible: bool, + pub enabled: bool, + pub build_kind: String, + pub executable: String, + pub detail: String, +} + +fn autostart_eligibility(is_debug: bool, executable: &Path) -> (bool, String, String) { + let executable_text = executable.to_string_lossy().to_string(); + if !cfg!(target_os = "windows") { + return ( + false, + "unsupported".to_string(), + "当前仅支持 Windows 正式安装版".to_string(), + ); + } + if is_debug { + return ( + false, + "debug".to_string(), + "debug 构建拒绝启用登录自启动;请安装正式版后操作".to_string(), + ); + } + let components = executable + .components() + .map(|component| component.as_os_str().to_string_lossy().to_ascii_lowercase()) + .collect::>(); + let executable_name = format!("{}.exe", env!("CARGO_PKG_NAME")); + let expected_executable = executable + .file_name() + .and_then(|name| name.to_str()) + .map(|name| name.eq_ignore_ascii_case(&executable_name)) + .unwrap_or(false); + let has_product_directory = components + .iter() + .take(components.len().saturating_sub(1)) + .any(|component| component == env!("CARGO_PKG_NAME")); + let under_program_files = components + .iter() + .any(|component| matches!(component.as_str(), "program files" | "program files (x86)")); + let under_local_app_data = components + .windows(2) + .any(|parts| parts == ["appdata", "local"]); + let installed_location = has_product_directory && (under_program_files || under_local_app_data); + if !expected_executable || !installed_location { + return ( + false, + "release_uninstalled".to_string(), + "release 构建尚未通过正式安装器安装,拒绝注册登录自启动".to_string(), + ); + } + ( + true, + "installed_release".to_string(), + format!("正式安装版可注册当前 executable:{executable_text}"), + ) +} + +fn current_autostart_eligibility() -> Result<(bool, String, String, String), String> { + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let executable_text = executable.to_string_lossy().to_string(); + let (eligible, build_kind, detail) = autostart_eligibility(cfg!(debug_assertions), &executable); + Ok((eligible, build_kind, executable_text, detail)) +} + +#[tauri::command] +pub fn get_agent_autostart_status(app: tauri::AppHandle) -> Result { + let (eligible, build_kind, executable, detail) = current_autostart_eligibility()?; + let enabled = if eligible { + app.autolaunch() + .is_enabled() + .map_err(|error| error.to_string())? + } else { + false + }; + Ok(AgentAutostartStatus { + supported: cfg!(target_os = "windows"), + eligible, + enabled, + build_kind, + executable, + detail, + }) +} + +#[tauri::command] +pub fn set_agent_autostart( + app: tauri::AppHandle, + enabled: bool, +) -> Result { + let before = get_agent_autostart_status(app.clone())?; + if !before.eligible { + if enabled { + return Err(before.detail); + } + return Ok(before); + } + if before.enabled != enabled { + if enabled { + app.autolaunch() + .enable() + .map_err(|error| error.to_string())?; + } else { + app.autolaunch() + .disable() + .map_err(|error| error.to_string())?; + } + } + get_agent_autostart_status(app) +} + // ── 健康检查 ────────────────────────────────────────────────────────────────── #[derive(Debug, Serialize, Deserialize)] @@ -296,7 +747,7 @@ fn skip_item(id: &str, name: &str, detail: &str) -> HealthItem { /// 检测 MCP Server 是否响应 fn check_mcp() -> HealthItem { let port = crate::mcp_server::read_port(); - let url = format!("http://localhost:{}/health", port); + let url = crate::mcp_server::mcp_health_url(port); let start = std::time::Instant::now(); match ureq::builder() .timeout(std::time::Duration::from_secs(5)) @@ -766,4 +1217,118 @@ mod tests { fn settings_routing_via_db() { crate::db::conn_with_schema(); // 仅验证 schema 可正常建立,不依赖全局 pool } + + #[test] + fn mcp_health_check_uses_loopback_url() { + assert_eq!( + crate::mcp_server::mcp_health_url(27190), + "http://127.0.0.1:27190/health" + ); + } + + fn ready_evidence() -> AgentRuntimeEvidence { + AgentRuntimeEvidence { + configured: AgentRuntimeLayer::new( + AgentRuntimeState::Ready, + "lian-jing 指向本地 MCP 服务", + ), + reachable: Ok("loopback 可达".to_string()), + handshake: Ok(()), + tool_names: Ok(CORE_AGENT_TOOLS + .iter() + .map(|name| name.to_string()) + .collect()), + reload_required: false, + } + } + + #[test] + fn agent_runtime_ready_requires_all_layers() { + let status = build_agent_runtime_status("p1", ready_evidence()); + + assert!(status.ready); + assert_eq!(status.configured.state, AgentRuntimeState::Ready); + assert_eq!(status.reachable.state, AgentRuntimeState::Ready); + assert_eq!(status.handshake.state, AgentRuntimeState::Ready); + assert_eq!(status.tools_ready.state, AgentRuntimeState::Ready); + assert_eq!(status.reload_required.required, Some(false)); + } + + #[test] + fn agent_runtime_port_refusal_never_reports_ready() { + let mut evidence = ready_evidence(); + evidence.reachable = Err("connection refused".to_string()); + let status = build_agent_runtime_status("p1", evidence); + + assert!(!status.ready); + assert_eq!(status.configured.state, AgentRuntimeState::Ready); + assert_eq!(status.reachable.state, AgentRuntimeState::Unavailable); + assert_eq!(status.handshake.state, AgentRuntimeState::Unavailable); + assert_eq!(status.tools_ready.state, AgentRuntimeState::Unavailable); + } + + #[test] + fn agent_runtime_distinguishes_protocol_missing_and_reload_states() { + let mut protocol = ready_evidence(); + protocol.handshake = Err("initialize 响应缺少 result".to_string()); + let status = build_agent_runtime_status("p1", protocol); + assert_eq!(status.handshake.state, AgentRuntimeState::ProtocolError); + assert!(!status.ready); + + let mut missing = ready_evidence(); + missing.tool_names = Ok(vec!["get_diagnostics".to_string()]); + let status = build_agent_runtime_status("p1", missing); + assert_eq!(status.tools_ready.state, AgentRuntimeState::Missing); + assert!(status.tools_ready.detail.contains("verify_onboarding")); + + let mut reload = ready_evidence(); + reload.reload_required = true; + let status = build_agent_runtime_status("p1", reload); + assert_eq!(status.reload_required.state, AgentRuntimeState::Missing); + assert_eq!(status.reload_required.required, Some(true)); + assert!(!status.ready); + + let mut unconfigured = ready_evidence(); + unconfigured.configured = + AgentRuntimeLayer::new(AgentRuntimeState::Missing, ".codex/config.toml 缺失"); + let status = build_agent_runtime_status("p1", unconfigured); + assert_eq!(status.configured.state, AgentRuntimeState::Missing); + assert!(!status.ready); + } + + #[test] + fn autostart_accepts_only_installed_release_executable() { + let (eligible, kind, _) = autostart_eligibility( + true, + Path::new(r"C:\Program Files\炼境\dev-manager-tauri.exe"), + ); + assert!(!eligible); + assert_eq!(kind, "debug"); + + let (eligible, kind, _) = autostart_eligibility( + false, + Path::new( + r"E:\code-project\dev-manager-tauri\src-tauri\target\release\dev-manager-tauri.exe", + ), + ); + assert!(!eligible); + assert_eq!(kind, "release_uninstalled"); + + let (eligible, kind, _) = autostart_eligibility( + false, + Path::new(r"C:\Users\user\Downloads\dev-manager-tauri.exe"), + ); + assert!(!eligible); + assert_eq!(kind, "release_uninstalled"); + + let (eligible, kind, detail) = autostart_eligibility( + false, + Path::new( + r"C:\Users\user\AppData\Local\Programs\dev-manager-tauri\dev-manager-tauri.exe", + ), + ); + assert!(eligible); + assert_eq!(kind, "installed_release"); + assert!(detail.contains("正式安装版")); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 13d5c2e..e7bca88 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -48,8 +48,8 @@ use commands::{ proxy::*, publish::*, settings::{ - check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path, - update_settings, + check_mcp_health, detect_editors, get_agent_autostart_status, get_agent_runtime_status, + get_settings, run_health_check, search_editor_path, set_agent_autostart, update_settings, }, shell::*, spaces::*, @@ -58,6 +58,10 @@ use commands::{ toolchain::*, }; +fn has_autostart_arg(args: impl IntoIterator) -> bool { + args.into_iter().any(|arg| arg == "--autostart") +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -70,6 +74,10 @@ pub fn run() { let _ = w.set_focus(); } })) + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + Some(vec!["--autostart"]), + )) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) @@ -169,6 +177,9 @@ pub fn run() { let _ = win.hide(); } }); + if has_autostart_arg(std::env::args_os()) { + let _ = window.hide(); + } } Ok(()) @@ -201,6 +212,9 @@ pub fn run() { detect_editors, search_editor_path, check_mcp_health, + get_agent_runtime_status, + get_agent_autostart_status, + set_agent_autostart, run_health_check, // project tools get_project_tools, @@ -438,3 +452,18 @@ pub fn run() { .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[cfg(test)] +mod tests { + #[test] + fn autostart_launch_argument_is_explicit() { + assert!(super::has_autostart_arg([ + "dev-manager-tauri.exe".into(), + "--autostart".into(), + ])); + assert!(!super::has_autostart_arg([ + "dev-manager-tauri.exe".into(), + "--inspect".into(), + ])); + } +} diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 552021e..ca37f0f 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -459,7 +459,7 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> { /// 并清理旧组级 key(迁移内建于重注入) pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { let port = crate::mcp_server::read_port(); - let server_url = format!("http://127.0.0.1:{}/mcp", port); + let server_url = crate::mcp_server::scoped_mcp_url(port, project_id); write_canonical_context(project_id)?; upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url)?; let path = settings_path(project_id)?; @@ -633,6 +633,13 @@ mod tests { assert!(configured.contains("[mcp_servers.lian-jing]")); assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\"")); + upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp/projects/project%20one") + .unwrap(); + let migrated = std::fs::read_to_string(&path).unwrap(); + assert!(migrated.contains("model = \"gpt-6\"")); + assert!(migrated.contains("url = \"http://127.0.0.1:27190/mcp/projects/project%20one\"")); + assert!(!migrated.contains("url = \"http://127.0.0.1:27190/mcp\"\n")); + remove_codex_mcp_config(&path).unwrap(); assert_eq!( std::fs::read_to_string(&path).unwrap(), @@ -641,7 +648,11 @@ mod tests { let manual = "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n"; std::fs::write(&path, manual).unwrap(); - assert!(upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp").is_err()); + assert!(upsert_codex_mcp_config( + &path, + "http://127.0.0.1:27190/mcp/projects/project%20one" + ) + .is_err()); assert_eq!(std::fs::read_to_string(&path).unwrap(), manual); let _ = std::fs::remove_dir_all(&base); } diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 7e9c23a..13b9eed 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Query, State}, + extract::{Path, Query, State}, http::StatusCode, response::{ sse::{Event, KeepAlive, Sse}, @@ -10,29 +10,112 @@ use axum::{ }; use serde::Deserialize; use serde_json::{json, Value}; -use std::{collections::HashMap, convert::Infallible, net::SocketAddr, sync::Arc}; +use std::{ + collections::{HashMap, HashSet}, + convert::Infallible, + net::SocketAddr, + sync::{Arc, OnceLock, RwLock}, +}; use tokio::sync::{mpsc, Mutex}; use tokio_stream::{wrappers::ReceiverStream, StreamExt as _}; use crate::db; -/// SSE 会话表:session_id → 发送端 -type Sessions = Arc>>>; +struct SessionEntry { + sender: mpsc::Sender<(String, String)>, + project_scope: Option, +} + +/// SSE 会话表:session_id → 发送端与连接作用域 +type Sessions = Arc>>; + +const MCP_LOOPBACK_HOST: &str = "127.0.0.1"; +const MCP_SERVER_INSTRUCTIONS: &str = "先读取 get_diagnostics、verify_onboarding 或项目状态,再执行后续操作。项目级工具必须使用当前项目的 canonical project_id,不得跨项目猜测或改写身份。写操作仅在用户明确确认目标与影响后调用;状态 unavailable/unknown 不得当作成功。"; +static INITIALIZED_PROJECT_CLIENTS: OnceLock>> = OnceLock::new(); + +fn initialized_project_clients() -> &'static RwLock> { + INITIALIZED_PROJECT_CLIENTS.get_or_init(|| RwLock::new(HashSet::new())) +} + +fn record_project_client_initialize(req: &RpcRequest, project_scope: Option<&str>) { + let Some(project_id) = project_scope else { + return; + }; + let client_name = req + .params + .as_ref() + .and_then(|params| params.get("clientInfo")) + .and_then(|info| info.get("name")) + .and_then(Value::as_str) + .unwrap_or_default(); + if client_name == "lian-jing-readiness" { + return; + } + if let Ok(mut clients) = initialized_project_clients().write() { + clients.insert(project_id.to_string()); + } +} + +pub(crate) fn has_initialized_project_client(project_id: &str) -> bool { + initialized_project_clients() + .read() + .map(|clients| clients.contains(project_id)) + .unwrap_or(false) +} + +/// MCP 仅监听 IPv4 loopback,避免把本机 agent 控制面暴露到局域网。 +fn mcp_bind_addr(port: u16) -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], port)) +} + +/// 所有 MCP 对外 URL 的单一基址事实。 +fn mcp_base_url(port: u16) -> String { + format!("http://{MCP_LOOPBACK_HOST}:{port}") +} + +pub(crate) fn mcp_health_url(port: u16) -> String { + format!("{}/health", mcp_base_url(port)) +} + +fn sse_messages_url(port: u16, session_id: &str) -> String { + format!( + "{}/mcp/messages?session_id={session_id}", + mcp_base_url(port) + ) +} + +pub(crate) fn scoped_mcp_url(port: u16, project_id: &str) -> String { + format!( + "{}/mcp/projects/{}", + mcp_base_url(port), + urlencoding::encode(project_id) + ) +} + +fn scoped_sse_messages_url(port: u16, project_id: &str, session_id: &str) -> String { + format!( + "{}/messages?session_id={session_id}", + scoped_mcp_url(port, project_id) + ) +} /// 从 settings 表读取 MCP 端口,默认 27190 +pub(crate) fn read_port_from_conn(conn: &rusqlite::Connection) -> u16 { + conn.query_row( + "SELECT value FROM settings WHERE key = 'mcp_port'", + [], + |row| row.get::<_, String>(0), + ) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(27190) +} + pub fn read_port() -> u16 { db::pool() .get() .ok() - .and_then(|c| { - c.query_row( - "SELECT value FROM settings WHERE key = 'mcp_port'", - [], - |r| r.get::<_, String>(0), - ) - .ok() - }) - .and_then(|v| v.parse().ok()) + .map(|conn| read_port_from_conn(&conn)) .unwrap_or(27190) } @@ -44,8 +127,16 @@ pub async fn start(port: u16) { .route("/health", get(health)) // 单一全局服务器路由(lian-jing):寻址不依赖 group_id,工具显式传 project_id .route("/mcp", post(handle_mcp).get(handle_sse_connect)) + .route( + "/mcp/projects/:project_id", + post(handle_scoped_mcp).get(handle_scoped_sse_connect), + ) // SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回 .route("/mcp/messages", post(handle_sse_message)) + .route( + "/mcp/projects/:project_id/messages", + post(handle_scoped_sse_message), + ) // Gitea webhook:接收 push/pull_request 事件写入飞轮 .route( "/webhook/gitea/:project_id", @@ -53,7 +144,7 @@ pub async fn start(port: u16) { ) .with_state(sessions); - let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let addr = mcp_bind_addr(port); match tokio::net::TcpListener::bind(addr).await { Ok(listener) => { db::log_event( @@ -103,15 +194,37 @@ struct SessionQuery { // ── 共享 RPC 处理逻辑 ───────────────────────────────────────────────────────── -async fn process_rpc(req: &RpcRequest) -> Result { +async fn process_rpc(req: &RpcRequest, project_scope: Option<&str>) -> Result { + if req.method == "initialize" { + record_project_client_initialize(req, project_scope); + } match req.method.as_str() { "initialize" => Ok(json!({ "protocolVersion": "2024-11-05", "capabilities": { "tools": {} }, - "serverInfo": { "name": "炼境", "version": env!("CARGO_PKG_VERSION") } + "serverInfo": { "name": "炼境", "version": env!("CARGO_PKG_VERSION") }, + "instructions": MCP_SERVER_INSTRUCTIONS, + "lianJing": match project_scope { + Some(project_id) => json!({ + "scope_mode": "project", + "project_id": project_id + }), + None => json!({ + "scope_mode": "legacy", + "upgrade_hint": "重跑 inject_mcp,将项目配置升级到 /mcp/projects/:project_id" + }) + } })), - "tools/list" => Ok(crate::mcp_tools::tools_list_result()), - "tools/call" => Ok(crate::mcp_tools::tools_call(req.params.as_ref()).await), + "tools/list" => Ok(match project_scope { + Some(project_id) => crate::mcp_tools::tools_list_result_for_project(project_id), + None => crate::mcp_tools::tools_list_result(), + }), + "tools/call" => Ok(match project_scope { + Some(project_id) => { + crate::mcp_tools::tools_call_for_project(req.params.as_ref(), project_id).await + } + None => crate::mcp_tools::tools_call(req.params.as_ref()).await, + }), _ => Err(format!("Method not found: {}", req.method)), } } @@ -123,7 +236,25 @@ async fn handle_mcp(Json(req): Json) -> Response { return StatusCode::ACCEPTED.into_response(); } let id = req.id.clone(); - match process_rpc(&req).await { + match process_rpc(&req, None).await { + Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(), + Err(msg) => Json(json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32601, "message": msg } + })) + .into_response(), + } +} + +async fn handle_scoped_mcp( + Path(project_id): Path, + Json(req): Json, +) -> Response { + if req.id.is_none() { + return StatusCode::ACCEPTED.into_response(); + } + let id = req.id.clone(); + match process_rpc(&req, Some(&project_id)).await { Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(), Err(msg) => Json(json!({ "jsonrpc": "2.0", "id": id, @@ -143,12 +274,15 @@ async fn handle_sse_connect( let (tx, rx) = mpsc::channel::<(String, String)>(32); let port = read_port(); - let endpoint_uri = format!( - "http://127.0.0.1:{}/mcp/messages?session_id={}", - port, session_id - ); + let endpoint_uri = sse_messages_url(port, &session_id); let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await; - sessions.lock().await.insert(session_id, tx); + sessions.lock().await.insert( + session_id, + SessionEntry { + sender: tx, + project_scope: None, + }, + ); let stream = ReceiverStream::new(rx).map(|(evt_name, data)| { Ok::(Event::default().event(evt_name).data(data)) @@ -157,6 +291,27 @@ async fn handle_sse_connect( Sse::new(stream).keep_alive(KeepAlive::default()) } +async fn handle_scoped_sse_connect( + Path(project_id): Path, + State(sessions): State, +) -> Sse>> { + let session_id = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = mpsc::channel::<(String, String)>(32); + let endpoint_uri = scoped_sse_messages_url(read_port(), &project_id, &session_id); + let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await; + sessions.lock().await.insert( + session_id, + SessionEntry { + sender: tx, + project_scope: Some(project_id), + }, + ); + let stream = ReceiverStream::new(rx).map(|(evt_name, data)| { + Ok::(Event::default().event(evt_name).data(data)) + }); + Sse::new(stream).keep_alive(KeepAlive::default()) +} + #[cfg(test)] mod tests { use super::*; @@ -173,10 +328,17 @@ mod tests { /// 单一 lian-jing 服务器的握手路径:initialize / tools/list 必须可用 #[tokio::test] async fn process_rpc_handshake() { - let init = process_rpc(&rpc("initialize")).await.unwrap(); + let init = process_rpc(&rpc("initialize"), None).await.unwrap(); assert_eq!(init["serverInfo"]["name"], "炼境"); + let instructions = init["instructions"].as_str().unwrap(); + assert!(instructions.len() <= 512); + assert!(instructions.contains("先读取")); + assert!(instructions.contains("项目")); + assert!(instructions.contains("用户明确确认")); + assert_eq!(init["lianJing"]["scope_mode"], "legacy"); + assert!(init["lianJing"]["upgrade_hint"].is_string()); - let tools = process_rpc(&rpc("tools/list")).await.unwrap(); + let tools = process_rpc(&rpc("tools/list"), None).await.unwrap(); let list = tools["tools"].as_array().unwrap(); assert!( list.len() >= 20, @@ -184,10 +346,60 @@ mod tests { list.len() ); assert!( - list.iter().any(|tool| tool["name"] == "get_delivery_status"), + list.iter() + .any(|tool| tool["name"] == "get_delivery_status"), "MCP 握手后的工具清单应暴露 get_delivery_status" ); } + + #[tokio::test] + async fn scoped_handshake_binds_project_and_filters_global_tools() { + let init = process_rpc(&rpc("initialize"), Some("p1")).await.unwrap(); + assert_eq!(init["lianJing"]["scope_mode"], "project"); + assert_eq!(init["lianJing"]["project_id"], "p1"); + + let tools = process_rpc(&rpc("tools/list"), Some("p1")).await.unwrap(); + let list = tools["tools"].as_array().unwrap(); + assert!(list + .iter() + .any(|tool| tool["name"] == "get_delivery_status")); + assert!(!list.iter().any(|tool| tool["name"] == "unregister_project")); + } + + #[tokio::test] + async fn readiness_probe_does_not_satisfy_client_reload_evidence() { + let project_id = "reload-evidence-fixture"; + let mut readiness = rpc("initialize"); + readiness.params = Some(json!({ + "clientInfo": { "name": "lian-jing-readiness", "version": "test" } + })); + process_rpc(&readiness, Some(project_id)).await.unwrap(); + assert!(!has_initialized_project_client(project_id)); + + let mut codex = rpc("initialize"); + codex.params = Some(json!({ + "clientInfo": { "name": "codex", "version": "test" } + })); + process_rpc(&codex, Some(project_id)).await.unwrap(); + assert!(has_initialized_project_client(project_id)); + } + + #[test] + fn loopback_urls_share_one_ipv4_base() { + let port = 27190; + let session_id = "test-session"; + + assert_eq!(mcp_bind_addr(port).to_string(), "127.0.0.1:27190"); + assert_eq!(mcp_health_url(port), "http://127.0.0.1:27190/health"); + assert_eq!( + scoped_mcp_url(port, "project one"), + "http://127.0.0.1:27190/mcp/projects/project%20one" + ); + assert_eq!( + sse_messages_url(port, session_id), + "http://127.0.0.1:27190/mcp/messages?session_id=test-session" + ); + } } /// POST /mcp/messages?session_id=xxx @@ -204,7 +416,7 @@ async fn handle_sse_message( let session_id = query.session_id.clone(); tokio::spawn(async move { let id = req.id.clone(); - let response_json = match process_rpc(&req).await { + let response_json = match process_rpc(&req, None).await { Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), Err(msg) => json!({ "jsonrpc": "2.0", "id": id, @@ -212,8 +424,12 @@ async fn handle_sse_message( }), }; let mut guard = sessions.lock().await; - if let Some(tx) = guard.get(&session_id) { - if tx + if let Some(entry) = guard.get(&session_id) { + if entry.project_scope.is_some() { + return; + } + if entry + .sender .send(("message".to_string(), response_json.to_string())) .await .is_err() @@ -225,3 +441,46 @@ async fn handle_sse_message( StatusCode::ACCEPTED.into_response() } + +async fn handle_scoped_sse_message( + Path(project_id): Path, + State(sessions): State, + Query(query): Query, + Json(req): Json, +) -> Response { + if req.id.is_none() { + return StatusCode::ACCEPTED.into_response(); + } + let session_id = query.session_id.clone(); + let scope_matches = sessions + .lock() + .await + .get(&session_id) + .and_then(|entry| entry.project_scope.as_deref()) + == Some(project_id.as_str()); + if !scope_matches { + return StatusCode::FORBIDDEN.into_response(); + } + tokio::spawn(async move { + let id = req.id.clone(); + let response_json = match process_rpc(&req, Some(&project_id)).await { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err(msg) => json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32601, "message": msg } + }), + }; + let mut guard = sessions.lock().await; + if let Some(entry) = guard.get(&session_id) { + if entry + .sender + .send(("message".to_string(), response_json.to_string())) + .await + .is_err() + { + guard.remove(&session_id); + } + } + }); + StatusCode::ACCEPTED.into_response() +} diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 32284af..0468ef5 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -5,7 +5,7 @@ use crate::db; // ── 工具 Schema 定义 ────────────────────────────────────────────────────────── pub fn tools_list_result() -> Value { - json!({ + let mut result = json!({ "tools": [ { "name": "get_diagnostics", @@ -376,24 +376,223 @@ pub fn tools_list_result() -> Value { } } ] + }); + apply_tool_annotations(&mut result); + result +} + +fn is_project_scoped_tool(tool: &Value) -> bool { + let name = tool.get("name").and_then(Value::as_str).unwrap_or_default(); + if name == "get_diagnostics" { + return true; + } + if matches!(name, "unregister_project" | "setup_gitea_repo") { + return false; + } + tool.get("inputSchema") + .and_then(|schema| schema.get("properties")) + .and_then(|properties| properties.get("project_id")) + .is_some() +} + +pub fn tools_list_result_for_project(project_id: &str) -> Value { + let mut result = tools_list_result(); + let Some(tools) = result.get_mut("tools").and_then(Value::as_array_mut) else { + return result; + }; + tools.retain(is_project_scoped_tool); + for tool in tools { + let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) else { + continue; + }; + if let Some(project_property) = schema + .get_mut("properties") + .and_then(Value::as_object_mut) + .and_then(|properties| properties.get_mut("project_id")) + .and_then(Value::as_object_mut) + { + project_property.insert("const".to_string(), json!(project_id)); + project_property.insert( + "description".to_string(), + json!(format!( + "当前连接已绑定 project_id={project_id};可省略,传入其他值会被拒绝" + )), + ); + } + if let Some(required) = schema.get_mut("required").and_then(Value::as_array_mut) { + required.retain(|field| field.as_str() != Some("project_id")); + } + } + result +} + +fn scope_violation(detail: impl Into) -> Value { + json!({ + "content": [{ "type": "text", "text": detail.into() }], + "isError": true, + "structuredContent": { "code": "scope_violation" } }) } +fn bind_project_scope(tool_name: &str, args: &mut Value, project_id: &str) -> Result<(), String> { + let catalog = tools_list_result_for_project(project_id); + let allowed = catalog["tools"] + .as_array() + .map(|tools| tools.iter().any(|tool| tool["name"] == tool_name)) + .unwrap_or(false); + if !allowed { + return Err(format!( + "scope_violation: 工具 {tool_name} 不允许通过项目作用域连接调用" + )); + } + let object = args + .as_object_mut() + .ok_or_else(|| "scope_violation: arguments 必须是 JSON 对象".to_string())?; + if let Some(explicit) = object.get("project_id").and_then(Value::as_str) { + if explicit != project_id { + return Err(format!( + "scope_violation: 当前连接绑定 project_id={project_id},拒绝访问 project_id={explicit}" + )); + } + } + object.insert("project_id".to_string(), json!(project_id)); + Ok(()) +} + +fn apply_tool_annotations(result: &mut Value) { + let Some(tools) = result.get_mut("tools").and_then(Value::as_array_mut) else { + return; + }; + for tool in tools { + let name = tool.get("name").and_then(Value::as_str).unwrap_or_default(); + let read_only = name.starts_with("get_") + || name.starts_with("list_") + || name.starts_with("scan_") + || name.starts_with("plan_") + || name.starts_with("predict_") + || name == "verify_onboarding" + || name == "generate_blueprint_prompt"; + let destructive = matches!( + name, + "unregister_project" + | "merge_pull_request" + | "apply_cicd_workflow" + | "distribute_cicd" + | "prepare_toolchain" + | "setup_gitea_repo" + | "create_pull_request" + ); + let idempotent = read_only + || matches!( + name, + "inject_mcp" + | "apply_onboarding_pack" + | "setup_webhook" + | "sync_blueprint_rules" + | "backfill_notes_archive" + | "ingest_git_history" + ); + let open_world = matches!( + name, + "create_pull_request" + | "get_ci_status" + | "get_delivery_status" + | "merge_pull_request" + | "setup_webhook" + | "setup_gitea_repo" + ); + if let Some(object) = tool.as_object_mut() { + object.insert( + "annotations".to_string(), + json!({ + "readOnlyHint": read_only, + "destructiveHint": destructive, + "idempotentHint": idempotent, + "openWorldHint": open_world + }), + ); + } + } +} + +fn structured_tool_summary(tool_name: &str, value: &Value) -> String { + match tool_name { + "get_delivery_status" => format!( + "交付状态:project_id={},Git={},PR={},CI={},review={},deployment={}", + value["project_id"].as_str().unwrap_or("?"), + value["git"]["state"].as_str().unwrap_or("unknown"), + value["pull_request"]["state"].as_str().unwrap_or("unknown"), + value["ci"]["state"].as_str().unwrap_or("unknown"), + value["review"]["state"].as_str().unwrap_or("unknown"), + value["deployment"]["state"].as_str().unwrap_or("unknown") + ), + "get_toolchain_status" => format!( + "工具链状态:project_id={},Node={},pnpm={},resolver={},冲突={} 项", + value["project_id"].as_str().unwrap_or("?"), + value["node"]["status"].as_str().unwrap_or("unknown"), + value["pnpm"]["status"].as_str().unwrap_or("unknown"), + value["resolver"]["status"].as_str().unwrap_or("unknown"), + value["conflicts"].as_array().map(Vec::len).unwrap_or(0) + ), + "verify_onboarding" => value["summary"] + .as_str() + .unwrap_or("接入验收结构化结果已返回") + .to_string(), + _ => "结构化结果已返回".to_string(), + } +} + +fn tool_success_response(tool_name: &str, text: String) -> Value { + const STRUCTURED_TOOLS: &[&str] = &[ + "get_delivery_status", + "get_toolchain_status", + "verify_onboarding", + ]; + if STRUCTURED_TOOLS.contains(&tool_name) { + if let Ok(structured) = serde_json::from_str::(&text) { + let summary = structured_tool_summary(tool_name, &structured); + return json!({ + "content": [{ "type": "text", "text": summary }], + "structuredContent": structured + }); + } + } + json!({ "content": [{ "type": "text", "text": text }] }) +} + // ── 工具分发 ────────────────────────────────────────────────────────────────── pub async fn tools_call(params: Option<&Value>) -> Value { + tools_call_with_scope(params, None).await +} + +pub async fn tools_call_for_project(params: Option<&Value>, project_id: &str) -> Value { + tools_call_with_scope(params, Some(project_id.to_string())).await +} + +async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option) -> Value { let tool_name = params .and_then(|p| p.get("name")) .and_then(|n| n.as_str()) .unwrap_or("") .to_string(); - let args = params + let mut args = params .and_then(|p| p.get("arguments")) .cloned() .unwrap_or(json!({})); + if let Some(project_id) = project_scope.as_deref() { + if let Err(detail) = bind_project_scope(&tool_name, &mut args, project_id) { + return scope_violation(detail); + } + } + let response_tool_name = tool_name.clone(); + let scope_for_call = project_scope.clone(); let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() { - "get_diagnostics" => get_diagnostics(), + "get_diagnostics" => match scope_for_call.as_deref() { + Some(project_id) => get_scoped_diagnostics(project_id), + None => get_diagnostics(), + }, "list_group_projects" => { let group_id = args .get("group_id") @@ -903,13 +1102,10 @@ pub async fn tools_call(params: Option<&Value>) -> Value { let root = resolve_project_root(pid)?; let conn = db::pool().get().map_err(|e| e.to_string())?; let checks = crate::commands::onboarding::verify_onboarding(&conn, pid, &root); - let passed = checks.iter().filter(|c| c.pass).count(); - serde_json::to_string_pretty(&json!({ - "project_id": pid, - "pass": passed == checks.len(), - "summary": format!("{passed}/{} 项通过", checks.len()), - "checks": checks, - })) + let runtime = crate::commands::settings::server_observed_runtime_status(pid, &root); + let report = + crate::commands::onboarding::summarize_onboarding(pid, checks, runtime); + serde_json::to_string_pretty(&report) .map_err(|e| e.to_string()) } _ => Err(format!("未知工具: {}", tool_name)), @@ -917,7 +1113,7 @@ pub async fn tools_call(params: Option<&Value>) -> Value { .await; match outcome { - Ok(Ok(text)) => json!({ "content": [{ "type": "text", "text": text }] }), + Ok(Ok(text)) => tool_success_response(&response_tool_name, text), Ok(Err(e)) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }), Err(e) => { json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true }) @@ -1087,6 +1283,18 @@ fn get_diagnostics() -> Result { Ok(out) } +fn get_scoped_diagnostics(project_id: &str) -> Result { + let root = resolve_project_root(project_id)?; + let runtime = crate::commands::settings::server_observed_runtime_status(project_id, &root); + serde_json::to_string_pretty(&json!({ + "project_id": project_id, + "scope": "project", + "runtime": runtime, + "detail": "当前诊断仅包含绑定项目;全局项目清单与其他项目状态已隔离" + })) + .map_err(|error| error.to_string()) +} + fn list_group_projects(group_id: &str) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; let mut stmt = conn @@ -1759,9 +1967,119 @@ mod tests { tool["inputSchema"].is_object(), "工具 {name} 缺少 inputSchema" ); + assert!( + tool["annotations"].is_object(), + "工具 {name} 缺少风险 annotations" + ); } } + #[test] + fn core_status_tools_are_read_only_and_destructive_tools_are_marked() { + let result = tools_list_result(); + let tools = result["tools"].as_array().unwrap(); + for name in [ + "get_diagnostics", + "get_delivery_status", + "get_toolchain_status", + "verify_onboarding", + ] { + let tool = tools.iter().find(|tool| tool["name"] == name).unwrap(); + assert_eq!(tool["annotations"]["readOnlyHint"], true, "{name}"); + assert_eq!(tool["annotations"]["destructiveHint"], false, "{name}"); + assert_eq!(tool["annotations"]["idempotentHint"], true, "{name}"); + } + let delivery = tools + .iter() + .find(|tool| tool["name"] == "get_delivery_status") + .unwrap(); + assert_eq!(delivery["annotations"]["openWorldHint"], true); + for name in ["merge_pull_request", "unregister_project"] { + let tool = tools.iter().find(|tool| tool["name"] == name).unwrap(); + assert_eq!(tool["annotations"]["destructiveHint"], true, "{name}"); + assert_eq!(tool["annotations"]["readOnlyHint"], false, "{name}"); + } + } + + #[test] + fn core_status_response_contains_summary_and_structured_content() { + let response = tool_success_response( + "get_delivery_status", + json!({ + "project_id": "p1", + "git": { "state": "clean" }, + "pull_request": { "state": "open" }, + "ci": { "state": "passed" }, + "review": { "state": "approved" }, + "deployment": { "state": "unknown" } + }) + .to_string(), + ); + assert_eq!(response["structuredContent"]["project_id"], "p1"); + let text = response["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("Git=clean")); + assert!(text.contains("CI=passed")); + } + + #[test] + fn scoped_catalog_binds_project_id_and_hides_global_management() { + let result = tools_list_result_for_project("p1"); + let tools = result["tools"].as_array().unwrap(); + let delivery = tools + .iter() + .find(|tool| tool["name"] == "get_delivery_status") + .unwrap(); + assert_eq!( + delivery["inputSchema"]["properties"]["project_id"]["const"], + "p1" + ); + assert!(!delivery["inputSchema"]["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == "project_id")); + assert!(!tools + .iter() + .any(|tool| tool["name"] == "unregister_project")); + assert!(!tools.iter().any(|tool| tool["name"] == "list_all_projects")); + } + + #[tokio::test] + async fn scoped_calls_reject_cross_project_and_global_write_before_io() { + let mismatch = tools_call_for_project( + Some(&json!({ + "name": "get_delivery_status", + "arguments": { "project_id": "p2" } + })), + "p1", + ) + .await; + assert_eq!(mismatch["isError"], true); + assert_eq!(mismatch["structuredContent"]["code"], "scope_violation"); + assert!(mismatch["content"][0]["text"] + .as_str() + .unwrap() + .contains("绑定 project_id=p1")); + + let global_write = tools_call_for_project( + Some(&json!({ + "name": "unregister_project", + "arguments": { "project_id": "p1" } + })), + "p1", + ) + .await; + assert_eq!(global_write["isError"], true); + assert_eq!(global_write["structuredContent"]["code"], "scope_violation"); + } + + #[test] + fn scoped_call_injects_omitted_project_id() { + let mut args = json!({}); + bind_project_scope("get_delivery_status", &mut args, "p1").unwrap(); + assert_eq!(args["project_id"], "p1"); + } + #[test] fn merge_pull_request_schema_requires_confirmation_and_expected_sha() { let result = tools_list_result(); diff --git a/src/components/settings/SettingsPage.test.ts b/src/components/settings/SettingsPage.test.ts new file mode 100644 index 0000000..c5025e9 --- /dev/null +++ b/src/components/settings/SettingsPage.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import type { AgentRuntimeStatus, AgentRuntimeState } from "../../lib/commands"; +import { getAgentRuntimePresentation } from "./SettingsPage"; + +const layer = (state: AgentRuntimeState, detail = state) => ({ state, detail }); + +function status(overrides: Partial = {}): AgentRuntimeStatus { + return { + project_id: "p1", + ready: true, + configured: layer("ready"), + reachable: layer("ready"), + handshake: layer("ready"), + tools_ready: layer("ready"), + reload_required: { ...layer("ready"), required: false }, + ...overrides, + }; +} + +describe("getAgentRuntimePresentation", () => { + it.each([ + [status({ configured: layer("missing") }), "未配置", "注入 MCP"], + [status({ reachable: layer("unavailable") }), "炼境未运行", "托盘运行"], + [status({ handshake: layer("protocol_error") }), "协议或工具异常", "重启或升级"], + [status({ reload_required: { ...layer("missing"), required: true } }), "需重载 Codex", "新建 Codex 任务"], + [status(), "Agent 已就绪", "无需操作"], + ])("为每种阻塞状态给出唯一行动", (runtime, label, action) => { + const view = getAgentRuntimePresentation(runtime); + expect(view.label).toBe(label); + expect(view.action).toContain(action); + }); +}); diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 2ed44ba..f675547 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -6,11 +6,36 @@ import { relaunch } from "@tauri-apps/plugin-process"; import { getVersion } from "@tauri-apps/api/app"; import { detectEditors, getSettings, getSpaces, searchEditorPath, updateSettings, getWslDistros, - getProjects, syncBlueprintRules, checkMcpHealth, - type EditorCandidate, type SyncResult, type McpHealthResult, + getProjects, syncBlueprintRules, checkMcpHealth, getAgentRuntimeStatus, + getAgentAutostartStatus, setAgentAutostart, + type EditorCandidate, type SyncResult, type McpHealthResult, type AgentRuntimeStatus, } from "../../lib/commands"; // GitHub OAuth 配置已移至侧边栏「应用设置」 import { useUIStore } from "../../store/ui"; + +export interface AgentRuntimePresentation { + tone: "green" | "amber" | "red"; + label: string; + action: string; + detail: string; +} + +export function getAgentRuntimePresentation(status: AgentRuntimeStatus): AgentRuntimePresentation { + if (status.configured.state !== "ready") { + return { tone: "amber", label: "未配置", action: "先对该项目执行「注入 MCP」", detail: status.configured.detail }; + } + if (status.reachable.state !== "ready") { + return { tone: "red", label: "炼境未运行", action: "保持炼境托盘运行,然后重新检查", detail: status.reachable.detail }; + } + if (status.handshake.state !== "ready" || status.tools_ready.state !== "ready") { + const layer = status.handshake.state !== "ready" ? status.handshake : status.tools_ready; + return { tone: "red", label: "协议或工具异常", action: "重启或升级炼境,然后重新检查", detail: layer.detail }; + } + if (status.reload_required.required) { + return { tone: "amber", label: "需重载 Codex", action: "新建 Codex 任务或重载客户端", detail: status.reload_required.detail }; + } + return { tone: "green", label: "Agent 已就绪", action: "无需操作", detail: status.tools_ready.detail }; +} import { Dialog } from "../ui/Dialog"; import { GiteaInstancesSection } from "../dashboard/GiteaPanel"; @@ -184,6 +209,19 @@ export function SettingsPage() { queryFn: () => getSettings(spaceId), enabled: spaces.length > 0, }); + const { data: projects = [] } = useQuery({ + queryKey: ["projects", spaceId, "agent-runtime"], + queryFn: () => getProjects(undefined, spaceId), + enabled: spaces.length > 0, + }); + const { + data: autostartStatus, + isFetching: autostartChecking, + refetch: refreshAutostart, + } = useQuery({ + queryKey: ["agent-autostart"], + queryFn: getAgentAutostartStatus, + }); const [editors, setEditors] = useState([]); const [activeIds, setActiveIds] = useState([]); // 多选 @@ -196,6 +234,16 @@ export function SettingsPage() { const [mcpPort, setMcpPort] = useState("27190"); const [mcpHealth, setMcpHealth] = useState(null); const [mcpChecking, setMcpChecking] = useState(false); + const [runtimeProjectId, setRuntimeProjectId] = useState(""); + const [runtimeStatus, setRuntimeStatus] = useState(null); + const [runtimeChecking, setRuntimeChecking] = useState(false); + const [autostartSaving, setAutostartSaving] = useState(false); + + useEffect(() => { + if (!runtimeProjectId && projects.length > 0) { + setRuntimeProjectId(projects.find((project) => project.win_path)?.id ?? projects[0].id); + } + }, [projects, runtimeProjectId]); useEffect(() => { if (settings) { @@ -284,6 +332,34 @@ export function SettingsPage() { pingMcp(); }; + const checkAgentRuntime = async () => { + if (!runtimeProjectId) return; + setRuntimeChecking(true); + try { + setRuntimeStatus(await getAgentRuntimeStatus(runtimeProjectId)); + } catch (error) { + showToast(`Agent 连接检查失败:${String(error)}`); + setRuntimeStatus(null); + } finally { + setRuntimeChecking(false); + } + }; + + const toggleAgentAutostart = async (enabled: boolean) => { + setAutostartSaving(true); + try { + await setAgentAutostart(enabled); + await refreshAutostart(); + showToast(enabled ? "已启用 Windows 登录自启动 ✓" : "已关闭 Windows 登录自启动 ✓"); + } catch (error) { + showToast(`自启动设置失败:${String(error)}`); + } finally { + setAutostartSaving(false); + } + }; + + const runtimeView = runtimeStatus ? getAgentRuntimePresentation(runtimeStatus) : null; + const detectWsl = async () => { setDetectingWsl(true); setWslError(""); @@ -509,9 +585,7 @@ export function SettingsPage() {

- 产品组 MCP Server,为组内项目的 Claude 提供跨项目上下文能力。配置好产品组成员后,每个项目的 - {" "}.claude/settings.json{" "} - 会自动注入对应的 MCP 连接。 + 本机 Agent 控制面仅监听 127.0.0.1;项目配置使用 scoped URL 绑定自身 project_id,避免跨项目误操作。

@@ -532,12 +606,81 @@ export function SettingsPage() {

- MCP 地址(单一全局服务器): + MCP 地址(项目模板): - http://localhost:{mcpPort}/mcp + http://127.0.0.1:{mcpPort}/mcp/projects/<project_id>

+ +
+
+
+
Windows 登录自启动
+

+ {autostartChecking + ? "正在读取…" + : autostartStatus?.detail ?? "状态不可用"} +

+ {autostartStatus && !autostartStatus.eligible && ( +

仅正式安装版可启用;debug 与 target/release 直接运行均被拒绝。

+ )} +
+ +
+ +
+
+

Agent 项目连接

+

逐层检查配置、端口、握手、核心工具与客户端重载提示。

+
+ +
+ + {runtimeView && ( +
+
{runtimeView.label}
+
{runtimeView.detail}
+
建议:{runtimeView.action}
+
+ )} +
{/* 编辑器表单弹窗 */} diff --git a/src/lib/commands.ts b/src/lib/commands.ts index d83808f..0833579 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -153,6 +153,42 @@ export interface McpHealthResult { export const checkMcpHealth = () => invoke("check_mcp_health"); +export type AgentRuntimeState = "ready" | "missing" | "unavailable" | "protocol_error"; + +export interface AgentRuntimeLayer { + state: AgentRuntimeState; + detail: string; + required?: boolean; +} + +export interface AgentRuntimeStatus { + project_id: string; + ready: boolean; + configured: AgentRuntimeLayer; + reachable: AgentRuntimeLayer; + handshake: AgentRuntimeLayer; + tools_ready: AgentRuntimeLayer; + reload_required: AgentRuntimeLayer; +} + +export const getAgentRuntimeStatus = (projectId: string) => + invoke("get_agent_runtime_status", { projectId }); + +export interface AgentAutostartStatus { + supported: boolean; + eligible: boolean; + enabled: boolean; + build_kind: "debug" | "release_uninstalled" | "installed_release" | "unsupported"; + executable: string; + detail: string; +} + +export const getAgentAutostartStatus = () => + invoke("get_agent_autostart_status"); + +export const setAgentAutostart = (enabled: boolean) => + invoke("set_agent_autostart", { enabled }); + export interface HealthItem { id: string; name: string;