Compare commits

..

No commits in common. "7c62a1c148b7eafad7c4ceb321fba61eca2c72f8" and "57db32aaf03f6a20c1559528708f3b28c7fca382" have entirely different histories.

20 changed files with 99 additions and 1990 deletions

View File

@ -6,7 +6,7 @@ description: >
项目自身保留业务规则、测试、部署声明与交付决策。炼境本身亦被纳入其中,
形成可自我进化的开发智能闭环。
iteration: 1
updated: 2026-09-23
updated: 2026-09-22
agent_button_carrier: tauri
@ -474,21 +474,11 @@ 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-23
note: "A1-A8 已闭环;A9 已通过双项目 scoped HTTP、越界拒绝及完全退出 unavailable 验证,等待关闭窗口托盘、新任务、UI 与正式安装版 autostart human_verify。"
position: [2750, 950]
updated: 2026-09-22
note: "v1 与 T8 自洽补强均已闭环;T9 正在将通用 CI 分发模板迁移为优先消费项目 `.nvmrc` / `packageManager`、缺失时兼容旧项目回退的同源 adapter。"
position: [2500, 950]
edges:
# 前端依赖
- from: workspace-mgmt
@ -799,13 +789,3 @@ 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

View File

@ -1,160 +0,0 @@
---
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;不得在本模块内提前扩张。

View File

@ -141,14 +141,13 @@ 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: done
### 🔵 T9. 通用 CI 分发模板消费项目工具链契约
- status: in_progress
- 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 事实收口。"
## 执行顺序

View File

@ -15,7 +15,7 @@
"mcpServers": {
"lian-jing": {
"type": "sse",
"url": "http://127.0.0.1:27190/mcp/projects/dev-manager-tauri"
"url": "http://127.0.0.1:27190/mcp"
}
},
"permissions": {

View File

@ -1,4 +1,4 @@
# LIANJING_MCP_MANAGED_START
[mcp_servers.lian-jing]
url = "http://127.0.0.1:27190/mcp/projects/dev-manager-tauri"
url = "http://127.0.0.1:27190/mcp"
# LIANJING_MCP_MANAGED_END

View File

@ -1,6 +1,6 @@
---
version: 1.1.0
updated: 2026-09-23
version: 1.0.0
updated: 2026-07-03
usage: 炼境 agent 按钮体系的完整设计文档,供 Claude 理解体系全貌和设计意图;也是分发给子项目的方针来源
---
@ -124,20 +124,6 @@ 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。
## 三条设计原则
辩证结论,缺一会让体系在现实中碎裂。

View File

@ -6,7 +6,7 @@ contracts:
- label: API 接口(Axum)
paths:
- "src-tauri/src/mcp_server.rs"
doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/<project_id> scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。旧 /mcp 只作兼容迁移。路由变更需同步 Agent 按钮体系文档。"
doc: "路由变更需同步 API 文档"
- 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 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
- label: 被管理项目身份上下文
paths:
@ -30,11 +30,10 @@ contracts:
paths:
- "src-tauri/src/commands/**/*.rs"
- "src/lib/commands.ts"
doc: "新增 command 时前后端必须同步。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。"
doc: "新增 command 时前后端必须同步"
architecture_docs:
- "AGENTS.md"
- "docs/ai-context/agent-button-system.md"
agent_docs:
- "AGENTS.md"

View File

@ -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` 获取可观察信号。scoped 连接已绑定当前项目,可省略 `project_id`;仅在 initialize 报告 legacy 时显式传 canonical `project_id`,不得越界。
3. 当项目已有 `lian-jing` MCP 时,可调用 `get_diagnostics`、`verify_onboarding`、`get_toolchain_status` 和 `get_delivery_status` 获取可观察信号;所有调用显式传入 `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. 若缺少 MCP,依次判断 configured(scoped URL)→ reachable(炼境 loopback)→ reload(新建任务/重载客户端);停在哪层就报告哪层。缺少 canonical context 或项目登记时不得猜测 project_id、MCP URL、凭证或状态,并建议通过炼境接入或 `inject_mcp` 修复。
7. 若缺少 canonical context、MCP 或项目登记,不得猜测 project_id、MCP URL、凭证或状态。说明缺失项,并建议通过炼境接入或 `inject_mcp` 修复。
8. 炼境提供状态、凭证托管和受控入口;项目 Agent 仍负责代码判断、测试选择、提交分组与业务交付决策。不要把 CI 成功或部署信号表述为业务验收。
本插件不包含 MCP server、MCP URL、项目 ID、凭证、部署命令或客户端项目 UI 映射;这些都属于项目接入与炼境控制面。

82
src-tauri/Cargo.lock generated
View File

@ -228,17 +228,6 @@ 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"
@ -910,7 +899,6 @@ dependencies = [
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-http",
"tauri-plugin-opener",
@ -936,33 +924,13 @@ 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 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",
"dirs-sys",
]
[[package]]
@ -973,7 +941,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"redox_users",
"windows-sys 0.61.2",
]
@ -1094,7 +1062,7 @@ dependencies = [
"rustc_version",
"toml 0.9.12+spec-1.1.0",
"vswhom",
"winreg 0.55.0",
"winreg",
]
[[package]]
@ -3638,17 +3606,6 @@ 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"
@ -4691,7 +4648,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs 6.0.0",
"dirs",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@ -4742,7 +4699,7 @@ checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d"
dependencies = [
"anyhow",
"cargo_toml",
"dirs 6.0.0",
"dirs",
"glob",
"heck 0.5.0",
"json-patch",
@ -4814,20 +4771,6 @@ 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"
@ -4967,7 +4910,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
"dirs",
"flate2",
"futures-util",
"http",
@ -5473,7 +5416,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c"
dependencies = [
"crossbeam-channel",
"dirs 6.0.0",
"dirs",
"libappindicator",
"muda",
"objc2",
@ -6466,15 +6409,6 @@ 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"
@ -6589,7 +6523,7 @@ dependencies = [
"block2",
"cookie",
"crossbeam-channel",
"dirs 6.0.0",
"dirs",
"dom_query",
"dpi",
"dunce",

View File

@ -35,7 +35,6 @@ 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"
@ -50,3 +49,4 @@ sha2 = "0.10"
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }

View File

@ -9,7 +9,7 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管
如果当前客户端已发现 `lian-jing` MCP:
1. scoped MCP 连接已由 URL 绑定当前 `project_id`,项目级调用可省略 `project_id`;若 server initialize 明确报告 legacy,迁移前才显式传入 canonical `project_id`,绝不传其他项目 ID。
1. 所有 MCP 调用显式传入 `project_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,9 +17,4 @@ 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 未出现,按以下顺序降级,不猜测状态:
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、凭证或远端状态。
如果 MCP 未出现,说明 Codex 项目配置尚未被加载、项目未被信任,或炼境未运行。说明该限制后继续完成不依赖 MCP 的本地工作;不得伪造诊断结果。

View File

@ -350,35 +350,6 @@ 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")]
@ -622,60 +593,23 @@ fn url_host(url: &str) -> Option<String> {
}
}
/// 读取 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;
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())
}
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 修复",
)
Some(_) => (
false,
"未找到炼境的 Codex MCP 配置——调用 inject_mcp 修复".into(),
),
None => (
false,
".codex/config.toml 缺失——调用 inject_mcp 后生成".into(),
),
}
}
@ -850,11 +784,8 @@ 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));
checks.push(check_codex_mcp(
read(".codex/config.toml").as_deref(),
project_id,
crate::mcp_server::read_port_from_conn(conn),
));
let (pass, detail) = check_codex_mcp(read(".codex/config.toml").as_deref());
checks.push(VerifyCheck::from_pass("Codex MCP 已配置", pass, detail));
// 3 lefthook hook 真挂在 .git/hooks
let (pass, detail) = match read(".git/hooks/pre-commit") {
@ -1204,67 +1135,6 @@ 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();
@ -1289,7 +1159,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/projects/p1\"\n",
"[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:27190/mcp\"\n",
)
.unwrap();
std::fs::create_dir_all(base.join(".git/hooks")).unwrap();
@ -1360,60 +1230,6 @@ 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 批量实调实锤)。

View File

@ -3,15 +3,6 @@ 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] = &[
@ -250,7 +241,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 = crate::mcp_server::mcp_health_url(port);
let url = format!("http://localhost:{}/health", port);
let start = std::time::Instant::now();
match ureq::builder()
.timeout(std::time::Duration::from_secs(2))
@ -266,467 +257,6 @@ 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)]
@ -766,7 +296,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 = crate::mcp_server::mcp_health_url(port);
let url = format!("http://localhost:{}/health", port);
let start = std::time::Instant::now();
match ureq::builder()
.timeout(std::time::Duration::from_secs(5))
@ -1236,146 +766,4 @@ 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
);
}
}

View File

@ -48,8 +48,8 @@ use commands::{
proxy::*,
publish::*,
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,
check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path,
update_settings,
},
shell::*,
spaces::*,
@ -58,10 +58,6 @@ 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()
@ -74,10 +70,6 @@ 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())
@ -177,9 +169,6 @@ pub fn run() {
let _ = win.hide();
}
});
if has_autostart_arg(std::env::args_os()) {
let _ = window.hide();
}
}
Ok(())
@ -212,9 +201,6 @@ 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,
@ -452,18 +438,3 @@ 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(),
]));
}
}

View File

@ -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 = crate::mcp_server::scoped_mcp_url(port, project_id);
let server_url = format!("http://127.0.0.1:{}/mcp", port);
write_canonical_context(project_id)?;
upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url)?;
let path = settings_path(project_id)?;
@ -633,13 +633,6 @@ 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(),
@ -648,11 +641,7 @@ 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/projects/project%20one"
)
.is_err());
assert!(upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp").is_err());
assert_eq!(std::fs::read_to_string(&path).unwrap(), manual);
let _ = std::fs::remove_dir_all(&base);
}

View File

@ -1,5 +1,5 @@
use axum::{
extract::{Path, Query, State},
extract::{Query, State},
http::StatusCode,
response::{
sse::{Event, KeepAlive, Sse},
@ -10,112 +10,29 @@ use axum::{
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::{
collections::{HashMap, HashSet},
convert::Infallible,
net::SocketAddr,
sync::{Arc, OnceLock, RwLock},
};
use std::{collections::HashMap, convert::Infallible, net::SocketAddr, sync::Arc};
use tokio::sync::{mpsc, Mutex};
use tokio_stream::{wrappers::ReceiverStream, StreamExt as _};
use crate::db;
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)
)
}
/// SSE 会话表:session_id → 发送端
type Sessions = Arc<Mutex<HashMap<String, mpsc::Sender<(String, String)>>>>;
/// 从 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()
.map(|conn| read_port_from_conn(&conn))
.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())
.unwrap_or(27190)
}
@ -127,16 +44,8 @@ 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",
@ -144,7 +53,7 @@ pub async fn start(port: u16) {
)
.with_state(sessions);
let addr = mcp_bind_addr(port);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
match tokio::net::TcpListener::bind(addr).await {
Ok(listener) => {
db::log_event(
@ -194,37 +103,15 @@ struct SessionQuery {
// ── 共享 RPC 处理逻辑 ─────────────────────────────────────────────────────────
async fn process_rpc(req: &RpcRequest, project_scope: Option<&str>) -> Result<Value, String> {
if req.method == "initialize" {
record_project_client_initialize(req, project_scope);
}
async fn process_rpc(req: &RpcRequest) -> Result<Value, String> {
match req.method.as_str() {
"initialize" => Ok(json!({
"protocolVersion": "2024-11-05",
"capabilities": { "tools": {} },
"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"
})
}
"serverInfo": { "name": "炼境", "version": env!("CARGO_PKG_VERSION") }
})),
"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,
}),
"tools/list" => Ok(crate::mcp_tools::tools_list_result()),
"tools/call" => Ok(crate::mcp_tools::tools_call(req.params.as_ref()).await),
_ => Err(format!("Method not found: {}", req.method)),
}
}
@ -236,25 +123,7 @@ async fn handle_mcp(Json(req): Json<RpcRequest>) -> Response {
return StatusCode::ACCEPTED.into_response();
}
let id = req.id.clone();
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 {
match process_rpc(&req).await {
Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(),
Err(msg) => Json(json!({
"jsonrpc": "2.0", "id": id,
@ -274,15 +143,12 @@ async fn handle_sse_connect(
let (tx, rx) = mpsc::channel::<(String, String)>(32);
let port = read_port();
let endpoint_uri = sse_messages_url(port, &session_id);
let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await;
sessions.lock().await.insert(
session_id,
SessionEntry {
sender: tx,
project_scope: None,
},
let endpoint_uri = format!(
"http://127.0.0.1:{}/mcp/messages?session_id={}",
port, session_id
);
let _ = tx.send(("endpoint".to_string(), endpoint_uri)).await;
sessions.lock().await.insert(session_id, tx);
let stream = ReceiverStream::new(rx).map(|(evt_name, data)| {
Ok::<Event, Infallible>(Event::default().event(evt_name).data(data))
@ -291,27 +157,6 @@ 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::*;
@ -328,17 +173,10 @@ mod tests {
/// 单一 lian-jing 服务器的握手路径:initialize / tools/list 必须可用
#[tokio::test]
async fn process_rpc_handshake() {
let init = process_rpc(&rpc("initialize"), None).await.unwrap();
let init = process_rpc(&rpc("initialize")).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"), None).await.unwrap();
let tools = process_rpc(&rpc("tools/list")).await.unwrap();
let list = tools["tools"].as_array().unwrap();
assert!(
list.len() >= 20,
@ -346,60 +184,10 @@ 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
@ -416,7 +204,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, None).await {
let response_json = match process_rpc(&req).await {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err(msg) => json!({
"jsonrpc": "2.0", "id": id,
@ -424,12 +212,8 @@ async fn handle_sse_message(
}),
};
let mut guard = sessions.lock().await;
if let Some(entry) = guard.get(&session_id) {
if entry.project_scope.is_some() {
return;
}
if entry
.sender
if let Some(tx) = guard.get(&session_id) {
if tx
.send(("message".to_string(), response_json.to_string()))
.await
.is_err()
@ -441,46 +225,3 @@ 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()
}

View File

@ -5,7 +5,7 @@ use crate::db;
// ── 工具 Schema 定义 ──────────────────────────────────────────────────────────
pub fn tools_list_result() -> Value {
let mut result = json!({
json!({
"tools": [
{
"name": "get_diagnostics",
@ -376,223 +376,24 @@ 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 mut args = params
let 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" => match scope_for_call.as_deref() {
Some(project_id) => get_scoped_diagnostics(project_id),
None => get_diagnostics(),
},
"get_diagnostics" => get_diagnostics(),
"list_group_projects" => {
let group_id = args
.get("group_id")
@ -1102,10 +903,13 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
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 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)
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,
}))
.map_err(|e| e.to_string())
}
_ => Err(format!("未知工具: {}", tool_name)),
@ -1113,7 +917,7 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
.await;
match outcome {
Ok(Ok(text)) => tool_success_response(&response_tool_name, text),
Ok(Ok(text)) => json!({ "content": [{ "type": "text", "text": text }] }),
Ok(Err(e)) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }),
Err(e) => {
json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true })
@ -1283,18 +1087,6 @@ 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
@ -1967,119 +1759,9 @@ 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();

View File

@ -1,32 +0,0 @@
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);
});
});

View File

@ -6,36 +6,11 @@ import { relaunch } from "@tauri-apps/plugin-process";
import { getVersion } from "@tauri-apps/api/app";
import {
detectEditors, getSettings, getSpaces, searchEditorPath, updateSettings, getWslDistros,
getProjects, syncBlueprintRules, checkMcpHealth, getAgentRuntimeStatus,
getAgentAutostartStatus, setAgentAutostart,
type EditorCandidate, type SyncResult, type McpHealthResult, type AgentRuntimeStatus,
getProjects, syncBlueprintRules, checkMcpHealth,
type EditorCandidate, type SyncResult, type McpHealthResult,
} 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";
@ -209,19 +184,6 @@ 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[]>([]); // 多选
@ -234,16 +196,6 @@ 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) {
@ -332,34 +284,6 @@ 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("");
@ -585,7 +509,9 @@ 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">
本机 Agent 控制面仅监听 127.0.0.1;项目配置使用 scoped URL 绑定自身 project_id,避免跨项目误操作。
产品组 MCP Server,为组内项目的 Claude 提供跨项目上下文能力。配置好产品组成员后,每个项目的
{" "}<code className="font-mono bg-gray-50 px-1 rounded">.claude/settings.json</code>{" "}
会自动注入对应的 MCP 连接。
</p>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">监听端口</label>
@ -606,81 +532,12 @@ 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://127.0.0.1:{mcpPort}/mcp/projects/&lt;project_id&gt;
http://localhost:{mcpPort}/mcp
</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>
{/* 编辑器表单弹窗 */}

View File

@ -153,42 +153,6 @@ 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;