Merge pull request 'feat(agent/access): 强化项目级运行态接入' (#18) from feat/agent-access-reliability into master
All checks were successful
Push & PR Check / check (push) Successful in 57s
All checks were successful
Push & PR Check / check (push) Successful in 57s
This commit is contained in:
commit
4c113e24bd
@ -6,7 +6,7 @@ description: >
|
||||
项目自身保留业务规则、测试、部署声明与交付决策。炼境本身亦被纳入其中,
|
||||
形成可自我进化的开发智能闭环。
|
||||
iteration: 1
|
||||
updated: 2026-09-22
|
||||
updated: 2026-09-23
|
||||
agent_button_carrier: tauri
|
||||
|
||||
|
||||
@ -474,11 +474,21 @@ modules:
|
||||
- id: toolchain-capability-pack
|
||||
name: 项目工具链能力包
|
||||
area: infra
|
||||
status: done
|
||||
progress: 100
|
||||
updated: 2026-09-23
|
||||
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-22
|
||||
note: "v1 与 T8 自洽补强均已闭环;T9 正在将通用 CI 分发模板迁移为优先消费项目 `.nvmrc` / `packageManager`、缺失时兼容旧项目回退的同源 adapter。"
|
||||
position: [2500, 950]
|
||||
updated: 2026-09-23
|
||||
note: "A1-A8 已闭环;A9 已通过双项目 scoped HTTP、越界拒绝及完全退出 unavailable 验证,等待关闭窗口托盘、新任务、UI 与正式安装版 autostart human_verify。"
|
||||
position: [2750, 950]
|
||||
|
||||
edges:
|
||||
# 前端依赖
|
||||
- from: workspace-mgmt
|
||||
@ -789,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
|
||||
|
||||
160
.blueprint/modules/agent-access-reliability.md
Normal file
160
.blueprint/modules/agent-access-reliability.md
Normal file
@ -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:<port>、health=http://127.0.0.1:<port>/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 通过纯状态机 fixture 证明重复调用幂等,debug 与 target/release 路径拒绝启用,未改 pnpm-lock。路径 fixture、cargo test --lib 179/179 与 cargo check --release 通过;`mise exec -- pnpm build:local` 已生成 NSIS/MSI,NSIS SHA-256=82F05ECB0A68E5B87FCD5D50906A64A4CD07B6E9169A07CB2F20D6130FE36C12。正式安装后的注册表效果留待安装包 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;不得在本模块内提前扩张。
|
||||
@ -141,13 +141,14 @@ Enterprise System 已精确声明 Node `24.19.0` 与 pnpm `9.15.0`,而 Codex b
|
||||
- done_note: "2026-09-22:炼境自身新增 `.nvmrc` 24.19.0、packageManager pnpm@9.15.0 与最小 mise resolver。CLI 命中 mise 安装目录,typecheck 通过、Rust 151/151;真实 MCP get_toolchain_status 验收 Node/pnpm 均为 ready_managed,pnpm-lock.yaml 零改动。首次 push CI #120 精确暴露 pnpm 双版本来源,修复后 push #122 与 PR #123 均成功,PR #15 以 head 23e9c297caad897c7917776308ca35a8014941df 经安全门禁合并为 96f88a8。"
|
||||
- retrospective: "【复盘】炼境自身工具链契约闭环|任务类型: 架构调整|复杂度: M|实际轮数: 3轮(预估 1 轮)|是否返工: 是|返工原因: Gitea workflow 同时硬编码 pnpm 9 与读取 packageManager,pnpm/action-setup 拒绝双版本来源|有效策略: 本地与 MCP 后继续让真实 push/PR CI 消费契约,门禁暴露 adapter 漂移并以单一事实来源修复|遗留风险: distribute_cicd 的通用 web_ssh/tauri_oss/pr_ci 模板仍硬编码 pnpm 9 与默认 Node 20,需另立兼容旧项目的任务,不能混入“炼境自身”T8|auto_applied_rules: R02, R11, R13|rule_effectiveness: R02=有效, R11=有效, R13=有效"
|
||||
|
||||
### 🔵 T9. 通用 CI 分发模板消费项目工具链契约
|
||||
- status: in_progress
|
||||
### ✅ T9. 通用 CI 分发模板消费项目工具链契约
|
||||
- status: done
|
||||
- complexity: M
|
||||
- depends: T1. Node/pnpm 工具链契约解析器, T8. 炼境自身工具链契约闭环
|
||||
- files: src-tauri/src/commands/cicd.rs, src/components/dashboard/CicdModal.tsx, docs/tauri-cicd-guide.md, .blueprint/modules/toolchain-capability-pack.md, .blueprint/manifest.yaml
|
||||
- acceptance: "agent_verify: [cargo test] 临时项目提供 `.nvmrc` + package.json#packageManager 时,web_ssh/tauri_oss/pr_ci 模板使用 node-version-file 且 pnpm/action-setup 不重复写 version;只有部分声明时逐工具消费契约,完全缺失时保持已保存 Node 版本或 Node 20 + pnpm 9 的旧项目回退;冲突声明拒绝生成并返回证据;`cargo test --lib` 与 `pnpm typecheck` 通过,`git diff -- pnpm-lock.yaml` 为空。human_verify: CI/CD 模态框明确标注 Node 输入仅为无项目声明时的兼容回退版本。"
|
||||
- decision_note: "CI adapter 复用 parse_toolchain_contract,不复制解析规则;项目声明是第一事实源,CicdConfig 只保留旧项目回退语义。"
|
||||
- done_note: "2026-09-23:实现提交 9bf2d579 通过 PR #16 合并为 7c87b86;web_ssh、tauri_oss、pr_ci 模板统一消费项目工具链契约,旧项目回退与冲突拒绝路径保留。后续 T10 的 PR #17 也在同一 master 基线上通过 CI,蓝图状态现与 Git 事实收口。"
|
||||
|
||||
## 执行顺序
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,20 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
|
||||
- 实现者角色:炼境开发角度(导入新项目、跑通接入包分发流程)
|
||||
- 消费者角色:被管理项目角度(接入包能否真的在子项目里用起来)
|
||||
|
||||
## 本地 Agent 控制面的稳定边界
|
||||
|
||||
炼境对 Codex、Claude 等客户端提供同一份本地能力本体,客户端插件与 UI 只是不同投影:
|
||||
|
||||
- MCP 仅监听 IPv4 loopback `127.0.0.1`,不以 Agent 易用性为由暴露到局域网。
|
||||
- 项目使用 `/mcp/projects/<project_id>` 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 继续保证同一时间只有一个炼境实例。
|
||||
- 开发炼境前先从托盘完全退出正式安装版;不得把正式版与 `pnpm tauri dev` 并行运行。single-instance 是运行时兜底,不替代这一开发约定。
|
||||
- updater 是独立的发布能力,不属于自启动或 readiness 的完成条件。
|
||||
|
||||
这套 scope 是单用户本机的误操作隔离,不是多租户认证。威胁模型变化前,不向项目配置写持久 token。
|
||||
|
||||
## 三条设计原则
|
||||
|
||||
辩证结论,缺一会让体系在现实中碎裂。
|
||||
|
||||
@ -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/<project_id> 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"
|
||||
|
||||
@ -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 映射;这些都属于项目接入与炼境控制面。
|
||||
|
||||
82
src-tauri/Cargo.lock
generated
82
src-tauri/Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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"] }
|
||||
|
||||
|
||||
@ -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:<port>/mcp/projects/<canonical project_id>`;缺失或 legacy 时通过炼境重新 `inject_mcp`,手工同名 server 不覆盖。
|
||||
2. `reachable`:配置正确后检查 loopback 服务;连接拒绝表示炼境未运行,先启动正式安装版并保持托盘进程。
|
||||
3. `reload`:服务可达但当前任务看不到工具时,新建 Codex 任务或重载客户端;不要反复改配置或复制凭证。
|
||||
4. 仍不可用时,明确报告停在哪一层,继续完成不依赖 MCP 的本地工作;不得伪造诊断、项目 ID、凭证或远端状态。
|
||||
|
||||
@ -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<VerifyCheck>,
|
||||
pub runtime: crate::commands::settings::AgentRuntimeStatus,
|
||||
}
|
||||
|
||||
pub fn summarize_onboarding(
|
||||
project_id: &str,
|
||||
checks: Vec<VerifyCheck>,
|
||||
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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
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/<canonical project_id>"),
|
||||
"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 批量实调实锤)。
|
||||
|
||||
@ -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<Option<String>, String> {
|
||||
#[tauri::command]
|
||||
pub fn check_mcp_health() -> Result<serde_json::Value, String> {
|
||||
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,467 @@ pub fn check_mcp_health() -> Result<serde_json::Value, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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<bool>,
|
||||
}
|
||||
|
||||
impl AgentRuntimeLayer {
|
||||
fn new(state: AgentRuntimeState, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
state,
|
||||
detail: detail.into(),
|
||||
required: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reload(required: bool, detail: impl Into<String>) -> 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<String, String>,
|
||||
handshake: Result<(), String>,
|
||||
tool_names: Result<Vec<String>, 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::<Vec<_>>();
|
||||
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<PathBuf, String> {
|
||||
if project_id.trim().is_empty() {
|
||||
return Err("project_id 不能为空".to_string());
|
||||
}
|
||||
let win_path: Option<String> = 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::<Value>(&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<Value, String> {
|
||||
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::<Vec<_>>()
|
||||
})
|
||||
.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<AgentRuntimeStatus, String> {
|
||||
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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AutostartAction {
|
||||
RefuseEnable,
|
||||
Noop,
|
||||
Enable,
|
||||
Disable,
|
||||
}
|
||||
|
||||
fn plan_autostart_action(eligible: bool, current: bool, requested: bool) -> AutostartAction {
|
||||
if !eligible && requested {
|
||||
return AutostartAction::RefuseEnable;
|
||||
}
|
||||
if !eligible || current == requested {
|
||||
return AutostartAction::Noop;
|
||||
}
|
||||
if requested {
|
||||
AutostartAction::Enable
|
||||
} else {
|
||||
AutostartAction::Disable
|
||||
}
|
||||
}
|
||||
|
||||
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::<Vec<_>>();
|
||||
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<AgentAutostartStatus, String> {
|
||||
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<AgentAutostartStatus, String> {
|
||||
let before = get_agent_autostart_status(app.clone())?;
|
||||
match plan_autostart_action(before.eligible, before.enabled, enabled) {
|
||||
AutostartAction::RefuseEnable => return Err(before.detail),
|
||||
AutostartAction::Noop => return Ok(before),
|
||||
AutostartAction::Enable => {
|
||||
app.autolaunch()
|
||||
.enable()
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
AutostartAction::Disable => {
|
||||
app.autolaunch()
|
||||
.disable()
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
get_agent_autostart_status(app)
|
||||
}
|
||||
|
||||
// ── 健康检查 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@ -296,7 +766,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 +1236,146 @@ 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("正式安装版"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autostart_transition_is_idempotent_and_refuses_ineligible_enable() {
|
||||
assert_eq!(
|
||||
plan_autostart_action(false, false, true),
|
||||
AutostartAction::RefuseEnable
|
||||
);
|
||||
assert_eq!(
|
||||
plan_autostart_action(false, false, false),
|
||||
AutostartAction::Noop
|
||||
);
|
||||
assert_eq!(
|
||||
plan_autostart_action(true, false, false),
|
||||
AutostartAction::Noop
|
||||
);
|
||||
assert_eq!(
|
||||
plan_autostart_action(true, true, true),
|
||||
AutostartAction::Noop
|
||||
);
|
||||
assert_eq!(
|
||||
plan_autostart_action(true, false, true),
|
||||
AutostartAction::Enable
|
||||
);
|
||||
assert_eq!(
|
||||
plan_autostart_action(true, true, false),
|
||||
AutostartAction::Disable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Item = std::ffi::OsString>) -> 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(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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<Mutex<HashMap<String, mpsc::Sender<(String, String)>>>>;
|
||||
struct SessionEntry {
|
||||
sender: mpsc::Sender<(String, String)>,
|
||||
project_scope: Option<String>,
|
||||
}
|
||||
|
||||
/// SSE 会话表:session_id → 发送端与连接作用域
|
||||
type Sessions = Arc<Mutex<HashMap<String, SessionEntry>>>;
|
||||
|
||||
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<RwLock<HashSet<String>>> = OnceLock::new();
|
||||
|
||||
fn initialized_project_clients() -> &'static RwLock<HashSet<String>> {
|
||||
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<Value, String> {
|
||||
async fn process_rpc(req: &RpcRequest, project_scope: Option<&str>) -> Result<Value, String> {
|
||||
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<RpcRequest>) -> 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<String>,
|
||||
Json(req): Json<RpcRequest>,
|
||||
) -> 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, Infallible>(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<String>,
|
||||
State(sessions): State<Sessions>,
|
||||
) -> Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>> {
|
||||
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, Infallible>(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<String>,
|
||||
State(sessions): State<Sessions>,
|
||||
Query(query): Query<SessionQuery>,
|
||||
Json(req): Json<RpcRequest>,
|
||||
) -> 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()
|
||||
}
|
||||
|
||||
@ -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<String>) -> 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::<Value>(&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<String>) -> 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<String, String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn get_scoped_diagnostics(project_id: &str) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
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();
|
||||
|
||||
32
src/components/settings/SettingsPage.test.ts
Normal file
32
src/components/settings/SettingsPage.test.ts
Normal file
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@ -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<EditorEntry[]>([]);
|
||||
const [activeIds, setActiveIds] = useState<string[]>([]); // 多选
|
||||
@ -196,6 +234,16 @@ export function SettingsPage() {
|
||||
const [mcpPort, setMcpPort] = useState("27190");
|
||||
const [mcpHealth, setMcpHealth] = useState<McpHealthResult | null>(null);
|
||||
const [mcpChecking, setMcpChecking] = useState(false);
|
||||
const [runtimeProjectId, setRuntimeProjectId] = useState("");
|
||||
const [runtimeStatus, setRuntimeStatus] = useState<AgentRuntimeStatus | null>(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() {
|
||||
<button onClick={pingMcp} className="text-xs text-gray-400 hover:text-gray-600">↻</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
产品组 MCP Server,为组内项目的 Claude 提供跨项目上下文能力。配置好产品组成员后,每个项目的
|
||||
{" "}<code className="font-mono bg-gray-50 px-1 rounded">.claude/settings.json</code>{" "}
|
||||
会自动注入对应的 MCP 连接。
|
||||
本机 Agent 控制面仅监听 127.0.0.1;项目配置使用 scoped URL 绑定自身 project_id,避免跨项目误操作。
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">监听端口</label>
|
||||
@ -532,12 +606,81 @@ export function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1.5">
|
||||
MCP 地址(单一全局服务器):
|
||||
MCP 地址(项目模板):
|
||||
<code className="font-mono bg-gray-50 px-1 rounded">
|
||||
http://localhost:{mcpPort}/mcp
|
||||
http://127.0.0.1:{mcpPort}/mcp/projects/<project_id>
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 pt-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border border-gray-100 bg-gray-50 px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-gray-700">Windows 登录自启动</div>
|
||||
<p className="text-xs text-gray-400 mt-1 break-words">
|
||||
{autostartChecking
|
||||
? "正在读取…"
|
||||
: autostartStatus?.detail ?? "状态不可用"}
|
||||
</p>
|
||||
{autostartStatus && !autostartStatus.eligible && (
|
||||
<p className="text-xs text-amber-600 mt-1">仅正式安装版可启用;debug 与 target/release 直接运行均被拒绝。</p>
|
||||
)}
|
||||
</div>
|
||||
<label className={`relative inline-flex items-center shrink-0 ${
|
||||
autostartStatus?.eligible ? "cursor-pointer" : "cursor-not-allowed opacity-50"
|
||||
}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={autostartStatus?.enabled ?? false}
|
||||
disabled={!autostartStatus?.eligible || autostartSaving}
|
||||
onChange={(event) => toggleAgentAutostart(event.target.checked)}
|
||||
/>
|
||||
<span className="w-9 h-5 rounded-full bg-gray-300 peer-checked:bg-blue-600 transition-colors" />
|
||||
<span className="absolute left-0.5 top-0.5 w-4 h-4 rounded-full bg-white shadow peer-checked:translate-x-4 transition-transform" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-700">Agent 项目连接</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">逐层检查配置、端口、握手、核心工具与客户端重载提示。</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={checkAgentRuntime}
|
||||
disabled={!runtimeProjectId || runtimeChecking}
|
||||
className="px-3 py-1.5 rounded-lg text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
{runtimeChecking ? "检查中…" : "检查连接"}
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={runtimeProjectId}
|
||||
onChange={(event) => {
|
||||
setRuntimeProjectId(event.target.value);
|
||||
setRuntimeStatus(null);
|
||||
}}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
|
||||
>
|
||||
<option value="">选择项目</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>{project.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{runtimeView && (
|
||||
<div className={`rounded-lg border px-3 py-3 text-xs ${
|
||||
runtimeView.tone === "green"
|
||||
? "border-green-200 bg-green-50 text-green-800"
|
||||
: runtimeView.tone === "amber"
|
||||
? "border-amber-200 bg-amber-50 text-amber-800"
|
||||
: "border-red-200 bg-red-50 text-red-800"
|
||||
}`}>
|
||||
<div className="font-semibold">{runtimeView.label}</div>
|
||||
<div className="mt-1 break-words opacity-80">{runtimeView.detail}</div>
|
||||
<div className="mt-2 font-medium">建议:{runtimeView.action}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 编辑器表单弹窗 */}
|
||||
|
||||
@ -153,6 +153,42 @@ export interface McpHealthResult {
|
||||
|
||||
export const checkMcpHealth = () => invoke<McpHealthResult>("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<AgentRuntimeStatus>("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<AgentAutostartStatus>("get_agent_autostart_status");
|
||||
|
||||
export const setAgentAutostart = (enabled: boolean) =>
|
||||
invoke<AgentAutostartStatus>("set_agent_autostart", { enabled });
|
||||
|
||||
export interface HealthItem {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user