Merge pull request 'feat(dev-variables): 项目开发变量与测试账户补给(V1–V11)' (#23) from feat/project-dev-variables into master
All checks were successful
Push & PR Check / check (push) Successful in 1m7s
All checks were successful
Push & PR Check / check (push) Successful in 1m7s
This commit is contained in:
commit
6105c3d4a3
@ -6,7 +6,7 @@ description: >
|
||||
项目自身保留业务规则、测试、部署声明与交付决策。炼境本身亦被纳入其中,
|
||||
形成可自我进化的开发智能闭环。
|
||||
iteration: 1
|
||||
updated: 2026-09-23
|
||||
updated: 2026-09-28
|
||||
agent_button_carrier: tauri
|
||||
|
||||
|
||||
@ -494,11 +494,24 @@ modules:
|
||||
area: infra
|
||||
status: done
|
||||
progress: 100
|
||||
updated: 2026-09-24
|
||||
note: "R1–R10 的合成路径已验证,含 scoped MCP 到模拟 Gitea 的 PR 全链路;不接管真实生产密钥或部署。"
|
||||
updated: 2026-09-26
|
||||
note: "R1–R11 的合成路径和真实 Codex CLI 客户端试点已验证;真实用户凭证迁移、正式版 UI 与生产链路未验证,未启动第二个日常 Tauri 实例。"
|
||||
position: [3000, 950]
|
||||
|
||||
- id: project-dev-variables
|
||||
name: 项目开发变量补给
|
||||
area: infra
|
||||
status: in_progress
|
||||
progress: 100
|
||||
updated: 2026-09-30
|
||||
note: "V1–V11 agent_verify 全部通过;V7 于 2026-09-30 以真实 Codex CLI 三会话完成登记→填写→跨会话读取→撤销后读取失败(单次 --approve-for-me,仅登记写调用)。待人终验真实桌面 UI 与日常便利;仅非生产开发用途。运行时落地增量见 docs/tasks/2026-09-30-dev-variables-materialize.md。"
|
||||
position: [3250, 950]
|
||||
|
||||
edges:
|
||||
- from: project-resource-broker
|
||||
to: project-dev-variables
|
||||
type: dependency
|
||||
|
||||
# 前端依赖
|
||||
- from: workspace-mgmt
|
||||
to: project-mgmt
|
||||
|
||||
211
.blueprint/modules/project-dev-variables.md
Normal file
211
.blueprint/modules/project-dev-variables.md
Normal file
@ -0,0 +1,211 @@
|
||||
---
|
||||
id: project-dev-variables
|
||||
name: 项目开发变量补给
|
||||
status: in_progress
|
||||
progress: 91
|
||||
---
|
||||
|
||||
## 设计决策:人填写、Agent 按项目取值(2026-09-26)
|
||||
|
||||
### 背景与目标
|
||||
|
||||
开发 Agent 在子项目验收时会反复需要本机专属配置,例如非生产测试账号、沙箱 API Key 或测试端点。用户希望在炼境的项目 UI 填写一次,此后 Agent 能稳定发现并读取,避免把值留在子项目 `.env` 中。首轮只解决 Agent 获取开发变量;子项目进程的 `process.env` 自动注入、CI、生产凭证及部署能力不在范围内。
|
||||
|
||||
本模块是已完成的 `project-resource-broker` 的**明确例外**:R1–R11 保持 Gitea/API Key/webhook 常态不泄露、按操作代办;只有用户明确放入“Agent 可读开发变量”的值,才允许经专门工具返回明文。不能将旧的 `get_secret` 禁令改写成通用任意取密接口。
|
||||
|
||||
### 方案选择
|
||||
|
||||
| 方案 | 收益 | 代价与结论 |
|
||||
| --- | --- | --- |
|
||||
| 继续各项目 `.env` | 与现有脚本直接兼容 | 需要重复配置,仍有误提交与 Agent 访问不稳定的问题;保留为回退路径 |
|
||||
| 炼境保存后由无认证 scoped MCP 返回值 | 最省实现步骤 | scoped URL 仅隔离误操作,不认证调用者;任何能访问本机端口的进程可能取值,否决 |
|
||||
| **项目级开发变量 + 认证的 scoped MCP 单项读取** | 人填一次、Agent 跨会话复用、仓库无值 | Agent 会看到明文;需要验证 Codex 的认证头与本机凭据供应链,选定 |
|
||||
|
||||
### 信任与数据边界
|
||||
|
||||
- 仅支持登记在炼境的项目、明确标为开发用途且允许 Agent 读取的标量值。用户在对应项目 UI 填写、修改、撤销;Agent 可登记名称/用途和读取状态,但不能通过 MCP 设置值。非生产、低权限、可重置是使用规则,不能靠变量名称自动判断生产性质。
|
||||
- SQLite 只保存 `project_id`、规范化变量名、用途、状态所需元数据和随机 `credential_ref`;值保存在现有 Windows Credential Manager adapter。普通列表、readiness、日志、错误与文档不含值。
|
||||
- 唯一明文出口是 `read_project_dev_variable(project_id, name)`;必须同时验证真实项目绑定、scoped MCP 连接的项目 ID、客户端认证以及记录已由人配置。全局 `/mcp`、未认证请求和跨项目请求必须拒绝。明文进入 Agent 工具结果与会话,因此本方案不声称对 Agent 保密。
|
||||
- MCP 认证材料不得写入项目仓库、URL 或普通日志。Codex `http_headers_helper` 已用两次独立真实 CLI 会话验证:从 Windows Credential Manager 的随机合成项取得 bearer,真实 initialize 请求均带正确 `Authorization`;失败不得降级成无认证接口。正式打包路径尚未验证。
|
||||
- 同一 Windows 用户的恶意进程仍可能读取用户可访问的配置或受保护存储;认证只阻断无凭据的本机 HTTP 调用,不提供跨进程强隔离。其他客户端 adapter 未验证前不宣称兼容。
|
||||
|
||||
### 系统边界与预计文件
|
||||
|
||||
- 数据:`src-tauri/src/db.rs` 增加兼容 migration;新业务模块 `src-tauri/src/commands/dev_variables.rs` 注入数据库连接和 `CredentialStore`,实现登记、保存、读取、撤销的单点边界。
|
||||
- 认证:`src-tauri/src/mcp_server.rs` / `mcp_tools.rs` 在取值调用前验证项目绑定及 bearer;`src-tauri/src/credential_store.rs` 存储随机认证材料;`src-tauri/src/main.rs` 与 `mcp_inject.rs` 评估最小本机 header helper 和配置注入。旧 MCP 工具继续按原契约可用。
|
||||
- 人的投影:`src-tauri/src/lib.rs` 注册 Tauri commands,`src/lib/commands.ts` 同步类型与封装;在 `src/components/dashboard/ProjectCard.tsx` 就近入口接入项目级面板,值仅通过显式填写表单提交。
|
||||
- Agent 投影:`src-tauri/src/mcp_tools.rs` 暴露登记需求、查询非秘密状态、单项取值;不提供批量导出、任意命令、HTTP/SSH/SQL 或部署代理。
|
||||
- 契约同步:`docs/ai-context/project-context.yaml` 与 `docs/ai-context/agent-button-system.md` 说明例外与信任模型;保留旧设计历史,不将项目通用 API Key 改为 Agent 可读。
|
||||
|
||||
### 验证路径
|
||||
|
||||
- `[Codex CLI + 合成 MCP]` 用 `http_headers_helper` 提供合成 bearer 后初始化 → 服务端观测认证头;无 header/错误 header → 敏感工具拒绝;未通过则停止实现取值接口。
|
||||
- `[cargo test]` 用 in-memory SQLite + 模拟 CredentialStore 登记、保存、重启式重建后读取、撤销 → 状态正确,旧值不可再取,列表与错误不含合成值。
|
||||
- `[真实 scoped MCP]` Agent 在 `p1` 登记 `E2E_PASSWORD`,人通过同一本体保存合成值,Agent 单项读取 → 仅 `p1` 的已认证调用得到该值;`p2`、全局路由和无认证调用失败。
|
||||
- `human_verify:` 炼境项目 UI 的待填写、已配置、修改和撤销路径可用,默认不显示值;使用者判断是否比重复配置 `.env` 省事。
|
||||
|
||||
### 风险与阶段闸门
|
||||
|
||||
- **认证材料供应**:helper 与客户端跨会话读取已用随机合成项验证,正式打包版尚未验证;若后续失败必须回到设计,不把 token 写进受 Git 跟踪的 `.codex/config.toml`。
|
||||
- **明文工具结果**:用户选择开发便利,Agent 可能在会话中持有值;先只用合成数据验证,不读取或迁移真实账号。生产和高权限凭证仍用原有代办/CI/运行时机制。
|
||||
- **不替代运行时 `.env`**:Agent 读取值不自动设置子进程环境变量;需要时另立任务,不在本迭代暗加 `export_env`。
|
||||
- **依赖 PR #22**:从已提交的 R11 基线独立分支开发,先核验 PR #22 状态;不得把新改动混入已有 PR 或原 `master` 脏工作区。
|
||||
|
||||
## 任务拆解
|
||||
|
||||
### ✅ V1 — 认证头 helper 真实客户端可行性
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/main.rs, src-tauri/src/lib.rs, src-tauri/src/credential_store.rs, src-tauri/src/mcp_inject.rs
|
||||
- depends: project-resource-broker
|
||||
- acceptance: "agent_verify: [Codex CLI + 合成 MCP] 通过本机 helper 从模拟受保护存储提供 bearer → initialize 请求含正确 Authorization,重启客户端后仍可读取;helper 缺失/失败时连接或取值 fail closed,项目配置文件不含 token。未通过即停止 V2–V7。"
|
||||
- evidence: `cargo test --locked codex_client_reads_windows_helper_header_across_sessions -- --ignored` 双会话通过;真实 exe helper 的读取/缺失 fail closed 测试通过;config 测试断言无 token。
|
||||
|
||||
### ✅ V2 — 敏感 MCP 调用认证与项目绑定
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_server.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/credential_store.rs
|
||||
- depends: V1
|
||||
- acceptance: "[cargo test] 合成 bearer 下 p1 的敏感 tools/call 认证门槛通过;无 header、错误 header、p2 和全局 /mcp 均在 dispatch 前拒绝,现有非敏感 scoped 工具保持可调用;cargo test 全绿。真实取值工具的 HTTP 端到端验收归 V6。"
|
||||
- evidence: `sensitive_read_requires_matching_project_bearer_on_both_transports` 通过;完整 `cargo test --locked` 通过。
|
||||
|
||||
### ✅ V3 — 开发变量元数据与受保护存储本体
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/db.rs, src-tauri/src/commands/dev_variables.rs, src-tauri/src/credential_store.rs
|
||||
- depends: V2
|
||||
- acceptance: "[cargo test] in-memory DB + 模拟 CredentialStore 完成登记、保存、覆盖、读取、撤销;模拟写入或删除失败时旧值/引用可恢复,列表序列化和错误不含合成值;旧 schema 经 migrate 后可用。"
|
||||
- evidence: `dev_variables::tests` 与 `dev_variable_migration_adds_metadata_table_to_existing_database` 通过;数据库重开后仍可读取合成值,删除失败保留旧引用。
|
||||
|
||||
### ✅ V4 — Tauri 人用命令与前端契约
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/dev_variables.rs, src-tauri/src/lib.rs, src/lib/commands.ts
|
||||
- depends: V3
|
||||
- acceptance: "[Tauri command/cargo test] 以项目 ID 查询待填写/已配置、保存合成值、撤销 → 状态随真实存储变化;常态返回类型无 value,pnpm typecheck exit 0。"
|
||||
- evidence: `tauri_commands_use_same_state_and_never_return_value_in_list` 通过,`mise exec -- pnpm typecheck` exit 0。
|
||||
|
||||
### ✅ V5 — 项目 UI 填写与撤销投影
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src/components/dashboard/ProjectCard.tsx, src/components/dashboard/ProjectDevVariablesPanel.tsx, src/lib/commands.ts
|
||||
- depends: V4
|
||||
- acceptance: "agent_verify: [Tauri command/cargo test] 填写、覆盖、撤销同一本体状态正确,pnpm typecheck exit 0。human_verify: 在对应项目可见用途与待填写状态,填写/修改/撤销顺手,常态不显示值。"
|
||||
- human_verify: pending(未启动隔离分支的真实桌面 UI;交人终验时判断交互摩擦)
|
||||
- evidence: Tauri command 真实函数路径测试与 TypeScript 类型检查通过;填写值从 React Query mutation 缓存中移除。
|
||||
|
||||
### ✅ V6 — Agent 登记、状态和单项读取工具
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_tools.rs, src-tauri/src/commands/dev_variables.rs, src-tauri/src/mcp_server.rs
|
||||
- depends: V4, V5
|
||||
- acceptance: "[真实 scoped MCP] p1 Agent 登记 E2E_PASSWORD → UI 待填写;人保存合成值后已认证 Agent 单项读取成功;未认证、p2 和全局路由拒绝;tools/list 无批量导出或任意代理。"
|
||||
- evidence: `scoped_dev_variable_http_pilot_denies_missing_wrong_cross_and_global_auth` 真实本机 HTTP 测试通过;撤销后读取失败,工具清单无批量出口。
|
||||
|
||||
### ✅ V7 — 契约、合成试点与改动边界验收
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: docs/ai-context/project-context.yaml, docs/ai-context/agent-button-system.md, .blueprint/modules/project-dev-variables.md, src-tauri/src/mcp_server.rs
|
||||
- depends: V6
|
||||
- acceptance: "agent_verify: [真实 Codex CLI + 合成数据] 跨会话登记→填写→读取→撤销后再读失败,项目目录无新 .env 或 token;[CLI] cargo test、pnpm typecheck、git diff --check 均 exit 0。human_verify: 不使用真实生产账号、不触发真实 PR/部署/数据库写入,确认日常开发摩擦是否下降。"
|
||||
- resolved_blocker: 原阻碍为非交互 `exec` 默认 approval=never 拒绝写工具。2026-09-30 用户授权单次 `--approve-for-me`(仅会话 A 登记这一写调用),沿用 R11 先例;未放宽全局策略,读工具无需审批。
|
||||
- verification: "2026-09-30:新增 ignored 宿主 `mcp_server::tests::isolated_dev_variables_client_pilot`(内存 DB、随机合成账户、临时 scoped MCP,认证走真实 `dev-manager-tauri.exe --lianjing-mcp-header` helper 取 Windows 凭据中的项目 bearer)。自检:helper exit 0;无认证 read_project_test_account → HTTP 403。真实 Codex CLI 0.155.0-alpha.9.2(gpt-5.6-terra)三个独立会话:A 登记 PILOT_LOGIN(environment=local)→ 宿主代人填写;B 新会话 list → configured=true、read → username=pilot-user-12abb974,与宿主生成值一致;宿主代人撤销后 C 新会话 read → 失败「开发变量尚未由用户填写」。finish 后宿主断言:状态全部未配置、目录无 `.env*`、任何文件不含 MCP token 与测试密码、MCP 认证材料已删除、临时目录已清理,test ok。回归:cargo test --locked 227 passed / 5 ignored,pnpm typecheck exit 0,git diff --check 通过。"
|
||||
- human_verify: "2026-09-30 用户在 dev-variables 构建(真实 DB,已备份)对 Enterprise AGENT_ADMIN 两轮终验:首轮否决两处——修改时空白(→ V12)、撤销无确认框(→ f74fe11,根因 plugin-dialog 异步 confirm,同类 5 处一并修复);次轮通过。日常开发摩擦是否下降待合并后真实任务观察。"
|
||||
- unverified: "正式打包版 helper 路径;Claude/DeepSeek 等非 Codex 客户端的 bearer helper 取值;B 会话中模型把 list/read 各重复调用 3 次(结果一致,属模型行为,未影响断言);宿主强制终止时 Drop 不保证执行,可能残留合成 Windows 凭据。"
|
||||
|
||||
## 设计决策:测试账户一次填写与跨会话复用(2026-09-28)
|
||||
|
||||
### 背景
|
||||
|
||||
单个开发变量虽已能登记、填写和读取,但测试登录通常同时需要账号与密码。让 Agent 分别登记两个变量、让用户分别填写,仍有重复操作和半配置状态;本迭代把常见测试账户做成一个有语义的需求卡,不扩展到生产凭证或任意表单生成器。
|
||||
|
||||
### 方案选择
|
||||
|
||||
| 方案 | 取舍 |
|
||||
| --- | --- |
|
||||
| 两个独立变量由 UI 猜测配对 | 无迁移,但没有可靠的同一需求身份,保存时可能只写成功一个字段;不选 |
|
||||
| **在现有开发变量记录上增加 `test_account` 类型,账号/密码作为一份受保护值原子保存** | 复用现有项目边界、Windows Credential Manager 与撤销路径,增加少量非秘密元数据;选定 |
|
||||
| 新建通用表单/凭证库 | 能支持任意字段,却扩大明文出口与维护面;本轮不做 |
|
||||
|
||||
### 系统边界与受影响文件
|
||||
|
||||
- `src-tauri/src/db.rs`:旧表兼容迁移,新增类型、目标系统和环境元数据;旧记录保持 `scalar`。
|
||||
- `src-tauri/src/commands/dev_variables.rs`:需求登记幂等;账号/密码按一份结构化值写入同一受保护引用,读写和撤销沿用项目校验;普通标量 API 不读取或覆盖测试账户记录。
|
||||
- `src-tauri/src/mcp_tools.rs`、`mcp_server.rs`:项目级登记、状态发现、专用读取;读取遵守现有 bearer 门槛,未认证、跨项目和全局路由拒绝,不增加通用 `get_secret`。
|
||||
- `src-tauri/src/lib.rs`、`src/lib/commands.ts`、`src/components/dashboard/ProjectDevVariablesPanel.tsx`:一张卡一次填写账号与密码;常态列表仅展示目标系统、环境、用途和配置状态,不回显值。
|
||||
- `docs/ai-context/project-context.yaml`、`docs/ai-context/agent-button-system.md`:同步外部契约与例外边界。
|
||||
|
||||
### 验证路径
|
||||
|
||||
- `[cargo test]` 旧 schema 迁移后旧标量仍可读;重复登记同一测试账户只得一张卡,语义冲突拒绝;一次保存后账号与密码同时可读,撤销后同时不可读,列表与错误不含合成值。
|
||||
- `[真实 scoped MCP]` 合成项目登记→Tauri 本体填写→已认证读取→跨项目、无认证及全局路由拒绝;工具目录没有批量导出或任意代理。
|
||||
- `human_verify:` 用户在项目面板一次填写账号与密码,后续会话无需重复填写;仅在人主动修改时出现输入框。
|
||||
|
||||
### 风险与闸门
|
||||
|
||||
- Agent 读取时会得到明文;本能力仅用于用户明确放入的非生产、低权限、可重置测试账户,不承诺对 Agent 保密。
|
||||
- 真实 Codex 非交互 `exec` 对写工具的客户端限制属于 V7 接入验证,不以错误标注只读或放宽全局策略绕过;业务层合成验收可以独立先做,真实客户端全链路未通过前不宣称日常可用。
|
||||
- 不自动把值注入子进程环境、不写 `.env`、不接管生产账号、SSH、SQL、HTTP 或部署。
|
||||
|
||||
### 拆解建议
|
||||
|
||||
按类型与受保护存储 → scoped Agent 投影 → 人用单表单投影 → 合成链路与文档四卡实施;既有 V7 仍单独保持 blocked,直到真实客户端验证通过。
|
||||
|
||||
## 任务拆解:测试账户预设
|
||||
|
||||
### ✅ V8 — 测试账户类型与原子受保护存储
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/db.rs, src-tauri/src/commands/dev_variables.rs
|
||||
- depends: V6
|
||||
- acceptance: "agent_verify: [cargo test] 旧 schema migrate 后旧 scalar 正常;register_test_account(project,name,service,environment,purpose) 重复调用只保留一条且语义冲突拒绝;save_test_account 一次写入合成账号/密码,read_test_account 同时返回,revoke 后同时不可读;列表及错误不含值,写入失败不留下半配置。"
|
||||
- evidence: `cargo test --locked test_account` 4 项通过;旧表迁移幂等、登记幂等、类型隔离、一次写入/撤销和初次/覆盖失败恢复均由合成数据断言。
|
||||
|
||||
### ✅ V9 — Agent 测试账户需求与项目级读取
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_tools.rs, src-tauri/src/mcp_server.rs
|
||||
- depends: V8
|
||||
- acceptance: "agent_verify: [真实 scoped MCP/cargo test] p1 登记一张合成测试账户需求、查询无秘密状态、已认证读取账号与密码;未认证、p2、全局 /mcp 拒绝读取,普通 scalar API 不能读取测试账户;tools/list 不含批量导出或任意代理。"
|
||||
- evidence: `sensitive_read_requires_matching_project_bearer_on_both_transports` 与扩展后的 `scoped_dev_variable_http_pilot_denies_missing_wrong_cross_and_global_auth` 通过;新明文工具先接入认证门槛再暴露,真实本机 HTTP 拒绝未认证/错误认证/p2/全局调用。
|
||||
|
||||
### ✅ V10 — 一张卡填写账号与密码
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/lib.rs, src/lib/commands.ts, src/components/dashboard/ProjectDevVariablesPanel.tsx
|
||||
- depends: V8
|
||||
- acceptance: "agent_verify: [Tauri command/cargo test + pnpm typecheck] UI 一次提交调用 save_test_account,状态从待填写到已配置,修改与撤销共用本体;常态 DTO、React Query 缓存与日志不含账号或密码,typecheck exit 0。human_verify: 项目面板一张卡一次填写两个字段,默认不回显值。"
|
||||
- human_verify: pending(隔离分支真实桌面 UI 尚未由用户终验)
|
||||
- evidence: `tauri_test_account_command_uses_same_state_without_listing_values` 通过,`mise exec -- pnpm typecheck` exit 0;UI 通过独立调用提交两字段,列表 DTO 只有非秘密元数据和配置状态。
|
||||
|
||||
### ✅ V11 — 合成复用验收与契约同步
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_server.rs, docs/ai-context/project-context.yaml, docs/ai-context/agent-button-system.md
|
||||
- depends: V9, V10
|
||||
- acceptance: "agent_verify: [真实 scoped MCP/cargo test] 合成项目登记→同一本体填写→再次查询已配置→读取→撤销后读取失败,并确认目录没有新增 .env 或明文配置;cargo test、pnpm typecheck、git diff --check exit 0。human_verify: 真实桌面 UI 与真实 Agent 客户端跨会话便利性另报未验证,不冒充 V7 完成。"
|
||||
- human_verify: pending(真实桌面 UI 和真实 Agent 客户端跨会话复用尚未终验;V7 保持 blocked)
|
||||
- evidence: 真实本机 scoped MCP HTTP 合成链路逐步断言登记、待填写、受保护存储填写、复查已配置、项目 bearer 读取、撤销后拒绝;数据库重开复用测试通过。`cargo test --locked` 227 passed/4 ignored,`mise exec -- pnpm typecheck` 与 `git diff --check` exit 0;未新增 `.env` 或明文配置文件。
|
||||
|
||||
## 任务拆解:UI 终验反馈与 agent 可发现性(2026-09-30)
|
||||
|
||||
> 来源:用户对 V7 的 human_verify 否决理由——「修改时空白,应像修改配置一样呈现旧值」「撤销无确认框」(后者为 bug,已由 `f74fe11` 修复并加回归测试);以及 agent 侧可发现性缺口:接入包 skill 未提示「需要测试账号时先查炼境」,agent 会继续向人索要。
|
||||
|
||||
### ✅ V12 — 修改时显式查看原值(人用投影)
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/commands/dev_variables.rs, src-tauri/src/lib.rs, src/lib/commands.ts, src/components/dashboard/ProjectDevVariablesPanel.tsx, docs/ai-context/project-context.yaml
|
||||
- depends: V10
|
||||
- acceptance: "agent_verify: [cargo test] reveal(已配置测试账户) → 账号与密码原值;reveal(标量) → 原值;未配置 → 错误;MCP tools/list 不含任何 reveal 工具。human_verify: 点「修改」→ 账号预填可见、密码预填但默认遮罩,点「显示」可见;保存后生效。"
|
||||
- verification: "2026-09-30:cargo test reveal_returns_original_value_by_kind_only_for_humans 覆盖测试账户/标量取回、未配置与撤销后报错、MCP tools/list 不含 reveal;全量 cargo test --locked 228 passed / 5 ignored,pnpm typecheck exit 0,vitest 8 passed。"
|
||||
- human_verify: "2026-09-30 通过:修改预填原账号、密码遮罩可切换显示、撤销弹出确认框且取消不生效。"
|
||||
- decision: 值仅经本机 Tauri command 在人显式点「修改」时取回,沿用 API Key R2 的显式 reveal 模式;不进 MCP,agent 取值仍只走已认证的 read_project_test_account。密码默认遮罩防截图/旁观泄露。
|
||||
|
||||
### ✅ V13 — 接入包 skill 提示 agent 先查炼境测试账户
|
||||
- status: done
|
||||
- complexity: S
|
||||
- files: src-tauri/resources/onboarding/.agents/skills/lian-jing/SKILL.md, src-tauri/src/commands/onboarding.rs
|
||||
- depends: V9
|
||||
- acceptance: "agent_verify: [cargo test onboarding] base-onboarding 版本升级后旧版本项目报告 version_mismatch、新模板含测试账户指引。human_verify: 合并并重建后对 Enterprise apply_onboarding_pack,agent 需要登录时先查炼境而非向人索要。"
|
||||
- verification: "2026-09-30:SKILL.md 新增第 8 步(先 list,已配置单项读取,否则登记并请用户在 UI 填写;禁止对话索要明文、禁止登记生产、禁止写入 Git 跟踪文件、认证被拒如实报告);BASE_ONBOARDING_PACK_VERSION 与模板 capability-packs.json 同步升至 1.5.0(仅升常量时 2 个 onboarding 测试失败,已同步修正);cargo test --locked 全绿。"
|
||||
- human_verify: pending(分发到 Enterprise 后观察 agent 行为)
|
||||
@ -68,6 +68,17 @@ progress: 100
|
||||
|
||||
## 任务拆解
|
||||
|
||||
### ✅ R11 — 隔离真实客户端试点宿主
|
||||
- status: done
|
||||
- complexity: M
|
||||
- files: src-tauri/src/mcp_server.rs, .blueprint/modules/project-resource-broker.md, .blueprint/manifest.yaml
|
||||
- depends: R10
|
||||
- acceptance: "agent_verify: [cargo test --ignored] isolated_scoped_mcp_client_pilot → 仅用内存数据库、随机合成 Windows 凭证与 127.0.0.1 模拟 Gitea 启动临时 scoped MCP;收到一笔有界 PR 请求后断言模拟端收到 token、测试退出时凭证清理。[cargo test] R10 继续断言 readiness 无明文与跨项目调用拒绝。[CLI] cargo test、git diff --check 通过。human_verify: Codex/Claude 客户端实际连接临时 scoped URL,依次调用 readiness、跨项目拒绝与模拟 PR,客户端会话只见非秘密状态和 PR 结果、不见 token;日常应用和真实数据库未被修改。"
|
||||
- human_verify: "Codex CLI 独立模型会话已实际连接临时 scoped URL:readiness 仅返回 configured/not_configured,跨项目调用返回 scope_violation,审批后的模拟 PR 返回 #43;会话输出不含 token。未启动第二个日常 Tauri 实例或打开真实用户数据库。"
|
||||
- verification: "2026-09-26:ignored Rust fixture 与独立协议进程三轮验收;随后 Codex CLI 真实模型会话以一次性 `-c` 覆盖连接同一合成 scoped MCP,依次调用 readiness、跨项目拒绝和模拟 PR。默认非交互 approval=never 阻止 PR;单次 `--approve-for-me` 开启审批后,mock Gitea 返回 #43 且模型结果不含 token。fixture exit 0,断言 Windows 合成凭证与临时目录清理。完整 cargo test 211 passed、2 ignored;typecheck、前端 6 tests、git diff --check 通过;R11 push CI 成功。"
|
||||
- unverified: "真实用户数据库/凭证迁移、正式安装版 UI、真实 Gitea PR/生产部署均未测试;强制终止测试进程时 Drop 不保证执行,可能留下无生产权限的随机合成 Windows 凭证;同一 Windows 用户下不提供强进程隔离。"
|
||||
- retrospective: "【复盘】隔离真实客户端试点宿主|任务类型: MCP工具|复杂度: M|实际轮数: 2轮(预估 2 轮)|是否返工: 是|返工原因: Codex CLI 非交互 approval=never 会拒绝写工具,即使客户端可读取 scoped readiness|有效策略: 先确认临时 URL 仅指向内存 fixture 与 mock Gitea,再以一次性审批模式完成真实模型调用,并在正常测试退出时断言清理|遗留风险: 真正的日常 UI 与真实旧数据迁移仍未验证;强制终止时合成 WinCred 可能残留|auto_applied_rules: R02,R03|rule_effectiveness: R02=有效,R03=有效"
|
||||
|
||||
### ✅ R10 — scoped MCP 合成 PR 端到端回归
|
||||
- status: done
|
||||
- complexity: M
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
version: 1.1.0
|
||||
updated: 2026-09-23
|
||||
updated: 2026-09-26
|
||||
usage: 炼境 agent 按钮体系的完整设计文档,供 Claude 理解体系全貌和设计意图;也是分发给子项目的方针来源
|
||||
---
|
||||
|
||||
@ -139,8 +139,12 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
|
||||
|
||||
这套 scope 是单用户本机的误操作隔离,不是多租户认证。威胁模型变化前,不向项目配置写持久 token。
|
||||
|
||||
项目开发变量是明确、有限的例外:Codex 项目配置只保存本机 helper 命令和项目 ID digest,随机 bearer 留在 Windows Credential Manager;helper 在连接时向 Codex 提供认证头。普通 scoped MCP 工具继续兼容无认证调用,但 `read_project_dev_variable` 与 `read_project_test_account` 在服务端验证该项目的 bearer,跨项目、全局 `/mcp`、无认证及错误认证均拒绝。同一 Windows 用户的恶意进程仍可能读取该用户可访问的凭证,本机制不声称提供同用户进程隔离;其他 Agent 客户端未验证前不承诺同等取值能力。
|
||||
|
||||
## 项目资源与凭证代办边界
|
||||
|
||||
项目开发变量由 Agent 在 scoped MCP 中登记名称和用途,人通过对应项目 UI 明确填写、修改或撤销。测试登录前 Agent 先查询非秘密需求状态:已有测试账户卡就复用;没有才以 `register_project_test_account` 登记稳定名称、目标系统、非生产环境和用途,不能通过 MCP 提交账号或密码。用户在同一张卡一次填写账号与密码;两字段作为一份结构化值原子写入 Windows Credential Manager,SQLite 仅保存非秘密元数据与随机引用。常态列表只报告待填写/已配置;`read_project_test_account` 经项目级认证后返回单张卡的账号与密码,旧的 `read_project_dev_variable` 仅返回标量,双方不能跨类型取值。结果会进入 Agent 会话,因此只允许非生产、低权限、可重置的开发数据;不替代子项目运行时 `.env` 注入,也不改变下述 Gitea/API Key 代办边界。
|
||||
|
||||
`get_project_resource_status(project_id)` 是同一项目绑定上的只读 MCP/Tauri 投影,只报告 Gitea PR 与 webhook 的 `configured`、`pending_migration`、`not_configured`、`unavailable` 或 `needs_repair` 及非秘密来源。旧 SQLite 凭证未迁移时只报告 `pending_migration`,不提前宣称可执行;scoped MCP 连接拒绝跨项目参数;状态不含原始凭证、凭证引用、仓库 URL 或连接串。`configured` 仅表示本机代办凭证就绪,不代表远端 Gitea、CI 或部署已验证。
|
||||
|
||||
Gitea PR 继续使用既有 `create_pull_request` 有界操作;Windows 本机代办凭证由 Credential Manager 保护,SQLite 只存引用及非秘密元数据。旧值在显式操作时,先在数据库同目录以 SQLite `VACUUM INTO` 生成 `dev-manager.pre-credential-migration.db`,校验完整性和当前旧值确在备份中,再按写入、读取校验、事务切换引用并清除旧列的顺序迁移;备份不可用或失败则保留旧值并报告 `needs_repair`。此备份可能含历史明文,须按原数据库同等敏感级别保护,不能因当前列清空就宣称历史明文已消失。普通 Gitea/API Key DTO 不带原始值;人的 API Key 查看与复制仍通过本机 Tauri 的显式 reveal,MCP 不暴露该入口。Gitea Actions secrets、项目运行时密钥与服务器凭证仍留在各自原用途位置;炼境不提供 `get_secret` 或任意 SSH、SQL、HTTP、部署代理。同一 Windows 用户下的进程隔离不是本方案提供的安全保证。
|
||||
|
||||
@ -29,6 +29,7 @@ src-tauri/src/db.rs 数据库 schema + migration
|
||||
src-tauri/src/credential_store.rs Windows 本机凭证引用、校验与兼容迁移
|
||||
src-tauri/src/mcp_server.rs MCP Server(agent 调用炼境的入口)
|
||||
src-tauri/src/mcp_tools.rs MCP tool schema 与 dispatch
|
||||
src-tauri/src/commands/dev_variables.rs 项目开发变量本体(Agent 登记、人填写、受认证单项读取)
|
||||
src-tauri/src/commands/delivery_status.rs 项目交付信号聚合(Git / Gitea / 部署证据)
|
||||
src-tauri/resources/onboarding/ 接入包分发模板
|
||||
.blueprint/ 项目蓝图
|
||||
|
||||
@ -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: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/<project_id> scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。旧 /mcp 只作兼容迁移。read_project_dev_variable 与 read_project_test_account 是两种有界开发值读取:仅 scoped 路由、匹配项目的本机 bearer 认证后单项读取;认证失败在 dispatch 前拒绝。路由变更需同步 Agent 按钮体系文档。"
|
||||
|
||||
- label: MCP 工具接口
|
||||
paths:
|
||||
@ -15,7 +15,8 @@ contracts:
|
||||
- "src-tauri/src/commands/toolchain.rs"
|
||||
- "src-tauri/src/commands/cicd.rs"
|
||||
- "src-tauri/src/commands/gitea.rs"
|
||||
doc: "工具 schema、dispatch 与调用说明必须同步;get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
|
||||
- "src-tauri/src/commands/dev_variables.rs"
|
||||
doc: "工具 schema、dispatch 与调用说明必须同步;get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。"
|
||||
|
||||
- label: 被管理项目身份上下文
|
||||
paths:
|
||||
@ -30,7 +31,7 @@ contracts:
|
||||
paths:
|
||||
- "src-tauri/src/commands/**/*.rs"
|
||||
- "src/lib/commands.ts"
|
||||
doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。"
|
||||
doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。"
|
||||
|
||||
architecture_docs:
|
||||
- "AGENTS.md"
|
||||
|
||||
@ -36,13 +36,13 @@
|
||||
- 返回值、错误文本、日志均不含账号或密码。
|
||||
- MCP 投影走 scoped 路由,属写工具。客户端审批策略(参见 V7 blocker)按正常审批处理,不标注为只读来绕过。人的投影:`ProjectDevVariablesPanel` 可选加「写入工作区」按钮,属于 human_verify 范围,非 v1 必需。
|
||||
|
||||
## 任务卡草案(合入 dev-variables 模块后编号接续 V11)
|
||||
## 任务卡草案(合入 dev-variables 模块后编号接续 V13;V12/V13 已被 UI 终验反馈占用)
|
||||
|
||||
- **V12 落地本体**(M)— files: `src-tauri/src/commands/dev_variables.rs`, `src/lib/commands.ts`;depends: project-dev-variables 合入 master
|
||||
- **V14 落地本体**(M)— files: `src-tauri/src/commands/dev_variables.rs`, `src/lib/commands.ts`;depends: project-dev-variables 合入 master
|
||||
- acceptance: `agent_verify: [cargo test] 临时 git 仓库 + 模拟受保护存储:目标被忽略 → 仅两变量行写入、其他行保留、二次调用字节不变;撤销后调用 → 两行被删;目标未被忽略 / 越出工作区 / worktree 属另一仓库 / 账户未填写 → 拒绝且文件字节不变;返回与错误序列化不含账号密码。`
|
||||
- **V13 MCP 投影**(M)— files: `src-tauri/src/mcp_tools.rs`;depends: V12
|
||||
- **V15 MCP 投影**(M)— files: `src-tauri/src/mcp_tools.rs`;depends: V14
|
||||
- acceptance: `agent_verify: [cargo test] tools/list 含 materialize_project_test_account,schema 要求 project_id;跨项目参数被 scope 拒绝;无认证调用可写入但响应只含变量名;仍无任何批量导出或 get_secret 工具。`
|
||||
- **V14 Enterprise worktree 试点**(M)— files: `.blueprint/modules/project-dev-variables.md`, `docs/ai-context/project-context.yaml`, `docs/ai-context/agent-button-system.md`;depends: V13
|
||||
- **V16 Enterprise worktree 试点**(M)— files: `.blueprint/modules/project-dev-variables.md`, `docs/ai-context/project-context.yaml`, `docs/ai-context/agent-button-system.md`;depends: V15
|
||||
- acceptance: `agent_verify: [CLI] Enterprise 新建隔离 worktree(无 .env.agent)→ [MCP] materialize_project_test_account(enterprise, <账户名>, ".env.agent", "AGENT_USERNAME", "AGENT_PASSWORD", worktree) → [CLI] 起本地 API 后 pnpm --silent --filter @enterprise-system/api agent:token exit 0 且 stdout 非空;git status 显示 .env.agent 未被跟踪;会话记录不含账号值。`
|
||||
- `human_verify: 在炼境登记一次后,agent 在新 worktree 中无需向人索要账号。`
|
||||
- 度量:人工传递账号次数(目标 0)、新 worktree 就绪到首次 agent:token 成功的耗时。未达收益按「管家退出」规则撤销。
|
||||
|
||||
@ -16,6 +16,7 @@ description: 读取炼境项目健康状态,并使用炼境 MCP 的项目管
|
||||
5. 交付、Gitea、CI、蓝图等平台状态优先通过 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 保存。
|
||||
8. 需要测试账号或开发变量(登录自验、沙箱 key 等)时,先调用 `list_project_dev_variables` 查找:已配置则用 `read_project_test_account` / `read_project_dev_variable` 单项读取;没有则用 `register_project_test_account` 登记名称、目标系统、非生产环境与用途,并请用户在炼境项目 UI「开发变量」中填写。不得在对话中向用户索要明文密码,不得登记生产账号,不得把读到的值写入受 Git 跟踪的文件。读取因缺少项目认证被拒时(当前仅 Codex 经 helper 验证可读),如实报告并改用项目原有配置方式,不得绕过认证。
|
||||
|
||||
如果 MCP 未出现,按以下顺序降级,不猜测状态:
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"packs": [
|
||||
{
|
||||
"pack_id": "base-onboarding",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"supported_clients": ["codex", "claude"],
|
||||
"project_artifacts": [
|
||||
"AGENTS.md",
|
||||
|
||||
984
src-tauri/src/commands/dev_variables.rs
Normal file
984
src-tauri/src/commands/dev_variables.rs
Normal file
@ -0,0 +1,984 @@
|
||||
//! 项目级开发变量本体。SQLite 只保存元数据和随机引用;值仅驻留受保护存储。
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::credential_store::{
|
||||
delete_references, put_verified, restore_references, CredentialStore,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DevVariableStatus {
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub purpose: String,
|
||||
pub kind: String,
|
||||
pub service: Option<String>,
|
||||
pub environment: Option<String>,
|
||||
pub configured: bool,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TestAccountValue {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// 人在 UI 显式「修改」时取回的原值;仅本机 Tauri 投影使用,MCP 不公开。
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum DevVariableReveal {
|
||||
Scalar { value: String },
|
||||
TestAccount { username: String, password: String },
|
||||
}
|
||||
|
||||
const SCALAR_KIND: &str = "scalar";
|
||||
const TEST_ACCOUNT_KIND: &str = "test_account";
|
||||
|
||||
fn normalized_name(name: &str) -> Result<String, String> {
|
||||
let name = name.trim().to_ascii_uppercase();
|
||||
if name.is_empty()
|
||||
|| name.len() > 64
|
||||
|| !name.bytes().next().unwrap().is_ascii_alphabetic()
|
||||
|| !name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
|
||||
{
|
||||
return Err("开发变量名须为 1–64 位 ASCII 字母、数字或下划线,且以字母开头".into());
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn require_project(conn: &Connection, project_id: &str) -> Result<(), String> {
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM project_workspaces WHERE id=?1)",
|
||||
[project_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|_| "项目状态不可用".to_string())?;
|
||||
if !exists {
|
||||
return Err("项目未登记".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reference(conn: &Connection, project_id: &str, name: &str) -> Result<Option<String>, String> {
|
||||
conn.query_row(
|
||||
"SELECT credential_ref FROM project_dev_variables WHERE project_id=?1 AND name=?2",
|
||||
params![project_id, name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|_| "开发变量未登记".to_string())
|
||||
}
|
||||
|
||||
fn require_kind(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
expected_kind: &str,
|
||||
) -> Result<(), String> {
|
||||
let kind: String = conn
|
||||
.query_row(
|
||||
"SELECT kind FROM project_dev_variables WHERE project_id=?1 AND name=?2",
|
||||
params![project_id, name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|_| "开发需求未登记".to_string())?;
|
||||
if kind != expected_kind {
|
||||
return Err("开发需求类型不匹配".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metadata(value: &str, label: &str, max_chars: usize) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.chars().count() > max_chars || value.chars().any(char::is_control)
|
||||
{
|
||||
return Err(format!("{label}须为 1–{max_chars} 字且不含控制字符"));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
purpose: &str,
|
||||
) -> Result<(), String> {
|
||||
require_project(conn, project_id)?;
|
||||
let name = normalized_name(name)?;
|
||||
let purpose = metadata(purpose, "用途说明", 240)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO project_dev_variables (project_id,name,purpose) VALUES (?1,?2,?3)",
|
||||
params![project_id, name, purpose],
|
||||
)
|
||||
.map_err(|_| "登记开发变量失败".to_string())?;
|
||||
require_kind(conn, project_id, &name, SCALAR_KIND)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_test_account(
|
||||
conn: &Connection,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
service: &str,
|
||||
environment: &str,
|
||||
purpose: &str,
|
||||
) -> Result<(), String> {
|
||||
require_project(conn, project_id)?;
|
||||
let name = normalized_name(name)?;
|
||||
let service = metadata(service, "目标系统", 80)?;
|
||||
let environment = metadata(environment, "测试环境", 80)?;
|
||||
if matches!(
|
||||
environment.to_ascii_lowercase().as_str(),
|
||||
"prod" | "production" | "live"
|
||||
) || environment.contains("生产")
|
||||
{
|
||||
return Err("测试账户不得登记生产环境".into());
|
||||
}
|
||||
let purpose = metadata(purpose, "用途说明", 240)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO project_dev_variables
|
||||
(project_id,name,purpose,kind,service,environment)
|
||||
VALUES (?1,?2,?3,'test_account',?4,?5)",
|
||||
params![project_id, name, purpose, service, environment],
|
||||
)
|
||||
.map_err(|_| "登记测试账户需求失败".to_string())?;
|
||||
let existing: (String, String, Option<String>, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT kind,purpose,service,environment FROM project_dev_variables
|
||||
WHERE project_id=?1 AND name=?2",
|
||||
params![project_id, name],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.map_err(|_| "查询测试账户需求失败".to_string())?;
|
||||
if existing.0 != TEST_ACCOUNT_KIND
|
||||
|| existing.1 != purpose
|
||||
|| existing.2.as_deref() != Some(service.as_str())
|
||||
|| existing.3.as_deref() != Some(environment.as_str())
|
||||
{
|
||||
return Err("同名需求的类型或用途不同,请更换名称".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
conn: &Connection,
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
) -> Result<Vec<DevVariableStatus>, String> {
|
||||
require_project(conn, project_id)?;
|
||||
let mut statement = conn
|
||||
.prepare(
|
||||
"SELECT name,purpose,kind,service,environment,credential_ref,updated_at FROM project_dev_variables
|
||||
WHERE project_id=?1 ORDER BY name",
|
||||
)
|
||||
.map_err(|_| "查询开发变量失败".to_string())?;
|
||||
let rows = statement
|
||||
.query_map([project_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, Option<String>>(4)?,
|
||||
row.get::<_, Option<String>>(5)?,
|
||||
row.get::<_, String>(6)?,
|
||||
))
|
||||
})
|
||||
.map_err(|_| "查询开发变量失败".to_string())?;
|
||||
let mut statuses = Vec::new();
|
||||
for row in rows {
|
||||
let (name, purpose, kind, service, environment, reference, updated_at) =
|
||||
row.map_err(|_| "查询开发变量失败".to_string())?;
|
||||
let configured = match reference {
|
||||
Some(reference) => {
|
||||
let bytes = store
|
||||
.get(&reference)
|
||||
.map_err(|_| "开发变量状态 needs_repair:受保护存储不可用".to_string())?
|
||||
.ok_or_else(|| "开发变量状态 needs_repair:凭证引用缺失".to_string())?;
|
||||
if kind == TEST_ACCOUNT_KIND {
|
||||
serde_json::from_slice::<TestAccountValue>(&bytes)
|
||||
.map(|value| !value.username.is_empty() && !value.password.is_empty())
|
||||
.map_err(|_| "测试账户状态 needs_repair:值格式无效".to_string())?
|
||||
} else {
|
||||
!bytes.is_empty()
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
statuses.push(DevVariableStatus {
|
||||
project_id: project_id.to_string(),
|
||||
name,
|
||||
purpose,
|
||||
kind,
|
||||
service,
|
||||
environment,
|
||||
configured,
|
||||
updated_at,
|
||||
});
|
||||
}
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
pub fn read(
|
||||
conn: &Connection,
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
) -> Result<String, String> {
|
||||
read_value(conn, store, project_id, name, SCALAR_KIND)
|
||||
}
|
||||
|
||||
pub fn read_test_account(
|
||||
conn: &Connection,
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
) -> Result<TestAccountValue, String> {
|
||||
let value = read_value(conn, store, project_id, name, TEST_ACCOUNT_KIND)?;
|
||||
let account = serde_json::from_str::<TestAccountValue>(&value)
|
||||
.map_err(|_| "测试账户状态 needs_repair:值格式无效".to_string())?;
|
||||
if account.username.is_empty() || account.password.is_empty() {
|
||||
return Err("测试账户状态 needs_repair:字段缺失".into());
|
||||
}
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
/// 按登记类型取回原值,复用 agent 读取同一本体,只是投影给人。
|
||||
pub fn reveal(
|
||||
conn: &Connection,
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
) -> Result<DevVariableReveal, String> {
|
||||
require_project(conn, project_id)?;
|
||||
let name = normalized_name(name)?;
|
||||
let kind: String = conn
|
||||
.query_row(
|
||||
"SELECT kind FROM project_dev_variables WHERE project_id=?1 AND name=?2",
|
||||
params![project_id, name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|_| "开发变量未登记".to_string())?;
|
||||
if kind == TEST_ACCOUNT_KIND {
|
||||
let account = read_test_account(conn, store, project_id, &name)?;
|
||||
Ok(DevVariableReveal::TestAccount {
|
||||
username: account.username,
|
||||
password: account.password,
|
||||
})
|
||||
} else {
|
||||
Ok(DevVariableReveal::Scalar {
|
||||
value: read(conn, store, project_id, &name)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn read_value(
|
||||
conn: &Connection,
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
expected_kind: &str,
|
||||
) -> Result<String, String> {
|
||||
require_project(conn, project_id)?;
|
||||
let name = normalized_name(name)?;
|
||||
require_kind(conn, project_id, &name, expected_kind)?;
|
||||
let reference =
|
||||
reference(conn, project_id, &name)?.ok_or_else(|| "开发变量尚未由用户填写".to_string())?;
|
||||
let bytes = store
|
||||
.get(&reference)
|
||||
.map_err(|_| "开发变量状态 needs_repair:受保护存储不可用".to_string())?
|
||||
.ok_or_else(|| "开发变量状态 needs_repair:凭证引用缺失".to_string())?;
|
||||
String::from_utf8(bytes).map_err(|_| "开发变量状态 needs_repair:值编码无效".to_string())
|
||||
}
|
||||
|
||||
pub fn save(
|
||||
conn: &Connection,
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
value: &str,
|
||||
) -> Result<(), String> {
|
||||
save_value(conn, store, project_id, name, value, SCALAR_KIND)
|
||||
}
|
||||
|
||||
pub fn save_test_account(
|
||||
conn: &Connection,
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), String> {
|
||||
if username.is_empty() || password.is_empty() || username.len() > 1024 || password.len() > 1024
|
||||
{
|
||||
return Err("测试账号与密码均须为 1–1024 字节".into());
|
||||
}
|
||||
let value = serde_json::to_string(&TestAccountValue {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
})
|
||||
.map_err(|_| "序列化测试账户失败".to_string())?;
|
||||
save_value(conn, store, project_id, name, &value, TEST_ACCOUNT_KIND)
|
||||
}
|
||||
|
||||
fn save_value(
|
||||
conn: &Connection,
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
value: &str,
|
||||
expected_kind: &str,
|
||||
) -> Result<(), String> {
|
||||
require_project(conn, project_id)?;
|
||||
let name = normalized_name(name)?;
|
||||
require_kind(conn, project_id, &name, expected_kind)?;
|
||||
if value.is_empty() || value.len() > 2560 {
|
||||
return Err("开发变量值长度须为 1–2560 字节".into());
|
||||
}
|
||||
let old_reference = reference(conn, project_id, &name)?;
|
||||
let new_reference = format!("dev-variable/{}", uuid::Uuid::new_v4());
|
||||
put_verified(store, &new_reference, value.as_bytes())?;
|
||||
let result = (|| {
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|_| "保存开发变量失败".to_string())?;
|
||||
let changed = tx
|
||||
.execute(
|
||||
"UPDATE project_dev_variables SET credential_ref=?1, updated_at=datetime('now')
|
||||
WHERE project_id=?2 AND name=?3 AND credential_ref IS ?4",
|
||||
params![new_reference, project_id, name, old_reference],
|
||||
)
|
||||
.map_err(|_| "保存开发变量失败".to_string())?;
|
||||
if changed != 1 {
|
||||
return Err("开发变量状态已变化,请重试".into());
|
||||
}
|
||||
let old_refs: Vec<String> = old_reference.clone().into_iter().collect();
|
||||
let snapshots = delete_references(store, &old_refs)?;
|
||||
if tx.commit().is_err() {
|
||||
restore_references(store, &snapshots)?;
|
||||
return Err("保存开发变量失败:数据库提交失败".into());
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() && store.delete(&new_reference).is_err() {
|
||||
return Err("开发变量状态 needs_repair:新凭证清理失败".into());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn revoke(
|
||||
conn: &Connection,
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
name: &str,
|
||||
) -> Result<(), String> {
|
||||
require_project(conn, project_id)?;
|
||||
let name = normalized_name(name)?;
|
||||
let old_reference = reference(conn, project_id, &name)?;
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|_| "撤销开发变量失败".to_string())?;
|
||||
let changed = tx
|
||||
.execute(
|
||||
"UPDATE project_dev_variables SET credential_ref=NULL, updated_at=datetime('now')
|
||||
WHERE project_id=?1 AND name=?2 AND credential_ref IS ?3",
|
||||
params![project_id, name, old_reference],
|
||||
)
|
||||
.map_err(|_| "撤销开发变量失败".to_string())?;
|
||||
if changed != 1 {
|
||||
return Err("开发变量状态已变化,请重试".into());
|
||||
}
|
||||
let old_refs: Vec<String> = old_reference.into_iter().collect();
|
||||
let snapshots = delete_references(store, &old_refs)?;
|
||||
if tx.commit().is_err() {
|
||||
restore_references(store, &snapshots)?;
|
||||
return Err("撤销开发变量失败:数据库提交失败".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 项目注销前清理所有开发变量引用;删除失败则保留数据库项目记录以便重试。
|
||||
pub fn purge_project(
|
||||
conn: &Connection,
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut statement = conn
|
||||
.prepare("SELECT credential_ref FROM project_dev_variables WHERE project_id=?1 AND credential_ref IS NOT NULL")
|
||||
.map_err(|_| "清理项目开发变量失败".to_string())?;
|
||||
let references: Vec<String> = statement
|
||||
.query_map([project_id], |row| row.get(0))
|
||||
.map_err(|_| "清理项目开发变量失败".to_string())?
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|_| "清理项目开发变量失败".to_string())?;
|
||||
drop(statement);
|
||||
let tx = conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|_| "清理项目开发变量失败".to_string())?;
|
||||
tx.execute(
|
||||
"DELETE FROM project_dev_variables WHERE project_id=?1",
|
||||
[project_id],
|
||||
)
|
||||
.map_err(|_| "清理项目开发变量失败".to_string())?;
|
||||
let snapshots = delete_references(store, &references)?;
|
||||
if tx.commit().is_err() {
|
||||
restore_references(store, &snapshots)?;
|
||||
return Err("清理项目开发变量失败:数据库提交失败".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_project_dev_variables(project_id: String) -> Result<Vec<DevVariableStatus>, String> {
|
||||
let conn = crate::db::pool()
|
||||
.get()
|
||||
.map_err(|_| "数据库不可用".to_string())?;
|
||||
list(
|
||||
&conn,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
&project_id,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_project_dev_variable(
|
||||
project_id: String,
|
||||
name: String,
|
||||
value: String,
|
||||
) -> Result<(), String> {
|
||||
let conn = crate::db::pool()
|
||||
.get()
|
||||
.map_err(|_| "数据库不可用".to_string())?;
|
||||
save(
|
||||
&conn,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
&project_id,
|
||||
&name,
|
||||
&value,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_project_test_account(
|
||||
project_id: String,
|
||||
name: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
let conn = crate::db::pool()
|
||||
.get()
|
||||
.map_err(|_| "数据库不可用".to_string())?;
|
||||
save_test_account(
|
||||
&conn,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
&project_id,
|
||||
&name,
|
||||
&username,
|
||||
&password,
|
||||
)
|
||||
}
|
||||
|
||||
/// 仅供本机 UI 在用户显式点「修改」时调用;MCP 不公开此 command。
|
||||
#[tauri::command]
|
||||
pub fn reveal_project_dev_variable(
|
||||
project_id: String,
|
||||
name: String,
|
||||
) -> Result<DevVariableReveal, String> {
|
||||
let conn = crate::db::pool()
|
||||
.get()
|
||||
.map_err(|_| "数据库不可用".to_string())?;
|
||||
reveal(
|
||||
&conn,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
&project_id,
|
||||
&name,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn revoke_project_dev_variable(project_id: String, name: String) -> Result<(), String> {
|
||||
let conn = crate::db::pool()
|
||||
.get()
|
||||
.map_err(|_| "数据库不可用".to_string())?;
|
||||
revoke(
|
||||
&conn,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
&project_id,
|
||||
&name,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::credential_store::{CredentialStore, MemoryCredentialStore};
|
||||
|
||||
fn fixture() -> Connection {
|
||||
let conn = crate::db::conn_with_schema();
|
||||
conn.execute(
|
||||
"INSERT INTO project_profiles (id,name) VALUES ('profile-1','QA')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_workspaces (id,profile_id) VALUES ('p1','profile-1')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_save_overwrite_rebuild_read_and_revoke_without_list_leak() {
|
||||
let conn = fixture();
|
||||
let store = MemoryCredentialStore::new();
|
||||
register(&conn, "p1", "e2e_password", "仅用于合成验收").unwrap();
|
||||
assert!(!list(&conn, &store, "p1").unwrap()[0].configured);
|
||||
assert!(read(&conn, &store, "p1", "E2E_PASSWORD").is_err());
|
||||
save(&conn, &store, "p1", "E2E_PASSWORD", "qa-only-old-value").unwrap();
|
||||
let old_reference: String = conn
|
||||
.query_row(
|
||||
"SELECT credential_ref FROM project_dev_variables WHERE project_id='p1'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
save(&conn, &store, "p1", "E2E_PASSWORD", "qa-only-new-value").unwrap();
|
||||
assert!(store.get(&old_reference).unwrap().is_none());
|
||||
assert_eq!(
|
||||
read(&conn, &store, "p1", "e2e_password").unwrap(),
|
||||
"qa-only-new-value"
|
||||
);
|
||||
let serialized = serde_json::to_string(&list(&conn, &store, "p1").unwrap()).unwrap();
|
||||
assert!(serialized.contains("\"configured\":true"));
|
||||
assert!(!serialized.contains("qa-only-old-value"));
|
||||
assert!(!serialized.contains("qa-only-new-value"));
|
||||
revoke(&conn, &store, "p1", "E2E_PASSWORD").unwrap();
|
||||
assert!(!list(&conn, &store, "p1").unwrap()[0].configured);
|
||||
assert!(read(&conn, &store, "p1", "E2E_PASSWORD").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_returns_original_value_by_kind_only_for_humans() {
|
||||
let conn = fixture();
|
||||
let store = MemoryCredentialStore::new();
|
||||
register_test_account(&conn, "p1", "AGENT_ADMIN", "api", "local", "自验").unwrap();
|
||||
register(&conn, "p1", "SANDBOX_KEY", "沙箱").unwrap();
|
||||
assert!(reveal(&conn, &store, "p1", "AGENT_ADMIN").is_err());
|
||||
assert!(reveal(&conn, &store, "p1", "MISSING").is_err());
|
||||
|
||||
save_test_account(&conn, &store, "p1", "AGENT_ADMIN", "synthetic-user", "synthetic-pass")
|
||||
.unwrap();
|
||||
save(&conn, &store, "p1", "SANDBOX_KEY", "synthetic-key").unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(reveal(&conn, &store, "p1", "agent_admin").unwrap()).unwrap(),
|
||||
serde_json::json!({"kind": "test_account", "username": "synthetic-user", "password": "synthetic-pass"})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(reveal(&conn, &store, "p1", "SANDBOX_KEY").unwrap()).unwrap(),
|
||||
serde_json::json!({"kind": "scalar", "value": "synthetic-key"})
|
||||
);
|
||||
|
||||
revoke(&conn, &store, "p1", "AGENT_ADMIN").unwrap();
|
||||
assert!(reveal(&conn, &store, "p1", "AGENT_ADMIN").is_err());
|
||||
// 人用投影不得进入 agent 通道。
|
||||
let tools = crate::mcp_tools::tools_list_result().to_string();
|
||||
assert!(!tools.contains("reveal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_account_is_one_idempotent_request_and_one_protected_value() {
|
||||
let conn = fixture();
|
||||
let store = MemoryCredentialStore::new();
|
||||
register_test_account(
|
||||
&conn,
|
||||
"p1",
|
||||
"E2E_LOGIN",
|
||||
"enterprise-system",
|
||||
"test",
|
||||
"登录验收",
|
||||
)
|
||||
.unwrap();
|
||||
register_test_account(
|
||||
&conn,
|
||||
"p1",
|
||||
"e2e_login",
|
||||
"enterprise-system",
|
||||
"test",
|
||||
"登录验收",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(register_test_account(
|
||||
&conn,
|
||||
"p1",
|
||||
"E2E_LOGIN",
|
||||
"another-system",
|
||||
"test",
|
||||
"登录验收"
|
||||
)
|
||||
.is_err());
|
||||
assert!(register_test_account(
|
||||
&conn,
|
||||
"p1",
|
||||
"PROD_LOGIN",
|
||||
"enterprise-system",
|
||||
"production",
|
||||
"登录验收"
|
||||
)
|
||||
.is_err());
|
||||
assert!(register(&conn, "p1", "E2E_LOGIN", "普通变量").is_err());
|
||||
let statuses = list(&conn, &store, "p1").unwrap();
|
||||
assert_eq!(statuses.len(), 1);
|
||||
assert_eq!(statuses[0].kind, TEST_ACCOUNT_KIND);
|
||||
assert_eq!(statuses[0].service.as_deref(), Some("enterprise-system"));
|
||||
assert!(!statuses[0].configured);
|
||||
save_test_account(
|
||||
&conn,
|
||||
&store,
|
||||
"p1",
|
||||
"E2E_LOGIN",
|
||||
"qa-only-user",
|
||||
"qa-only-password",
|
||||
)
|
||||
.unwrap();
|
||||
let status_json = serde_json::to_string(&list(&conn, &store, "p1").unwrap()).unwrap();
|
||||
assert!(!status_json.contains("qa-only-user"));
|
||||
assert!(!status_json.contains("qa-only-password"));
|
||||
assert!(read(&conn, &store, "p1", "E2E_LOGIN").is_err());
|
||||
assert!(save(&conn, &store, "p1", "E2E_LOGIN", "wrong-type").is_err());
|
||||
let account = read_test_account(&conn, &store, "p1", "E2E_LOGIN").unwrap();
|
||||
assert_eq!(account.username, "qa-only-user");
|
||||
assert_eq!(account.password, "qa-only-password");
|
||||
revoke(&conn, &store, "p1", "E2E_LOGIN").unwrap();
|
||||
assert!(read_test_account(&conn, &store, "p1", "E2E_LOGIN").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_test_account_overwrite_preserves_both_old_fields() {
|
||||
let conn = fixture();
|
||||
register_test_account(
|
||||
&conn,
|
||||
"p1",
|
||||
"E2E_LOGIN",
|
||||
"enterprise-system",
|
||||
"test",
|
||||
"登录验收",
|
||||
)
|
||||
.unwrap();
|
||||
let old_ref = "dev-variable/qa-only-account-old";
|
||||
let store = MemoryCredentialStore::failing_delete_for(old_ref);
|
||||
save_test_account(&conn, &store, "p1", "E2E_LOGIN", "old-user", "old-password").unwrap();
|
||||
let generated_ref: String = conn
|
||||
.query_row(
|
||||
"SELECT credential_ref FROM project_dev_variables WHERE project_id='p1' AND name='E2E_LOGIN'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.put(old_ref, &store.get(&generated_ref).unwrap().unwrap())
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE project_dev_variables SET credential_ref=?1 WHERE project_id='p1' AND name='E2E_LOGIN'",
|
||||
[old_ref],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
save_test_account(&conn, &store, "p1", "E2E_LOGIN", "new-user", "new-password")
|
||||
.is_err()
|
||||
);
|
||||
let account = read_test_account(&conn, &store, "p1", "E2E_LOGIN").unwrap();
|
||||
assert_eq!(account.username, "old-user");
|
||||
assert_eq!(account.password, "old-password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_initial_test_account_write_stays_pending() {
|
||||
let conn = fixture();
|
||||
register_test_account(
|
||||
&conn,
|
||||
"p1",
|
||||
"E2E_LOGIN",
|
||||
"enterprise-system",
|
||||
"test",
|
||||
"登录验收",
|
||||
)
|
||||
.unwrap();
|
||||
let store = MemoryCredentialStore::failing();
|
||||
assert!(save_test_account(&conn, &store, "p1", "E2E_LOGIN", "user", "password").is_err());
|
||||
assert!(!list(&conn, &MemoryCredentialStore::new(), "p1").unwrap()[0].configured);
|
||||
let reference: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT credential_ref FROM project_dev_variables WHERE project_id='p1' AND name='E2E_LOGIN'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reference.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_write_or_delete_preserves_old_value_and_reference() {
|
||||
let conn = fixture();
|
||||
register(&conn, "p1", "E2E_PASSWORD", "合成验收").unwrap();
|
||||
let failing_write = MemoryCredentialStore::failing();
|
||||
assert!(save(
|
||||
&conn,
|
||||
&failing_write,
|
||||
"p1",
|
||||
"E2E_PASSWORD",
|
||||
"qa-only-new-value"
|
||||
)
|
||||
.is_err());
|
||||
assert!(!list(&conn, &MemoryCredentialStore::new(), "p1").unwrap()[0].configured);
|
||||
|
||||
let old_ref = "dev-variable/synthetic-old";
|
||||
let failing_delete = MemoryCredentialStore::failing_delete_for(old_ref);
|
||||
failing_delete.put(old_ref, b"qa-only-old-value").unwrap();
|
||||
conn.execute(
|
||||
"UPDATE project_dev_variables SET credential_ref=?1 WHERE project_id='p1' AND name='E2E_PASSWORD'",
|
||||
[old_ref],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(save(
|
||||
&conn,
|
||||
&failing_delete,
|
||||
"p1",
|
||||
"E2E_PASSWORD",
|
||||
"qa-only-new-value"
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
read(&conn, &failing_delete, "p1", "E2E_PASSWORD").unwrap(),
|
||||
"qa-only-old-value"
|
||||
);
|
||||
assert!(revoke(&conn, &failing_delete, "p1", "E2E_PASSWORD").is_err());
|
||||
assert_eq!(
|
||||
read(&conn, &failing_delete, "p1", "E2E_PASSWORD").unwrap(),
|
||||
"qa-only-old-value"
|
||||
);
|
||||
let raw_ref: String = conn
|
||||
.query_row(
|
||||
"SELECT credential_ref FROM project_dev_variables WHERE project_id='p1'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(raw_ref, old_ref);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_project_and_malformed_name() {
|
||||
let conn = fixture();
|
||||
assert!(register(&conn, "missing", "E2E_PASSWORD", "用途").is_err());
|
||||
assert!(register(&conn, "p1", "../SECRET", "用途").is_err());
|
||||
assert!(register(&conn, "p1", "E2E_PASSWORD", "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_purge_deletes_values_and_refuses_failed_store_delete() {
|
||||
let conn = fixture();
|
||||
let store = MemoryCredentialStore::new();
|
||||
register(&conn, "p1", "E2E_PASSWORD", "合成验收").unwrap();
|
||||
save(&conn, &store, "p1", "E2E_PASSWORD", "qa-only-purge-value").unwrap();
|
||||
purge_project(&conn, &store, "p1").unwrap();
|
||||
assert!(list(&conn, &store, "p1").unwrap().is_empty());
|
||||
assert!(!store.contains_value(b"qa-only-purge-value"));
|
||||
|
||||
register(&conn, "p1", "E2E_PASSWORD", "合成验收").unwrap();
|
||||
let reference = "dev-variable/qa-only-failed-delete";
|
||||
let failing = MemoryCredentialStore::failing_delete_for(reference);
|
||||
failing.put(reference, b"qa-only-keep-value").unwrap();
|
||||
conn.execute(
|
||||
"UPDATE project_dev_variables SET credential_ref=?1 WHERE project_id='p1'",
|
||||
[reference],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(purge_project(&conn, &failing, "p1").is_err());
|
||||
assert_eq!(
|
||||
read(&conn, &failing, "p1", "E2E_PASSWORD").unwrap(),
|
||||
"qa-only-keep-value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_protected_value_survive_database_reopen() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("lianjing-dev-variable-{}.db", uuid::Uuid::new_v4()));
|
||||
let store = MemoryCredentialStore::new();
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
|
||||
crate::db::migrate(&conn);
|
||||
conn.execute(
|
||||
"INSERT INTO project_profiles (id,name) VALUES ('profile-1','QA')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_workspaces (id,profile_id) VALUES ('p1','profile-1')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
register(&conn, "p1", "E2E_PASSWORD", "合成验收").unwrap();
|
||||
save(&conn, &store, "p1", "E2E_PASSWORD", "qa-only-reopen-value").unwrap();
|
||||
}
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
crate::db::migrate(&conn);
|
||||
assert_eq!(
|
||||
read(&conn, &store, "p1", "E2E_PASSWORD").unwrap(),
|
||||
"qa-only-reopen-value"
|
||||
);
|
||||
revoke(&conn, &store, "p1", "E2E_PASSWORD").unwrap();
|
||||
assert!(read(&conn, &store, "p1", "E2E_PASSWORD").is_err());
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_account_can_be_reused_after_database_reopen() {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("lianjing-test-account-{}.db", uuid::Uuid::new_v4()));
|
||||
let store = MemoryCredentialStore::new();
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
|
||||
crate::db::migrate(&conn);
|
||||
conn.execute(
|
||||
"INSERT INTO project_profiles (id,name) VALUES ('profile-1','QA')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_workspaces (id,profile_id) VALUES ('p1','profile-1')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
register_test_account(
|
||||
&conn,
|
||||
"p1",
|
||||
"E2E_LOGIN",
|
||||
"enterprise-system",
|
||||
"test",
|
||||
"登录验收",
|
||||
)
|
||||
.unwrap();
|
||||
save_test_account(
|
||||
&conn,
|
||||
&store,
|
||||
"p1",
|
||||
"E2E_LOGIN",
|
||||
"reused-user",
|
||||
"reused-password",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
crate::db::migrate(&conn);
|
||||
let statuses = list(&conn, &store, "p1").unwrap();
|
||||
assert_eq!(statuses.len(), 1);
|
||||
assert!(statuses[0].configured);
|
||||
let account = read_test_account(&conn, &store, "p1", "E2E_LOGIN").unwrap();
|
||||
assert_eq!(account.username, "reused-user");
|
||||
assert_eq!(account.password, "reused-password");
|
||||
revoke(&conn, &store, "p1", "E2E_LOGIN").unwrap();
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn tauri_commands_use_same_state_and_never_return_value_in_list() {
|
||||
crate::db::init_test_pool();
|
||||
let project_id = format!("qa-only-dev-var-{}", uuid::Uuid::new_v4());
|
||||
let profile_id = format!("qa-only-profile-{}", uuid::Uuid::new_v4());
|
||||
{
|
||||
let conn = crate::db::pool().get().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_profiles (id,name) VALUES (?1,'QA')",
|
||||
[&profile_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_workspaces (id,profile_id) VALUES (?1,?2)",
|
||||
params![project_id, profile_id],
|
||||
)
|
||||
.unwrap();
|
||||
register(&conn, &project_id, "E2E_PASSWORD", "合成验收").unwrap();
|
||||
}
|
||||
assert!(!list_project_dev_variables(project_id.clone()).unwrap()[0].configured);
|
||||
save_project_dev_variable(
|
||||
project_id.clone(),
|
||||
"E2E_PASSWORD".into(),
|
||||
"qa-only-command-value".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let statuses = list_project_dev_variables(project_id.clone()).unwrap();
|
||||
assert!(statuses[0].configured);
|
||||
assert!(!serde_json::to_string(&statuses)
|
||||
.unwrap()
|
||||
.contains("qa-only-command-value"));
|
||||
revoke_project_dev_variable(project_id.clone(), "E2E_PASSWORD".into()).unwrap();
|
||||
assert!(!list_project_dev_variables(project_id.clone()).unwrap()[0].configured);
|
||||
let conn = crate::db::pool().get().unwrap();
|
||||
conn.execute("DELETE FROM project_workspaces WHERE id=?1", [&project_id])
|
||||
.unwrap();
|
||||
conn.execute("DELETE FROM project_profiles WHERE id=?1", [&profile_id])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn tauri_test_account_command_uses_same_state_without_listing_values() {
|
||||
crate::db::init_test_pool();
|
||||
let project_id = format!("qa-only-account-{}", uuid::Uuid::new_v4());
|
||||
let profile_id = format!("qa-only-profile-{}", uuid::Uuid::new_v4());
|
||||
{
|
||||
let conn = crate::db::pool().get().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_profiles (id,name) VALUES (?1,'QA')",
|
||||
[&profile_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_workspaces (id,profile_id) VALUES (?1,?2)",
|
||||
params![project_id, profile_id],
|
||||
)
|
||||
.unwrap();
|
||||
register_test_account(
|
||||
&conn,
|
||||
&project_id,
|
||||
"E2E_LOGIN",
|
||||
"enterprise-system",
|
||||
"test",
|
||||
"合成登录验收",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(!list_project_dev_variables(project_id.clone()).unwrap()[0].configured);
|
||||
save_project_test_account(
|
||||
project_id.clone(),
|
||||
"E2E_LOGIN".into(),
|
||||
"qa-only-command-user".into(),
|
||||
"qa-only-command-password".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let statuses = list_project_dev_variables(project_id.clone()).unwrap();
|
||||
assert!(statuses[0].configured);
|
||||
let serialized = serde_json::to_string(&statuses).unwrap();
|
||||
assert!(!serialized.contains("qa-only-command-user"));
|
||||
assert!(!serialized.contains("qa-only-command-password"));
|
||||
revoke_project_dev_variable(project_id.clone(), "E2E_LOGIN".into()).unwrap();
|
||||
assert!(!list_project_dev_variables(project_id.clone()).unwrap()[0].configured);
|
||||
let conn = crate::db::pool().get().unwrap();
|
||||
conn.execute("DELETE FROM project_workspaces WHERE id=?1", [&project_id])
|
||||
.unwrap();
|
||||
conn.execute("DELETE FROM project_profiles WHERE id=?1", [&profile_id])
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ pub mod cloud_db_databases;
|
||||
pub mod cloud_products;
|
||||
pub mod commit_metrics;
|
||||
pub mod delivery_status;
|
||||
pub mod dev_variables;
|
||||
pub mod devtools;
|
||||
pub mod docker_apps;
|
||||
pub mod git_ops;
|
||||
|
||||
@ -26,7 +26,7 @@ const TMPL_CAPABILITY_PACKS: &str =
|
||||
const TMPL_MISE: &str = include_str!("../../resources/onboarding/mise.toml.tmpl");
|
||||
const PROJECT_CONTEXT_SCHEMA_VERSION: u64 = 1;
|
||||
const CAPABILITY_PACK_SCHEMA_VERSION: u64 = 1;
|
||||
const BASE_ONBOARDING_PACK_VERSION: &str = "1.4.0";
|
||||
const BASE_ONBOARDING_PACK_VERSION: &str = "1.5.0";
|
||||
const TOOLCHAIN_PACK_VERSION: &str = "1.0.0";
|
||||
|
||||
/// 单个模板的分发结果。
|
||||
|
||||
@ -325,6 +325,12 @@ pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), Stri
|
||||
)
|
||||
.unwrap_or((None, None, None));
|
||||
|
||||
crate::commands::dev_variables::purge_project(
|
||||
&conn,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
&id,
|
||||
)?;
|
||||
|
||||
// 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径)
|
||||
if let Err(e) = mcp_inject::remove_project_mcp(&id) {
|
||||
eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e);
|
||||
|
||||
@ -54,6 +54,75 @@ pub trait CredentialStore {
|
||||
fn delete(&self, reference: &str) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// 项目认证材料只用于 MCP 客户端的本机 HTTP 认证;digest 可写入项目配置,token 不可。
|
||||
pub fn mcp_auth_digest(project_id: &str) -> Result<String, String> {
|
||||
if project_id.trim().is_empty() {
|
||||
return Err("项目 ID 不能为空".into());
|
||||
}
|
||||
use sha2::Digest;
|
||||
let digest = sha2::Sha256::digest(project_id.as_bytes());
|
||||
Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect())
|
||||
}
|
||||
|
||||
fn mcp_auth_reference_for_digest(digest: &str) -> Result<String, String> {
|
||||
if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err("MCP 认证引用格式无效".into());
|
||||
}
|
||||
Ok(format!("mcp-client-auth/{digest}"))
|
||||
}
|
||||
|
||||
fn decode_mcp_auth_token(bytes: Vec<u8>) -> Result<String, String> {
|
||||
let token = String::from_utf8(bytes).map_err(|_| "MCP 认证材料 needs_repair".to_string())?;
|
||||
if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return Err("MCP 认证材料 needs_repair".into());
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn get_or_create_mcp_auth_token(
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let digest = mcp_auth_digest(project_id)?;
|
||||
let reference = mcp_auth_reference_for_digest(&digest)?;
|
||||
if let Some(bytes) = store
|
||||
.get(&reference)
|
||||
.map_err(|_| "MCP 认证材料 needs_repair:受保护存储不可用".to_string())?
|
||||
{
|
||||
return decode_mcp_auth_token(bytes);
|
||||
}
|
||||
let token = format!(
|
||||
"{}{}",
|
||||
uuid::Uuid::new_v4().simple(),
|
||||
uuid::Uuid::new_v4().simple()
|
||||
);
|
||||
put_verified(store, &reference, token.as_bytes())?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn read_mcp_auth_token_for_digest(
|
||||
store: &impl CredentialStore,
|
||||
digest: &str,
|
||||
) -> Result<String, String> {
|
||||
let reference = mcp_auth_reference_for_digest(digest)?;
|
||||
let bytes = store
|
||||
.get(&reference)
|
||||
.map_err(|_| "MCP 认证材料 needs_repair:受保护存储不可用".to_string())?
|
||||
.ok_or_else(|| "MCP 认证材料未配置".to_string())?;
|
||||
decode_mcp_auth_token(bytes)
|
||||
}
|
||||
|
||||
pub fn delete_mcp_auth_token_for_project(
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let digest = mcp_auth_digest(project_id)?;
|
||||
let reference = mcp_auth_reference_for_digest(&digest)?;
|
||||
store
|
||||
.delete(&reference)
|
||||
.map_err(|_| "MCP 认证材料 needs_repair:删除失败".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum LegacyCredentialKind {
|
||||
GiteaAccessToken,
|
||||
@ -486,7 +555,9 @@ impl CredentialStore for MemoryCredentialStore {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
get_or_migrate_legacy, CredentialStore, LegacyCredentialKind, MemoryCredentialStore,
|
||||
get_or_create_mcp_auth_token, get_or_migrate_legacy, mcp_auth_digest,
|
||||
read_mcp_auth_token_for_digest, CredentialStore, LegacyCredentialKind,
|
||||
MemoryCredentialStore,
|
||||
};
|
||||
use rusqlite::Connection;
|
||||
|
||||
@ -523,6 +594,25 @@ mod tests {
|
||||
assert!(!error.contains("do-not-print-this"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_auth_token_is_stable_per_project_and_never_in_reference() {
|
||||
let store = MemoryCredentialStore::new();
|
||||
let p1 = get_or_create_mcp_auth_token(&store, "p1").unwrap();
|
||||
let p2 = get_or_create_mcp_auth_token(&store, "p2").unwrap();
|
||||
let digest = mcp_auth_digest("p1").unwrap();
|
||||
assert_eq!(p1.len(), 64);
|
||||
assert_ne!(p1, p2);
|
||||
assert_eq!(get_or_create_mcp_auth_token(&store, "p1").unwrap(), p1);
|
||||
assert_eq!(read_mcp_auth_token_for_digest(&store, &digest).unwrap(), p1);
|
||||
assert!(!digest.contains(&p1));
|
||||
assert!(read_mcp_auth_token_for_digest(&store, "invalid").is_err());
|
||||
assert!(
|
||||
get_or_create_mcp_auth_token(&MemoryCredentialStore::failing(), "p1")
|
||||
.unwrap_err()
|
||||
.contains("受保护存储")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_migration_verifies_then_switches_reference_and_clears_old_value() {
|
||||
let conn = migration_db();
|
||||
@ -690,8 +780,20 @@ mod tests {
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod windows_tests {
|
||||
use super::{windows_store, CredentialStore, WindowsCredentialStore};
|
||||
use std::process::Command;
|
||||
use super::{
|
||||
get_or_create_mcp_auth_token, mcp_auth_digest, mcp_auth_reference_for_digest,
|
||||
windows_store, CredentialStore, WindowsCredentialStore,
|
||||
};
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
process::Command,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const CHILD_ENV: &str = "DEV_MANAGER_CREDENTIAL_STORE_CHILD_TEST";
|
||||
|
||||
@ -722,6 +824,115 @@ mod windows_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "先运行 cargo build 构建同分支的真实可执行文件"]
|
||||
fn mcp_auth_header_helper_reads_windows_store_and_fails_closed() {
|
||||
let project_id = format!("qa-only-mcp-auth-{}", uuid::Uuid::new_v4());
|
||||
let digest = mcp_auth_digest(&project_id).unwrap();
|
||||
let store = WindowsCredentialStore;
|
||||
let token = get_or_create_mcp_auth_token(&store, &project_id).unwrap();
|
||||
let executable = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("target/debug/dev-manager-tauri.exe");
|
||||
let configured = Command::new(&executable)
|
||||
.args(["--lianjing-mcp-header", &digest])
|
||||
.output();
|
||||
let missing_digest = mcp_auth_digest("qa-only-missing-project").unwrap();
|
||||
let missing = Command::new(&executable)
|
||||
.args(["--lianjing-mcp-header", &missing_digest])
|
||||
.output();
|
||||
let cleanup = store.delete(&mcp_auth_reference_for_digest(&digest).unwrap());
|
||||
assert!(cleanup.is_ok(), "测试认证材料清理失败");
|
||||
let configured = configured.expect("无法启动真实 helper 可执行文件");
|
||||
assert!(configured.status.success());
|
||||
let headers: serde_json::Value = serde_json::from_slice(&configured.stdout).unwrap();
|
||||
let expected = format!("Bearer {token}");
|
||||
assert!(headers["Authorization"].as_str() == Some(expected.as_str()));
|
||||
let missing = missing.expect("无法启动真实 helper 可执行文件");
|
||||
assert!(!missing.status.success());
|
||||
assert!(missing.stdout.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "需已登录 Codex CLI,先 cargo build;只使用随机合成认证材料"]
|
||||
fn codex_client_reads_windows_helper_header_across_sessions() {
|
||||
let project_id = format!("qa-only-codex-auth-{}", uuid::Uuid::new_v4());
|
||||
let digest = mcp_auth_digest(&project_id).unwrap();
|
||||
let reference = mcp_auth_reference_for_digest(&digest).unwrap();
|
||||
let store = WindowsCredentialStore;
|
||||
let token = get_or_create_mcp_auth_token(&store, &project_id).unwrap();
|
||||
let executable = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("target/debug/dev-manager-tauri.exe");
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let observed = Arc::new(AtomicUsize::new(0));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let observed_for_server = Arc::clone(&observed);
|
||||
let stop_for_server = Arc::clone(&stop);
|
||||
let server = std::thread::spawn(move || {
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
while !stop_for_server.load(Ordering::SeqCst) && Instant::now() < deadline {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.unwrap();
|
||||
let mut bytes = [0u8; 8192];
|
||||
let count = stream.read(&mut bytes).unwrap_or(0);
|
||||
let request = String::from_utf8_lossy(&bytes[..count]);
|
||||
let expected = format!("authorization: bearer {token}");
|
||||
if request.to_ascii_lowercase().contains(&expected) {
|
||||
observed_for_server.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
let _ = stream.write_all(b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
let helper = format!(
|
||||
"\"{}\" --lianjing-mcp-header {digest}",
|
||||
executable.display()
|
||||
);
|
||||
let url_config = format!("mcp_servers.qa_only_auth.url=\"http://127.0.0.1:{port}/mcp\"");
|
||||
let helper_config = format!(
|
||||
"mcp_servers.qa_only_auth.http_headers_helper={}",
|
||||
serde_json::to_string(&helper).unwrap()
|
||||
);
|
||||
let mut client_statuses = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let status = Command::new("codex")
|
||||
.args([
|
||||
"exec",
|
||||
"--skip-git-repo-check",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
"-c",
|
||||
&url_config,
|
||||
"-c",
|
||||
&helper_config,
|
||||
"Reply OK.",
|
||||
])
|
||||
.output();
|
||||
client_statuses.push(status.map(|result| result.status));
|
||||
}
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
server.join().unwrap();
|
||||
let cleanup = store.delete(&reference);
|
||||
assert!(cleanup.is_ok(), "测试认证材料清理失败");
|
||||
assert!(
|
||||
client_statuses.iter().all(Result::is_ok),
|
||||
"Codex CLI 未能启动"
|
||||
);
|
||||
assert!(
|
||||
observed.load(Ordering::SeqCst) >= 2,
|
||||
"两次 Codex 会话未分别通过真实 helper 发送认证头"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn credential_child_probe() {
|
||||
|
||||
@ -32,7 +32,7 @@ pub(crate) fn try_pool() -> Option<&'static DbPool> {
|
||||
POOL.get()
|
||||
}
|
||||
|
||||
fn migrate(conn: &rusqlite::Connection) {
|
||||
pub(crate) fn migrate(conn: &rusqlite::Connection) {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
@ -1057,6 +1057,33 @@ fn migrate(conn: &rusqlite::Connection) {
|
||||
"ALTER TABLE gitea_repos ADD COLUMN webhook_secret_ref TEXT",
|
||||
[],
|
||||
);
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS project_dev_variables (
|
||||
project_id TEXT NOT NULL REFERENCES project_workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
credential_ref TEXT,
|
||||
kind TEXT NOT NULL DEFAULT 'scalar',
|
||||
service TEXT,
|
||||
environment TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (project_id, name)
|
||||
);",
|
||||
)
|
||||
.expect("migrate project dev variables");
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE project_dev_variables ADD COLUMN kind TEXT NOT NULL DEFAULT 'scalar'",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE project_dev_variables ADD COLUMN service TEXT",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE project_dev_variables ADD COLUMN environment TEXT",
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -1144,6 +1171,53 @@ mod tests {
|
||||
migrate(&conn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_variable_migration_adds_metadata_table_to_existing_database() {
|
||||
let conn = conn_with_schema();
|
||||
conn.execute("DROP TABLE project_dev_variables", [])
|
||||
.unwrap();
|
||||
migrate(&conn);
|
||||
let columns: Vec<String> = conn
|
||||
.prepare("PRAGMA table_info(project_dev_variables)")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get(1))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
assert!(columns.contains(&"credential_ref".to_string()));
|
||||
assert!(!columns.contains(&"value".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_account_columns_upgrade_existing_dev_variable_rows() {
|
||||
let conn = conn_with_schema();
|
||||
conn.execute_batch(
|
||||
"DROP TABLE project_dev_variables;
|
||||
CREATE TABLE project_dev_variables (
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
credential_ref TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (project_id, name)
|
||||
);
|
||||
INSERT INTO project_dev_variables (project_id,name,purpose)
|
||||
VALUES ('p1','LEGACY_VALUE','legacy');",
|
||||
)
|
||||
.unwrap();
|
||||
migrate(&conn);
|
||||
migrate(&conn);
|
||||
let row: (String, Option<String>, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT kind,service,environment FROM project_dev_variables
|
||||
WHERE project_id='p1' AND name='LEGACY_VALUE'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(row, ("scalar".to_string(), None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_state_touch_then_read() {
|
||||
let conn = conn_with_schema();
|
||||
|
||||
@ -8,6 +8,16 @@ mod mcp_webhook;
|
||||
mod models;
|
||||
use git2;
|
||||
|
||||
/// Codex 的 http_headers_helper 入口;只被本机同一可执行文件的专用参数调用。
|
||||
pub fn mcp_client_auth_headers_json(project_digest: &str) -> Result<String, String> {
|
||||
let token = credential_store::read_mcp_auth_token_for_digest(
|
||||
&credential_store::WindowsCredentialStore,
|
||||
project_digest,
|
||||
)?;
|
||||
serde_json::to_string(&serde_json::json!({ "Authorization": format!("Bearer {token}") }))
|
||||
.map_err(|_| "MCP 认证头生成失败".to_string())
|
||||
}
|
||||
|
||||
/// 创建不弹 CMD 窗口的子进程(Windows 专用 CREATE_NO_WINDOW 标志)。
|
||||
/// 所有后台静默命令都应通过此函数创建,避免在 Windows 上频繁闪出黑框。
|
||||
pub(crate) fn silent_cmd(program: &str) -> std::process::Command {
|
||||
@ -392,6 +402,12 @@ pub fn run() {
|
||||
commands::api_keys::edit_api_key,
|
||||
commands::api_keys::delete_api_key,
|
||||
commands::api_keys::reveal_api_key,
|
||||
// project development variables (human filling; no reveal command)
|
||||
commands::dev_variables::list_project_dev_variables,
|
||||
commands::dev_variables::save_project_dev_variable,
|
||||
commands::dev_variables::save_project_test_account,
|
||||
commands::dev_variables::reveal_project_dev_variable,
|
||||
commands::dev_variables::revoke_project_dev_variable,
|
||||
// host apps
|
||||
commands::host_apps::get_host_apps,
|
||||
commands::host_apps::add_host_app,
|
||||
|
||||
@ -2,5 +2,22 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args();
|
||||
let _ = args.next();
|
||||
if args.next().as_deref() == Some("--lianjing-mcp-header") {
|
||||
let Some(digest) = args.next() else {
|
||||
std::process::exit(1);
|
||||
};
|
||||
if args.next().is_some() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
match dev_manager_tauri_lib::mcp_client_auth_headers_json(&digest) {
|
||||
Ok(headers) => {
|
||||
println!("{headers}");
|
||||
return;
|
||||
}
|
||||
Err(_) => std::process::exit(1),
|
||||
}
|
||||
}
|
||||
dev_manager_tauri_lib::run()
|
||||
}
|
||||
|
||||
@ -375,9 +375,15 @@ fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
|
||||
std::fs::write(path, content).map_err(|e| format!("写入 settings.json 失败: {}", e))
|
||||
}
|
||||
|
||||
fn upsert_codex_mcp_config(path: &PathBuf, server_url: &str) -> Result<(), String> {
|
||||
fn upsert_codex_mcp_config(
|
||||
path: &PathBuf,
|
||||
server_url: &str,
|
||||
header_helper: &str,
|
||||
) -> Result<(), String> {
|
||||
let helper_literal = serde_json::to_string(header_helper)
|
||||
.map_err(|_| "MCP helper 配置序列化失败".to_string())?;
|
||||
let block = format!(
|
||||
"{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\n{CODEX_MCP_MANAGED_END}\n"
|
||||
"{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\nhttp_headers_helper = {helper_literal}\n{CODEX_MCP_MANAGED_END}\n"
|
||||
);
|
||||
let current = match std::fs::read_to_string(path) {
|
||||
Ok(value) => value,
|
||||
@ -460,8 +466,19 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> {
|
||||
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 digest = crate::credential_store::mcp_auth_digest(project_id)?;
|
||||
crate::credential_store::get_or_create_mcp_auth_token(
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
project_id,
|
||||
)?;
|
||||
let executable = std::env::current_exe()
|
||||
.map_err(|_| "无法定位炼境可执行文件,拒绝写入 MCP 认证配置".to_string())?;
|
||||
let header_helper = format!(
|
||||
"\"{}\" --lianjing-mcp-header {digest}",
|
||||
executable.display()
|
||||
);
|
||||
write_canonical_context(project_id)?;
|
||||
upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url)?;
|
||||
upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url, &header_helper)?;
|
||||
let path = settings_path(project_id)?;
|
||||
let mut settings = read_json(&path)?;
|
||||
|
||||
@ -501,20 +518,21 @@ pub fn inject_project_mcp(project_id: &str) -> Result<(), String> {
|
||||
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
|
||||
remove_codex_mcp_config(&codex_config_path(project_id)?)?;
|
||||
let path = settings_path(project_id)?;
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
if path.exists() {
|
||||
let mut settings = read_json(&path)?;
|
||||
if let Some(servers) = settings
|
||||
.get_mut("mcpServers")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
servers.remove(MCP_KEY);
|
||||
servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
|
||||
}
|
||||
write_json(&path, &settings)?;
|
||||
}
|
||||
let mut settings = read_json(&path)?;
|
||||
|
||||
if let Some(servers) = settings
|
||||
.get_mut("mcpServers")
|
||||
.and_then(|v| v.as_object_mut())
|
||||
{
|
||||
servers.remove(MCP_KEY);
|
||||
servers.retain(|k, _| !k.starts_with(LEGACY_KEY_PREFIX));
|
||||
}
|
||||
|
||||
write_json(&path, &settings)
|
||||
crate::credential_store::delete_mcp_auth_token_for_project(
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
project_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// 单一服务器化一次性迁移:批量重注入所有托管项目(换新 key + 清旧 key + liangjing.json 瘦身)
|
||||
@ -627,14 +645,21 @@ mod tests {
|
||||
let path = base.join(".codex/config.toml");
|
||||
|
||||
std::fs::write(&path, "model = \"gpt-6\"\n").unwrap();
|
||||
upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp").unwrap();
|
||||
let helper = "\"C:/Lianjing.exe\" --lianjing-mcp-header qa-only-digest";
|
||||
upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp", helper).unwrap();
|
||||
let configured = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(configured.contains("model = \"gpt-6\""));
|
||||
assert!(configured.contains("[mcp_servers.lian-jing]"));
|
||||
assert!(configured.contains("url = \"http://127.0.0.1:27190/mcp\""));
|
||||
assert!(configured.contains("http_headers_helper"));
|
||||
assert!(!configured.contains("qa-only-synthetic-mcp-token"));
|
||||
|
||||
upsert_codex_mcp_config(&path, "http://127.0.0.1:27190/mcp/projects/project%20one")
|
||||
.unwrap();
|
||||
upsert_codex_mcp_config(
|
||||
&path,
|
||||
"http://127.0.0.1:27190/mcp/projects/project%20one",
|
||||
helper,
|
||||
)
|
||||
.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\""));
|
||||
@ -650,7 +675,8 @@ mod tests {
|
||||
std::fs::write(&path, manual).unwrap();
|
||||
assert!(upsert_codex_mcp_config(
|
||||
&path,
|
||||
"http://127.0.0.1:27190/mcp/projects/project%20one"
|
||||
"http://127.0.0.1:27190/mcp/projects/project%20one",
|
||||
helper,
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), manual);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
http::{header::AUTHORIZATION, HeaderMap, StatusCode},
|
||||
response::{
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
IntoResponse, Response,
|
||||
@ -30,7 +30,9 @@ struct SessionEntry {
|
||||
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 不得当作成功。";
|
||||
const DEV_VARIABLE_READ_TOOL: &str = "read_project_dev_variable";
|
||||
const TEST_ACCOUNT_READ_TOOL: &str = "read_project_test_account";
|
||||
const MCP_SERVER_INSTRUCTIONS: &str = "先读取 get_diagnostics、verify_onboarding 或项目状态,再执行后续操作。项目级工具必须使用当前项目的 canonical project_id,不得跨项目猜测或改写身份。测试需要登录时先查询 list_project_dev_variables,已有需求就复用;未登记时可登记不含秘密的开发需求元数据,账号与密码只由用户在项目 UI 填写。其他写操作仅在用户明确确认目标与影响后调用;状态 unavailable/unknown 不得当作成功。";
|
||||
static INITIALIZED_PROJECT_CLIENTS: OnceLock<RwLock<HashSet<String>>> = OnceLock::new();
|
||||
|
||||
fn initialized_project_clients() -> &'static RwLock<HashSet<String>> {
|
||||
@ -195,6 +197,63 @@ struct SessionQuery {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
/// 仅敏感单项读取要求项目级认证;旧的非敏感 MCP 工具保持原有契约。
|
||||
fn authorize_sensitive_request(
|
||||
req: &RpcRequest,
|
||||
project_scope: Option<&str>,
|
||||
headers: &HeaderMap,
|
||||
store: &impl crate::credential_store::CredentialStore,
|
||||
) -> Result<(), &'static str> {
|
||||
let requested_tool = req
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|params| params.get("name"))
|
||||
.and_then(Value::as_str);
|
||||
if req.method != "tools/call"
|
||||
|| !matches!(
|
||||
requested_tool,
|
||||
Some(DEV_VARIABLE_READ_TOOL | TEST_ACCOUNT_READ_TOOL)
|
||||
)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let project_id = project_scope.ok_or("敏感工具仅允许项目级 MCP 连接")?;
|
||||
let requested_project = req
|
||||
.params
|
||||
.as_ref()
|
||||
.and_then(|params| params.get("arguments"))
|
||||
.and_then(|args| args.get("project_id"))
|
||||
.and_then(Value::as_str);
|
||||
if requested_project.is_some_and(|requested| requested != project_id) {
|
||||
return Err("项目作用域不匹配");
|
||||
}
|
||||
let mut auth_values = headers.get_all(AUTHORIZATION).iter();
|
||||
let header = auth_values
|
||||
.next()
|
||||
.and_then(|header| header.to_str().ok())
|
||||
.ok_or("缺少 MCP 客户端认证")?;
|
||||
if auth_values.next().is_some() {
|
||||
return Err("MCP 客户端认证格式无效");
|
||||
}
|
||||
let provided = header
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or("MCP 客户端认证格式无效")?;
|
||||
let digest =
|
||||
crate::credential_store::mcp_auth_digest(project_id).map_err(|_| "MCP 项目认证不可用")?;
|
||||
let expected = crate::credential_store::read_mcp_auth_token_for_digest(store, &digest)
|
||||
.map_err(|_| "MCP 项目认证不可用")?;
|
||||
let equal = provided.len() == expected.len()
|
||||
&& provided
|
||||
.bytes()
|
||||
.zip(expected.bytes())
|
||||
.fold(0u8, |difference, (left, right)| difference | (left ^ right))
|
||||
== 0;
|
||||
if !equal {
|
||||
return Err("MCP 客户端认证失败");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── 共享 RPC 处理逻辑 ─────────────────────────────────────────────────────────
|
||||
|
||||
async fn process_rpc(req: &RpcRequest, project_scope: Option<&str>) -> Result<Value, String> {
|
||||
@ -234,7 +293,17 @@ async fn process_rpc(req: &RpcRequest, project_scope: Option<&str>) -> Result<Va
|
||||
|
||||
// ── Streamable HTTP(POST)─────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_mcp(Json(req): Json<RpcRequest>) -> Response {
|
||||
async fn handle_mcp(headers: HeaderMap, Json(req): Json<RpcRequest>) -> Response {
|
||||
if authorize_sensitive_request(
|
||||
&req,
|
||||
None,
|
||||
&headers,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if req.id.is_none() {
|
||||
return StatusCode::ACCEPTED.into_response();
|
||||
}
|
||||
@ -251,8 +320,19 @@ async fn handle_mcp(Json(req): Json<RpcRequest>) -> Response {
|
||||
|
||||
async fn handle_scoped_mcp(
|
||||
Path(project_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RpcRequest>,
|
||||
) -> Response {
|
||||
if authorize_sensitive_request(
|
||||
&req,
|
||||
Some(&project_id),
|
||||
&headers,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if req.id.is_none() {
|
||||
return StatusCode::ACCEPTED.into_response();
|
||||
}
|
||||
@ -318,6 +398,7 @@ async fn handle_scoped_sse_connect(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::credential_store::{get_or_create_mcp_auth_token, MemoryCredentialStore};
|
||||
|
||||
fn rpc(method: &str) -> RpcRequest {
|
||||
RpcRequest {
|
||||
@ -328,6 +409,66 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_read_requires_matching_project_bearer_on_both_transports() {
|
||||
let store = MemoryCredentialStore::new();
|
||||
let p1 = get_or_create_mcp_auth_token(&store, "p1").unwrap();
|
||||
let _p2 = get_or_create_mcp_auth_token(&store, "p2").unwrap();
|
||||
let req = RpcRequest {
|
||||
jsonrpc: "2.0".into(),
|
||||
method: "tools/call".into(),
|
||||
id: Some(json!(1)),
|
||||
params: Some(json!({
|
||||
"name": DEV_VARIABLE_READ_TOOL,
|
||||
"arguments": {"project_id": "p1", "name": "E2E_PASSWORD"}
|
||||
})),
|
||||
};
|
||||
let mut headers = HeaderMap::new();
|
||||
assert!(authorize_sensitive_request(&req, Some("p1"), &headers, &store).is_err());
|
||||
headers.insert(AUTHORIZATION, "Bearer wrong".parse().unwrap());
|
||||
assert!(authorize_sensitive_request(&req, Some("p1"), &headers, &store).is_err());
|
||||
headers.insert(AUTHORIZATION, format!("Bearer {p1}").parse().unwrap());
|
||||
assert!(authorize_sensitive_request(&req, Some("p1"), &headers, &store).is_ok());
|
||||
assert!(authorize_sensitive_request(&req, Some("p2"), &headers, &store).is_err());
|
||||
assert!(authorize_sensitive_request(&req, None, &headers, &store).is_err());
|
||||
|
||||
let test_account = RpcRequest {
|
||||
params: Some(json!({
|
||||
"name": TEST_ACCOUNT_READ_TOOL,
|
||||
"arguments": {"project_id": "p1", "name": "E2E_LOGIN"}
|
||||
})),
|
||||
..rpc("tools/call")
|
||||
};
|
||||
assert!(
|
||||
authorize_sensitive_request(&test_account, Some("p1"), &HeaderMap::new(), &store)
|
||||
.is_err()
|
||||
);
|
||||
assert!(authorize_sensitive_request(&test_account, Some("p1"), &headers, &store).is_ok());
|
||||
assert!(authorize_sensitive_request(&test_account, Some("p2"), &headers, &store).is_err());
|
||||
assert!(authorize_sensitive_request(&test_account, None, &headers, &store).is_err());
|
||||
|
||||
let mut implicit_project = req;
|
||||
implicit_project.params.as_mut().unwrap()["arguments"] = json!({"name": "E2E_PASSWORD"});
|
||||
assert!(
|
||||
authorize_sensitive_request(&implicit_project, Some("p1"), &headers, &store).is_ok()
|
||||
);
|
||||
assert!(authorize_sensitive_request(
|
||||
&rpc("initialize"),
|
||||
Some("p1"),
|
||||
&HeaderMap::new(),
|
||||
&store
|
||||
)
|
||||
.is_ok());
|
||||
let normal = RpcRequest {
|
||||
method: "tools/call".into(),
|
||||
params: Some(json!({"name": "get_diagnostics", "arguments": {}})),
|
||||
..rpc("tools/call")
|
||||
};
|
||||
assert!(
|
||||
authorize_sensitive_request(&normal, Some("p1"), &HeaderMap::new(), &store).is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
/// 单一 lian-jing 服务器的握手路径:initialize / tools/list 必须可用
|
||||
#[tokio::test]
|
||||
async fn process_rpc_handshake() {
|
||||
@ -445,6 +586,312 @@ mod tests {
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn scoped_dev_variable_http_pilot_denies_missing_wrong_cross_and_global_auth() {
|
||||
struct Cleanup {
|
||||
ids: Vec<(String, String)>,
|
||||
}
|
||||
impl Drop for Cleanup {
|
||||
fn drop(&mut self) {
|
||||
let store = crate::credential_store::WindowsCredentialStore;
|
||||
for (project_id, profile_id) in &self.ids {
|
||||
if let Ok(conn) = db::pool().get() {
|
||||
let _ = crate::commands::dev_variables::revoke(
|
||||
&conn,
|
||||
&store,
|
||||
project_id,
|
||||
"E2E_PASSWORD",
|
||||
);
|
||||
let _ = crate::commands::dev_variables::revoke(
|
||||
&conn,
|
||||
&store,
|
||||
project_id,
|
||||
"E2E_LOGIN",
|
||||
);
|
||||
let _ = conn
|
||||
.execute("DELETE FROM project_workspaces WHERE id=?1", [project_id]);
|
||||
let _ =
|
||||
conn.execute("DELETE FROM project_profiles WHERE id=?1", [profile_id]);
|
||||
}
|
||||
let _ = crate::credential_store::delete_mcp_auth_token_for_project(
|
||||
&store, project_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db::init_test_pool();
|
||||
let p1 = format!("qa-only-dev-mcp-{}", uuid::Uuid::new_v4());
|
||||
let p2 = format!("qa-only-dev-mcp-{}", uuid::Uuid::new_v4());
|
||||
let profile1 = format!("{p1}-profile");
|
||||
let profile2 = format!("{p2}-profile");
|
||||
let _cleanup = Cleanup {
|
||||
ids: vec![
|
||||
(p1.clone(), profile1.clone()),
|
||||
(p2.clone(), profile2.clone()),
|
||||
],
|
||||
};
|
||||
{
|
||||
let conn = db::pool().get().unwrap();
|
||||
for (project_id, profile_id) in [(&p1, &profile1), (&p2, &profile2)] {
|
||||
conn.execute(
|
||||
"INSERT INTO project_profiles (id,name) VALUES (?1,'QA')",
|
||||
[profile_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_workspaces (id,profile_id) VALUES (?1,?2)",
|
||||
rusqlite::params![project_id, profile_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let store = crate::credential_store::WindowsCredentialStore;
|
||||
let token = crate::credential_store::get_or_create_mcp_auth_token(&store, &p1).unwrap();
|
||||
let _other_token =
|
||||
crate::credential_store::get_or_create_mcp_auth_token(&store, &p2).unwrap();
|
||||
let listener = tokio::net::TcpListener::bind(mcp_bind_addr(0))
|
||||
.await
|
||||
.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let sessions: Sessions = Arc::new(Mutex::new(HashMap::new()));
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, mcp_router(sessions)).await.unwrap();
|
||||
});
|
||||
let p1_url = format!("http://127.0.0.1:{port}/mcp/projects/{p1}");
|
||||
let p2_url = format!("http://127.0.0.1:{port}/mcp/projects/{p2}");
|
||||
let global_url = format!("http://127.0.0.1:{port}/mcp");
|
||||
let call_body = |name: &str, arguments: Value| {
|
||||
json!({
|
||||
"jsonrpc":"2.0","id":1,"method":"tools/call",
|
||||
"params":{"name":name,"arguments":arguments}
|
||||
})
|
||||
};
|
||||
let scoped_catalog = ureq::post(&p1_url)
|
||||
.send_json(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}))
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
let tool_names: Vec<&str> = scoped_catalog["result"]["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|tool| tool["name"].as_str())
|
||||
.collect();
|
||||
assert!(tool_names.contains(&"read_project_dev_variable"));
|
||||
assert!(tool_names.contains(&"register_project_test_account"));
|
||||
assert!(tool_names.contains(&"read_project_test_account"));
|
||||
assert!(!tool_names
|
||||
.iter()
|
||||
.any(|name| name.contains("get_secret") || name.contains("export_env")));
|
||||
let global_catalog = ureq::post(&global_url)
|
||||
.send_json(json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}))
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
assert!(!global_catalog
|
||||
.to_string()
|
||||
.contains("read_project_dev_variable"));
|
||||
assert!(!global_catalog
|
||||
.to_string()
|
||||
.contains("read_project_test_account"));
|
||||
let register = ureq::post(&p1_url)
|
||||
.send_json(call_body(
|
||||
"register_project_dev_variable",
|
||||
json!({"name":"E2E_PASSWORD","purpose":"合成验收"}),
|
||||
))
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
assert!(register["result"]["isError"].is_null());
|
||||
let pending = ureq::post(&p1_url)
|
||||
.send_json(call_body("list_project_dev_variables", json!({})))
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
assert!(pending["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("\"configured\":false"));
|
||||
{
|
||||
let conn = db::pool().get().unwrap();
|
||||
crate::commands::dev_variables::save(
|
||||
&conn,
|
||||
&store,
|
||||
&p1,
|
||||
"E2E_PASSWORD",
|
||||
"qa-only-http-value",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let read_call = call_body("read_project_dev_variable", json!({"name":"E2E_PASSWORD"}));
|
||||
assert!(matches!(
|
||||
ureq::post(&p1_url).send_json(read_call.clone()),
|
||||
Err(ureq::Error::Status(403, _))
|
||||
));
|
||||
assert!(matches!(
|
||||
ureq::post(&p1_url)
|
||||
.set("Authorization", "Bearer wrong")
|
||||
.send_json(read_call.clone()),
|
||||
Err(ureq::Error::Status(403, _))
|
||||
));
|
||||
assert!(matches!(
|
||||
ureq::post(&p2_url)
|
||||
.set("Authorization", &format!("Bearer {token}"))
|
||||
.send_json(read_call.clone()),
|
||||
Err(ureq::Error::Status(403, _))
|
||||
));
|
||||
let cross_argument = call_body(
|
||||
"read_project_dev_variable",
|
||||
json!({"project_id":p2,"name":"E2E_PASSWORD"}),
|
||||
);
|
||||
assert!(matches!(
|
||||
ureq::post(&p1_url)
|
||||
.set("Authorization", &format!("Bearer {token}"))
|
||||
.send_json(cross_argument),
|
||||
Err(ureq::Error::Status(403, _))
|
||||
));
|
||||
assert!(matches!(
|
||||
ureq::post(&global_url)
|
||||
.set("Authorization", &format!("Bearer {token}"))
|
||||
.send_json(read_call.clone()),
|
||||
Err(ureq::Error::Status(403, _))
|
||||
));
|
||||
let read = ureq::post(&p1_url)
|
||||
.set("Authorization", &format!("Bearer {token}"))
|
||||
.send_json(read_call.clone())
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
assert_eq!(read["result"]["content"][0]["text"], "qa-only-http-value");
|
||||
{
|
||||
let conn = db::pool().get().unwrap();
|
||||
crate::commands::dev_variables::revoke(&conn, &store, &p1, "E2E_PASSWORD").unwrap();
|
||||
}
|
||||
let after_revoke = ureq::post(&p1_url)
|
||||
.set("Authorization", &format!("Bearer {token}"))
|
||||
.send_json(read_call)
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
assert_eq!(after_revoke["result"]["isError"], true);
|
||||
assert!(!after_revoke.to_string().contains("qa-only-http-value"));
|
||||
|
||||
let account_register = ureq::post(&p1_url)
|
||||
.send_json(call_body(
|
||||
"register_project_test_account",
|
||||
json!({"name":"E2E_LOGIN","service":"enterprise-system","environment":"test","purpose":"合成登录验收"}),
|
||||
))
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
assert!(account_register["result"]["isError"].is_null());
|
||||
let account_pending = ureq::post(&p1_url)
|
||||
.send_json(call_body("list_project_dev_variables", json!({})))
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
let pending_text = account_pending["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
assert!(pending_text.contains("\"kind\":\"test_account\""));
|
||||
assert!(pending_text.contains("\"configured\":false"));
|
||||
assert!(!pending_text.contains("qa-only-account-user"));
|
||||
{
|
||||
let conn = db::pool().get().unwrap();
|
||||
crate::commands::dev_variables::save_test_account(
|
||||
&conn,
|
||||
&store,
|
||||
&p1,
|
||||
"E2E_LOGIN",
|
||||
"qa-only-account-user",
|
||||
"qa-only-account-password",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let account_registered_again = ureq::post(&p1_url)
|
||||
.send_json(call_body(
|
||||
"register_project_test_account",
|
||||
json!({"name":"E2E_LOGIN","service":"enterprise-system","environment":"test","purpose":"合成登录验收"}),
|
||||
))
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
assert!(account_registered_again["result"]["isError"].is_null());
|
||||
let account_ready = ureq::post(&p1_url)
|
||||
.send_json(call_body("list_project_dev_variables", json!({})))
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
let ready_text = account_ready["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
assert_eq!(ready_text.matches("\"name\":\"E2E_LOGIN\"").count(), 1);
|
||||
assert!(ready_text.contains("\"configured\":true"));
|
||||
assert!(!ready_text.contains("qa-only-account-user"));
|
||||
assert!(!ready_text.contains("qa-only-account-password"));
|
||||
let account_read = call_body("read_project_test_account", json!({"name":"E2E_LOGIN"}));
|
||||
assert!(matches!(
|
||||
ureq::post(&p1_url).send_json(account_read.clone()),
|
||||
Err(ureq::Error::Status(403, _))
|
||||
));
|
||||
assert!(matches!(
|
||||
ureq::post(&p1_url)
|
||||
.set("Authorization", "Bearer wrong")
|
||||
.send_json(account_read.clone()),
|
||||
Err(ureq::Error::Status(403, _))
|
||||
));
|
||||
assert!(matches!(
|
||||
ureq::post(&p2_url)
|
||||
.set("Authorization", &format!("Bearer {token}"))
|
||||
.send_json(account_read.clone()),
|
||||
Err(ureq::Error::Status(403, _))
|
||||
));
|
||||
assert!(matches!(
|
||||
ureq::post(&global_url)
|
||||
.set("Authorization", &format!("Bearer {token}"))
|
||||
.send_json(account_read.clone()),
|
||||
Err(ureq::Error::Status(403, _))
|
||||
));
|
||||
let account = ureq::post(&p1_url)
|
||||
.set("Authorization", &format!("Bearer {token}"))
|
||||
.send_json(account_read.clone())
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
let value: Value =
|
||||
serde_json::from_str(account["result"]["content"][0]["text"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(value["username"], "qa-only-account-user");
|
||||
assert_eq!(value["password"], "qa-only-account-password");
|
||||
let scalar_read = ureq::post(&p1_url)
|
||||
.set("Authorization", &format!("Bearer {token}"))
|
||||
.send_json(call_body(
|
||||
"read_project_dev_variable",
|
||||
json!({"name":"E2E_LOGIN"}),
|
||||
))
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
assert_eq!(scalar_read["result"]["isError"], true);
|
||||
{
|
||||
let conn = db::pool().get().unwrap();
|
||||
crate::commands::dev_variables::revoke(&conn, &store, &p1, "E2E_LOGIN").unwrap();
|
||||
}
|
||||
let account_after_revoke = ureq::post(&p1_url)
|
||||
.set("Authorization", &format!("Bearer {token}"))
|
||||
.send_json(account_read)
|
||||
.unwrap()
|
||||
.into_json::<Value>()
|
||||
.unwrap();
|
||||
assert_eq!(account_after_revoke["result"]["isError"], true);
|
||||
assert!(!account_after_revoke
|
||||
.to_string()
|
||||
.contains("qa-only-account-password"));
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn scoped_mcp_create_pull_request_uses_synthetic_credential() {
|
||||
@ -618,6 +1065,378 @@ mod tests {
|
||||
.is_none());
|
||||
}
|
||||
|
||||
/// 仅供隔离的真实客户端试点;普通 cargo test 不运行,也不启动 Tauri 应用。
|
||||
#[cfg(windows)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "connect a disposable Codex/Claude project to the printed scoped MCP URL"]
|
||||
async fn isolated_scoped_mcp_client_pilot() {
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
|
||||
struct PilotGuard {
|
||||
instance_id: String,
|
||||
project_dir: std::path::PathBuf,
|
||||
}
|
||||
impl Drop for PilotGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = crate::commands::gitea::gitea_delete_instance(self.instance_id.clone());
|
||||
let _ = std::fs::remove_file(self.project_dir.join(".lian-jing/context.json"));
|
||||
let _ = std::fs::remove_file(self.project_dir.join(".codex/config.toml"));
|
||||
let _ = std::fs::remove_dir(self.project_dir.join(".lian-jing"));
|
||||
let _ = std::fs::remove_dir(self.project_dir.join(".codex"));
|
||||
let _ = std::fs::remove_dir(&self.project_dir);
|
||||
}
|
||||
}
|
||||
|
||||
db::init_test_pool();
|
||||
let project_id = format!("synthetic-client-pilot-{}", uuid::Uuid::new_v4());
|
||||
let profile_id = format!("{project_id}-profile");
|
||||
let token = format!("qa-only-pilot-{}", uuid::Uuid::new_v4());
|
||||
let remote = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let remote_address = remote.local_addr().unwrap();
|
||||
let instance = crate::commands::gitea::gitea_save_instance(
|
||||
crate::commands::gitea::SaveGiteaInstanceInput {
|
||||
name: "isolated client pilot".into(),
|
||||
url: format!("http://{remote_address}"),
|
||||
access_token: token.clone(),
|
||||
is_default: Some(false),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let project_dir =
|
||||
std::env::temp_dir().join(format!("lianjing-r11-{}", uuid::Uuid::new_v4()));
|
||||
let guard = PilotGuard {
|
||||
instance_id: instance.id.clone(),
|
||||
project_dir: project_dir.clone(),
|
||||
};
|
||||
let reference: String = {
|
||||
let conn = db::pool().get().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT access_token_ref FROM gitea_instances WHERE id = ?1",
|
||||
[&instance.id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
{
|
||||
let conn = db::pool().get().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_profiles (id, name) VALUES (?1, 'Synthetic Client Pilot')",
|
||||
[&profile_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)",
|
||||
rusqlite::params![project_id, profile_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) VALUES (?1, ?2, ?3, 'owner', 'repo')",
|
||||
rusqlite::params![uuid::Uuid::new_v4().to_string(), instance.id, project_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(mcp_bind_addr(0))
|
||||
.await
|
||||
.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let sessions: Sessions = Arc::new(Mutex::new(HashMap::new()));
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, mcp_router(sessions)).await.unwrap();
|
||||
});
|
||||
let url = scoped_mcp_url(port, &project_id);
|
||||
std::fs::create_dir(&project_dir).unwrap();
|
||||
std::fs::create_dir(project_dir.join(".lian-jing")).unwrap();
|
||||
std::fs::create_dir(project_dir.join(".codex")).unwrap();
|
||||
std::fs::write(
|
||||
project_dir.join(".lian-jing/context.json"),
|
||||
serde_json::to_vec_pretty(&json!({"project_id": project_id, "schema_version": 1}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
project_dir.join(".codex/config.toml"),
|
||||
format!("[mcp_servers.lian-jing]\nurl = {url:?}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
println!("R11_PILOT_PROJECT_DIR={}", project_dir.display());
|
||||
println!("R11_PILOT_SCOPED_URL={url}");
|
||||
println!("R11_PILOT_PROJECT_ID={project_id}");
|
||||
std::io::stdout().flush().unwrap();
|
||||
|
||||
remote.set_nonblocking(true).unwrap();
|
||||
let mock = std::thread::spawn(move || {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
|
||||
let (mut stream, _) = loop {
|
||||
match remote.accept() {
|
||||
Ok(accepted) => break accepted,
|
||||
Err(error)
|
||||
if error.kind() == std::io::ErrorKind::WouldBlock
|
||||
&& std::time::Instant::now() < deadline =>
|
||||
{
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
Err(error) => panic!("5 分钟内未收到客户端的模拟 PR 请求: {error}"),
|
||||
}
|
||||
};
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert!(line.starts_with("POST /api/v1/repos/owner/repo/pulls "));
|
||||
let mut authorized = false;
|
||||
let mut content_length = 0;
|
||||
loop {
|
||||
line.clear();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if line == "\r\n" || line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if line.eq_ignore_ascii_case(&format!("authorization: token {token}\r\n")) {
|
||||
authorized = true;
|
||||
}
|
||||
if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
|
||||
content_length = value.trim().parse().unwrap();
|
||||
}
|
||||
}
|
||||
assert!(authorized, "模拟 Gitea 未收到受保护存储中的合成凭证");
|
||||
let mut body = vec![0; content_length];
|
||||
reader.read_exact(&mut body).unwrap();
|
||||
let request: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(request["head"], "synthetic-head");
|
||||
let response = r#"{"number":43,"html_url":"https://example.invalid/pr/43","title":"synthetic pilot"}"#;
|
||||
write!(stream, "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", response.len(), response).unwrap();
|
||||
});
|
||||
mock.join().unwrap();
|
||||
// 让 MCP handler 把模拟 Gitea 的响应完整交付给外部客户端后再停服。
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
server.abort();
|
||||
drop(guard);
|
||||
assert!(crate::credential_store::CredentialStore::get(
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
&reference,
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(!project_dir.exists());
|
||||
}
|
||||
|
||||
/// V7 隔离真实客户端试点:仅内存数据库、随机合成凭证与临时 scoped MCP,认证走真实 helper。
|
||||
/// 阶段靠临时项目 `.pilot/` 标记推进:客户端登记后宿主代人填写合成账户(写 filled);
|
||||
/// 见 `revoke` 标记后代人撤销(写 revoked);见 `finish` 标记后检查改动边界并清理。
|
||||
#[cfg(windows)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "V7: connect disposable Codex sessions to the printed scoped MCP URL"]
|
||||
async fn isolated_dev_variables_client_pilot() {
|
||||
use crate::credential_store::WindowsCredentialStore;
|
||||
use std::io::Write;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
struct PilotGuard {
|
||||
project_id: String,
|
||||
project_dir: std::path::PathBuf,
|
||||
}
|
||||
impl Drop for PilotGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(conn) = db::pool().get() {
|
||||
let _ = crate::commands::dev_variables::purge_project(
|
||||
&conn,
|
||||
&WindowsCredentialStore,
|
||||
&self.project_id,
|
||||
);
|
||||
}
|
||||
let _ = crate::credential_store::delete_mcp_auth_token_for_project(
|
||||
&WindowsCredentialStore,
|
||||
&self.project_id,
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&self.project_dir);
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_until<T>(
|
||||
what: &str,
|
||||
limit: Duration,
|
||||
mut probe: impl FnMut() -> Option<T>,
|
||||
) -> T {
|
||||
let deadline = Instant::now() + limit;
|
||||
loop {
|
||||
if let Some(value) = probe() {
|
||||
return value;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "等待超时:{what}");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
db::init_test_pool();
|
||||
let project_id = format!("synthetic-dev-variables-pilot-{}", uuid::Uuid::new_v4());
|
||||
let profile_id = format!("{project_id}-profile");
|
||||
let project_dir =
|
||||
std::env::temp_dir().join(format!("lianjing-v7-{}", uuid::Uuid::new_v4()));
|
||||
let pilot_dir = project_dir.join(".pilot");
|
||||
let guard = PilotGuard {
|
||||
project_id: project_id.clone(),
|
||||
project_dir: project_dir.clone(),
|
||||
};
|
||||
{
|
||||
let conn = db::pool().get().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_profiles (id, name) VALUES (?1, 'Synthetic Dev Variables Pilot')",
|
||||
[&profile_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)",
|
||||
rusqlite::params![project_id, profile_id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let token = crate::credential_store::get_or_create_mcp_auth_token(
|
||||
&WindowsCredentialStore,
|
||||
&project_id,
|
||||
)
|
||||
.unwrap();
|
||||
let digest = crate::credential_store::mcp_auth_digest(&project_id).unwrap();
|
||||
// 测试二进制位于 target/debug/deps/,helper 即同目录上一级的应用 debug 构建。
|
||||
let helper_exe = std::env::current_exe()
|
||||
.unwrap()
|
||||
.parent()
|
||||
.and_then(std::path::Path::parent)
|
||||
.unwrap()
|
||||
.join("dev-manager-tauri.exe");
|
||||
assert!(
|
||||
helper_exe.is_file(),
|
||||
"先 cargo build 生成 helper:{}",
|
||||
helper_exe.display()
|
||||
);
|
||||
let helper = format!(
|
||||
"\"{}\" --lianjing-mcp-header {digest}",
|
||||
helper_exe.display()
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(mcp_bind_addr(0))
|
||||
.await
|
||||
.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let sessions: Sessions = Arc::new(Mutex::new(HashMap::new()));
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, mcp_router(sessions)).await.unwrap();
|
||||
});
|
||||
let url = scoped_mcp_url(port, &project_id);
|
||||
std::fs::create_dir_all(project_dir.join(".lian-jing")).unwrap();
|
||||
std::fs::create_dir_all(project_dir.join(".codex")).unwrap();
|
||||
std::fs::create_dir_all(&pilot_dir).unwrap();
|
||||
std::fs::write(
|
||||
project_dir.join(".lian-jing/context.json"),
|
||||
serde_json::to_vec_pretty(&json!({"project_id": project_id, "schema_version": 1}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
project_dir.join(".codex/config.toml"),
|
||||
format!(
|
||||
"[mcp_servers.lian-jing]\nurl = {}\nhttp_headers_helper = {}\n",
|
||||
serde_json::to_string(&url).unwrap(),
|
||||
serde_json::to_string(&helper).unwrap()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let username = format!("pilot-user-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]);
|
||||
let password = format!("pilot-pass-{}", uuid::Uuid::new_v4().simple());
|
||||
println!("V7_PILOT_PROJECT_DIR={}", project_dir.display());
|
||||
println!("V7_PILOT_SCOPED_URL={url}");
|
||||
println!("V7_PILOT_PROJECT_ID={project_id}");
|
||||
println!("V7_PILOT_HELPER={helper}");
|
||||
println!("V7_PILOT_EXPECTED_USERNAME={username}");
|
||||
std::io::stdout().flush().unwrap();
|
||||
|
||||
// 阶段 1:等客户端登记测试账户需求,再代人填写合成值。
|
||||
let name = wait_until("客户端登记测试账户需求", Duration::from_secs(1800), || {
|
||||
let conn = db::pool().get().ok()?;
|
||||
conn.query_row(
|
||||
"SELECT name FROM project_dev_variables
|
||||
WHERE project_id=?1 AND kind='test_account' ORDER BY name LIMIT 1",
|
||||
[&project_id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.await;
|
||||
{
|
||||
let conn = db::pool().get().unwrap();
|
||||
crate::commands::dev_variables::save_test_account(
|
||||
&conn,
|
||||
&WindowsCredentialStore,
|
||||
&project_id,
|
||||
&name,
|
||||
&username,
|
||||
&password,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(pilot_dir.join("filled"), &name).unwrap();
|
||||
println!("V7_PILOT_FILLED={name}");
|
||||
std::io::stdout().flush().unwrap();
|
||||
|
||||
// 阶段 2:客户端跨会话读取后,由操作者放置 revoke 标记,宿主代人撤销。
|
||||
wait_until("撤销标记 .pilot/revoke", Duration::from_secs(1800), || {
|
||||
pilot_dir.join("revoke").exists().then_some(())
|
||||
})
|
||||
.await;
|
||||
{
|
||||
let conn = db::pool().get().unwrap();
|
||||
crate::commands::dev_variables::revoke(&conn, &WindowsCredentialStore, &project_id, &name)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(pilot_dir.join("revoked"), &name).unwrap();
|
||||
println!("V7_PILOT_REVOKED={name}");
|
||||
std::io::stdout().flush().unwrap();
|
||||
|
||||
// 阶段 3:撤销后再读由客户端验证失败;finish 标记后检查改动边界。
|
||||
wait_until("结束标记 .pilot/finish", Duration::from_secs(1800), || {
|
||||
pilot_dir.join("finish").exists().then_some(())
|
||||
})
|
||||
.await;
|
||||
server.abort();
|
||||
{
|
||||
let conn = db::pool().get().unwrap();
|
||||
let statuses = crate::commands::dev_variables::list(
|
||||
&conn,
|
||||
&WindowsCredentialStore,
|
||||
&project_id,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(statuses.iter().all(|status| !status.configured));
|
||||
}
|
||||
let mut pending = vec![project_dir.clone()];
|
||||
while let Some(dir) = pending.pop() {
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
pending.push(path);
|
||||
continue;
|
||||
}
|
||||
let file_name = path.file_name().unwrap().to_string_lossy();
|
||||
assert!(!file_name.starts_with(".env"), "项目目录出现 {file_name}");
|
||||
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
assert!(!content.contains(&token), "{} 含 MCP token", path.display());
|
||||
assert!(!content.contains(&password), "{} 含测试密码", path.display());
|
||||
}
|
||||
}
|
||||
drop(guard);
|
||||
assert!(crate::credential_store::read_mcp_auth_token_for_digest(
|
||||
&WindowsCredentialStore,
|
||||
&digest
|
||||
)
|
||||
.is_err());
|
||||
assert!(!project_dir.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_probe_does_not_satisfy_client_reload_evidence() {
|
||||
let project_id = "reload-evidence-fixture";
|
||||
@ -659,8 +1478,20 @@ mod tests {
|
||||
async fn handle_sse_message(
|
||||
State(sessions): State<Sessions>,
|
||||
Query(query): Query<SessionQuery>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RpcRequest>,
|
||||
) -> Response {
|
||||
if authorize_sensitive_request(
|
||||
&req,
|
||||
None,
|
||||
&headers,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
if req.id.is_none() {
|
||||
return StatusCode::ACCEPTED.into_response();
|
||||
}
|
||||
@ -698,8 +1529,19 @@ async fn handle_scoped_sse_message(
|
||||
Path(project_id): Path<String>,
|
||||
State(sessions): State<Sessions>,
|
||||
Query(query): Query<SessionQuery>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RpcRequest>,
|
||||
) -> Response {
|
||||
if authorize_sensitive_request(
|
||||
&req,
|
||||
Some(&project_id),
|
||||
&headers,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
if req.id.is_none() {
|
||||
return StatusCode::ACCEPTED.into_response();
|
||||
}
|
||||
|
||||
@ -412,6 +412,54 @@ pub fn tools_list_result_for_project(project_id: &str) -> Value {
|
||||
return result;
|
||||
};
|
||||
tools.retain(is_project_scoped_tool);
|
||||
tools.extend([
|
||||
json!({
|
||||
"name": "register_project_dev_variable",
|
||||
"description": "登记本项目开发阶段所需变量的名称和用途;不提交值。请让用户在炼境项目 UI 填写非生产、低权限、可重置的值。",
|
||||
"inputSchema": {"type":"object","properties":{
|
||||
"project_id":{"type":"string"},
|
||||
"name":{"type":"string","description":"变量名,例如 E2E_PASSWORD"},
|
||||
"purpose":{"type":"string","description":"不含秘密的用途说明"}
|
||||
},"required":["name","purpose"]},
|
||||
"annotations":{"readOnlyHint":false,"destructiveHint":false}
|
||||
}),
|
||||
json!({
|
||||
"name": "register_project_test_account",
|
||||
"description": "仅登记本项目非生产测试登录账户的需求卡,不提交账号或密码。重复相同名称和语义返回同一张卡;用户随后在炼境项目 UI 一次填写。",
|
||||
"inputSchema": {"type":"object","properties":{
|
||||
"project_id":{"type":"string"},
|
||||
"name":{"type":"string","description":"稳定需求名,例如 E2E_LOGIN"},
|
||||
"service":{"type":"string","description":"目标系统名称,不含账号或密码"},
|
||||
"environment":{"type":"string","description":"非生产测试环境名称"},
|
||||
"purpose":{"type":"string","description":"本账户用于什么测试,不含秘密"}
|
||||
},"required":["name","service","environment","purpose"]},
|
||||
"annotations":{"readOnlyHint":false,"destructiveHint":false}
|
||||
}),
|
||||
json!({
|
||||
"name": "list_project_dev_variables",
|
||||
"description": "测试登录前先查询本项目开发需求状态。返回名称、类型、目标系统、环境、用途和待填写/已配置状态,不返回值或凭证引用;已有需求请复用,勿重复登记。",
|
||||
"inputSchema": {"type":"object","properties":{"project_id":{"type":"string"}},"required":[]},
|
||||
"annotations":{"readOnlyHint":true}
|
||||
}),
|
||||
json!({
|
||||
"name": "read_project_dev_variable",
|
||||
"description": "经本机认证后单项读取本项目已由用户填写的标量开发变量;测试账户请使用 read_project_test_account。返回值会进入 Agent 会话;仅用于非生产、低权限、可重置的开发数据。",
|
||||
"inputSchema": {"type":"object","properties":{
|
||||
"project_id":{"type":"string"},
|
||||
"name":{"type":"string"}
|
||||
},"required":["name"]},
|
||||
"annotations":{"readOnlyHint":true}
|
||||
}),
|
||||
json!({
|
||||
"name": "read_project_test_account",
|
||||
"description": "经本机认证后读取本项目已由用户填写的单张非生产测试账户卡,返回 username/password 给 Agent 用于当前测试。不要写入仓库、日志或普通聊天摘要。",
|
||||
"inputSchema": {"type":"object","properties":{
|
||||
"project_id":{"type":"string"},
|
||||
"name":{"type":"string","description":"已登记的测试账户需求名"}
|
||||
},"required":["name"]},
|
||||
"annotations":{"readOnlyHint":true}
|
||||
}),
|
||||
]);
|
||||
for tool in tools {
|
||||
let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) else {
|
||||
continue;
|
||||
@ -600,6 +648,18 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
|
||||
.and_then(|p| p.get("arguments"))
|
||||
.cloned()
|
||||
.unwrap_or(json!({}));
|
||||
if project_scope.is_none()
|
||||
&& matches!(
|
||||
tool_name.as_str(),
|
||||
"register_project_dev_variable"
|
||||
| "register_project_test_account"
|
||||
| "list_project_dev_variables"
|
||||
| "read_project_dev_variable"
|
||||
| "read_project_test_account"
|
||||
)
|
||||
{
|
||||
return scope_violation("开发变量仅允许项目级 MCP 连接");
|
||||
}
|
||||
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);
|
||||
@ -609,6 +669,59 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
|
||||
let scope_for_call = project_scope.clone();
|
||||
|
||||
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
|
||||
"register_project_dev_variable" => {
|
||||
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
|
||||
let name = args.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
let purpose = args.get("purpose").and_then(Value::as_str).unwrap_or("");
|
||||
let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?;
|
||||
crate::commands::dev_variables::register(&conn, project_id, name, purpose)?;
|
||||
Ok("已登记开发变量需求;等待用户在炼境项目 UI 填写".to_string())
|
||||
}
|
||||
"register_project_test_account" => {
|
||||
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
|
||||
let name = args.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
let service = args.get("service").and_then(Value::as_str).unwrap_or("");
|
||||
let environment = args.get("environment").and_then(Value::as_str).unwrap_or("");
|
||||
let purpose = args.get("purpose").and_then(Value::as_str).unwrap_or("");
|
||||
let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?;
|
||||
crate::commands::dev_variables::register_test_account(
|
||||
&conn, project_id, name, service, environment, purpose,
|
||||
)?;
|
||||
Ok("已登记测试账户需求;等待用户在炼境项目 UI 一次填写账号与密码".to_string())
|
||||
}
|
||||
"list_project_dev_variables" => {
|
||||
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
|
||||
let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?;
|
||||
let statuses = crate::commands::dev_variables::list(
|
||||
&conn,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
project_id,
|
||||
)?;
|
||||
serde_json::to_string(&statuses).map_err(|_| "序列化开发变量状态失败".to_string())
|
||||
}
|
||||
"read_project_dev_variable" => {
|
||||
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
|
||||
let name = args.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?;
|
||||
crate::commands::dev_variables::read(
|
||||
&conn,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
project_id,
|
||||
name,
|
||||
)
|
||||
}
|
||||
"read_project_test_account" => {
|
||||
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
|
||||
let name = args.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?;
|
||||
let account = crate::commands::dev_variables::read_test_account(
|
||||
&conn,
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
project_id,
|
||||
name,
|
||||
)?;
|
||||
serde_json::to_string(&account).map_err(|_| "序列化测试账户失败".to_string())
|
||||
}
|
||||
"get_diagnostics" => match scope_for_call.as_deref() {
|
||||
Some(project_id) => get_scoped_diagnostics(project_id),
|
||||
None => get_diagnostics(),
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
gitPush,
|
||||
type GiteaInstance,
|
||||
} from "../../lib/commands";
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
type BindMode = "create" | "migrate" | "link";
|
||||
@ -98,7 +99,7 @@ export function GiteaInstancesSection() {
|
||||
};
|
||||
|
||||
const handleDelete = async (inst: GiteaInstance) => {
|
||||
if (!confirm(`删除实例「${inst.name}」?其下所有项目绑定会一并删除。`)) return;
|
||||
if (!(await confirm(`删除实例「${inst.name}」?其下所有项目绑定会一并删除。`, { kind: "warning" }))) return;
|
||||
try {
|
||||
await giteaDeleteInstance(inst.id);
|
||||
qc.invalidateQueries({ queryKey: ["gitea-instances"] });
|
||||
@ -308,7 +309,7 @@ export function GiteaPanel({ projectId, projectName, projectPath }: Props) {
|
||||
};
|
||||
|
||||
const handleUnlink = async () => {
|
||||
if (!confirm("解绑当前项目与 Gitea 仓库?(Gitea 侧仓库不会删除)")) return;
|
||||
if (!(await confirm("解绑当前项目与 Gitea 仓库?(Gitea 侧仓库不会删除)", { kind: "warning" }))) return;
|
||||
try {
|
||||
await giteaUnlinkRepo(projectId);
|
||||
refresh();
|
||||
|
||||
@ -12,6 +12,7 @@ import { MentorPanel } from "../mentor/MentorPanel";
|
||||
import { UserTodoPanel } from "./UserTodoPanel";
|
||||
import { ProjectServerPanel } from "./ProjectServerPanel";
|
||||
import { ClaudeConfigModal } from "./ClaudeConfigModal";
|
||||
import { ProjectDevVariablesPanel } from "./ProjectDevVariablesPanel";
|
||||
|
||||
// ── 蓝图状态徽章 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@ -66,6 +67,7 @@ export function ProjectCard({ project: p }: Props) {
|
||||
const [showBlueprintPrompt, setShowBlueprintPrompt] = useState(false);
|
||||
const [showMentor, setShowMentor] = useState(false);
|
||||
const [showClaudeConfig, setShowClaudeConfig] = useState(false);
|
||||
const [showDevVariables, setShowDevVariables] = useState(false);
|
||||
const [showTodos, setShowTodos] = useState(false);
|
||||
const [showServers, setShowServers] = useState(false);
|
||||
const [showBranchMenu, setShowBranchMenu] = useState(false);
|
||||
@ -613,6 +615,7 @@ export function ProjectCard({ project: p }: Props) {
|
||||
<div className="absolute right-0 bottom-full mb-1 w-40 bg-white border border-gray-200 rounded-lg shadow-lg z-50 py-1 text-xs">
|
||||
{([
|
||||
{ icon: "🤖", label: "Claude 自主性", action: () => setShowClaudeConfig(true) },
|
||||
{ icon: "🔑", label: "开发变量", action: () => setShowDevVariables(true) },
|
||||
{ icon: "📝", label: showTodos ? "收起备忘" : "个人备忘", action: () => setShowTodos((v) => !v) },
|
||||
{ icon: "🖥️", label: showServers ? "收起服务器" : "关联服务器", action: () => setShowServers((v) => !v) },
|
||||
{ icon: "🚀", label: "CI/CD 控制台", action: () => setShowCicd(true) },
|
||||
@ -705,6 +708,14 @@ export function ProjectCard({ project: p }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDevVariables && (
|
||||
<ProjectDevVariablesPanel
|
||||
projectId={p.id}
|
||||
projectName={p.name}
|
||||
onClose={() => setShowDevVariables(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 个人备忘 */}
|
||||
{showTodos && <UserTodoPanel projectId={p.id} />}
|
||||
|
||||
|
||||
201
src/components/dashboard/ProjectDevVariablesPanel.tsx
Normal file
201
src/components/dashboard/ProjectDevVariablesPanel.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
listProjectDevVariables,
|
||||
revealProjectDevVariable,
|
||||
saveProjectDevVariable,
|
||||
saveProjectTestAccount,
|
||||
revokeProjectDevVariable,
|
||||
type ProjectDevVariableStatus,
|
||||
} from "../../lib/commands";
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
import { Dialog } from "../ui/Dialog";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const showToast = useUIStore((state) => state.showToast);
|
||||
const [editingName, setEditingName] = useState<string | null>(null);
|
||||
const [draftValue, setDraftValue] = useState("");
|
||||
const [draftUsername, setDraftUsername] = useState("");
|
||||
const [draftPassword, setDraftPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [loadingName, setLoadingName] = useState<string | null>(null);
|
||||
const { data: variables = [], isPending, error } = useQuery({
|
||||
queryKey: ["projectDevVariables", projectId],
|
||||
queryFn: () => listProjectDevVariables(projectId),
|
||||
retry: false,
|
||||
});
|
||||
const clearDraft = () => {
|
||||
setDraftValue("");
|
||||
setDraftUsername("");
|
||||
setDraftPassword("");
|
||||
setShowSecret(false);
|
||||
};
|
||||
// 已配置项进入修改时预填原值(密码默认遮罩);取回失败则提示并以空白表单继续。
|
||||
const startEdit = async (item: ProjectDevVariableStatus) => {
|
||||
clearDraft();
|
||||
if (item.configured) {
|
||||
setLoadingName(item.name);
|
||||
try {
|
||||
const original = await revealProjectDevVariable(projectId, item.name);
|
||||
if (original.kind === "test_account") {
|
||||
setDraftUsername(original.username);
|
||||
setDraftPassword(original.password);
|
||||
} else {
|
||||
setDraftValue(original.value);
|
||||
}
|
||||
} catch (cause) {
|
||||
showToast(`读取原值失败:${cause}`);
|
||||
} finally {
|
||||
setLoadingName(null);
|
||||
}
|
||||
}
|
||||
setEditingName(item.name);
|
||||
};
|
||||
const secretToggle = (
|
||||
<button type="button" onClick={() => setShowSecret((shown) => !shown)}
|
||||
className="shrink-0 px-2 text-xs text-gray-500 hover:text-gray-700">
|
||||
{showSecret ? "隐藏" : "显示"}
|
||||
</button>
|
||||
);
|
||||
const handleSave = async (name: string, kind: "scalar" | "test_account") => {
|
||||
if (saving || (kind === "test_account" ? !draftUsername || !draftPassword : !draftValue)) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (kind === "test_account") {
|
||||
await saveProjectTestAccount(projectId, name, draftUsername, draftPassword);
|
||||
} else {
|
||||
await saveProjectDevVariable(projectId, name, draftValue);
|
||||
}
|
||||
clearDraft();
|
||||
setEditingName(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["projectDevVariables", projectId] });
|
||||
showToast(kind === "test_account" ? "测试账户已保存" : "开发变量已保存");
|
||||
} catch (cause) {
|
||||
showToast(`保存失败:${cause}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (name: string) => revokeProjectDevVariable(projectId, name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["projectDevVariables", projectId] });
|
||||
showToast("开发变量已撤销");
|
||||
},
|
||||
onError: (cause) => showToast(`撤销失败:${cause}`),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog title={`开发变量 · ${projectName}`} onClose={onClose} size="md">
|
||||
<div className="overflow-y-auto p-5 space-y-4 text-sm">
|
||||
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
仅填写非生产、低权限、可重置的开发值或测试账户。已配置的值可被此项目的已认证 Agent 单项读取,
|
||||
不会写入子项目文件;请勿存放生产密码。
|
||||
</p>
|
||||
{isPending && <p className="text-gray-500">正在读取需求…</p>}
|
||||
{error && <p className="text-red-600">读取失败:{String(error)}</p>}
|
||||
{!isPending && !error && variables.length === 0 && (
|
||||
<p className="text-gray-500">暂无变量需求。Agent 登记需要的名称和用途后,会显示在这里等待填写。</p>
|
||||
)}
|
||||
{variables.map((item) => (
|
||||
<div key={item.name} className="border border-gray-200 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-semibold text-gray-800 break-all flex-1">{item.name}</code>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${item.configured ? "bg-green-50 text-green-700" : "bg-amber-50 text-amber-700"}`}>
|
||||
{item.configured ? "已配置" : "待填写"}
|
||||
</span>
|
||||
</div>
|
||||
{item.kind === "test_account" && (
|
||||
<p className="text-xs text-gray-600">测试账户 · {item.service} · {item.environment}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 whitespace-pre-wrap">{item.purpose}</p>
|
||||
{editingName === item.name ? (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSave(item.name, item.kind);
|
||||
}}
|
||||
className="space-y-2"
|
||||
>
|
||||
{item.kind === "test_account" ? (
|
||||
<div className="grid gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={draftUsername}
|
||||
onChange={(event) => setDraftUsername(event.target.value)}
|
||||
placeholder="测试账号"
|
||||
aria-label={`${item.name} 测试账号`}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type={showSecret ? "text" : "password"}
|
||||
autoComplete="off"
|
||||
value={draftPassword}
|
||||
onChange={(event) => setDraftPassword(event.target.value)}
|
||||
placeholder="测试密码"
|
||||
aria-label={`${item.name} 测试密码`}
|
||||
className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
{secretToggle}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type={showSecret ? "text" : "password"}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={draftValue}
|
||||
onChange={(event) => setDraftValue(event.target.value)}
|
||||
placeholder="填写开发值"
|
||||
className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
{secretToggle}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={saving || (item.kind === "test_account" ? !draftUsername || !draftPassword : !draftValue)}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white rounded disabled:opacity-50">
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</button>
|
||||
<button type="button" onClick={() => { clearDraft(); setEditingName(null); }}
|
||||
className="px-2 py-1.5 text-gray-500">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex gap-3 text-xs">
|
||||
<button onClick={() => void startEdit(item)} disabled={loadingName === item.name}
|
||||
className="text-blue-600 hover:underline disabled:opacity-50">
|
||||
{loadingName === item.name ? "读取中…" : item.configured ? "修改" : "填写"}
|
||||
</button>
|
||||
{item.configured && (
|
||||
<button
|
||||
disabled={revokeMutation.isPending}
|
||||
onClick={async () => {
|
||||
if (await confirm(`撤销 ${item.name}?Agent 将无法再读取该值。`, { kind: "warning" })) {
|
||||
revokeMutation.mutate(item.name);
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:underline disabled:opacity-50"
|
||||
>撤销</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -12,6 +12,7 @@ import {
|
||||
type RuntimeToolStatus,
|
||||
type ToolchainStatus,
|
||||
} from "../../lib/commands";
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
// ── 层元信息 ───────────────────────────────────────────────────────────────────
|
||||
@ -113,8 +114,9 @@ export function ProjectEnvModal({ project, onClose }: Props) {
|
||||
};
|
||||
|
||||
const handlePrepareToolchain = async () => {
|
||||
const confirmed = window.confirm(
|
||||
const confirmed = await confirm(
|
||||
"炼境将通过 mise install 下载并准备项目已声明的 Node/pnpm 版本。不会修改 package.json 或 lockfile,是否继续?",
|
||||
{ kind: "warning" },
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setPreparing(true);
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
type GitRepo,
|
||||
type UpdateGitRepoDeployInput,
|
||||
} from "../../lib/commands";
|
||||
import { confirm } from "@tauri-apps/plugin-dialog";
|
||||
import { useUIStore } from "../../store/ui";
|
||||
|
||||
// ── 部署信息编辑表单 ──────────────────────────────────────────────────────────
|
||||
@ -127,7 +128,7 @@ export function RegistryTab() {
|
||||
});
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`确定删除仓库「${name}」?关联的工作区也会被删除。`)) return;
|
||||
if (!(await confirm(`确定删除仓库「${name}」?关联的工作区也会被删除。`, { kind: "warning" }))) return;
|
||||
try {
|
||||
await deleteGitRepo(id);
|
||||
qc.invalidateQueries({ queryKey: ["git-repos"] });
|
||||
|
||||
@ -1405,6 +1405,43 @@ export const deleteApiKey = (id: string) =>
|
||||
export const revealApiKey = (id: string) =>
|
||||
invoke<string>("reveal_api_key", { id });
|
||||
|
||||
// ── Project development variables ──────────────────────────────────────────
|
||||
|
||||
export interface ProjectDevVariableStatus {
|
||||
project_id: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
kind: "scalar" | "test_account";
|
||||
service: string | null;
|
||||
environment: string | null;
|
||||
configured: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const listProjectDevVariables = (projectId: string) =>
|
||||
invoke<ProjectDevVariableStatus[]>("list_project_dev_variables", { projectId });
|
||||
|
||||
export const saveProjectDevVariable = (projectId: string, name: string, value: string) =>
|
||||
invoke<void>("save_project_dev_variable", { projectId, name, value });
|
||||
|
||||
export const saveProjectTestAccount = (
|
||||
projectId: string,
|
||||
name: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) => invoke<void>("save_project_test_account", { projectId, name, username, password });
|
||||
|
||||
/** 仅在人显式点「修改」时取回原值;不经 MCP。 */
|
||||
export type ProjectDevVariableReveal =
|
||||
| { kind: "scalar"; value: string }
|
||||
| { kind: "test_account"; username: string; password: string };
|
||||
|
||||
export const revealProjectDevVariable = (projectId: string, name: string) =>
|
||||
invoke<ProjectDevVariableReveal>("reveal_project_dev_variable", { projectId, name });
|
||||
|
||||
export const revokeProjectDevVariable = (projectId: string, name: string) =>
|
||||
invoke<void>("revoke_project_dev_variable", { projectId, name });
|
||||
|
||||
// ── SSH Config & VS Code Remote ──────────────────────────────────────────────
|
||||
|
||||
export interface SshConfigStatus {
|
||||
|
||||
26
src/lib/confirmUsage.test.ts
Normal file
26
src/lib/confirmUsage.test.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// tauri-plugin-dialog 把 window.confirm 替换成 async 函数:同步写法拿到的是永远为真的 Promise,
|
||||
// 确认框形同虚设(2026-09-30 撤销/删除未弹框即执行)。破坏性操作必须 await plugin-dialog 的 confirm。
|
||||
const sources = import.meta.glob<string>(["../**/*.{ts,tsx}", "!../**/*.test.ts"], {
|
||||
query: "?raw",
|
||||
import: "default",
|
||||
eager: true,
|
||||
});
|
||||
|
||||
describe("confirm usage", () => {
|
||||
it("scans the frontend sources", () => {
|
||||
expect(Object.keys(sources).length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("never calls confirm without await", () => {
|
||||
const offenders = Object.entries(sources).flatMap(([file, text]) =>
|
||||
text
|
||||
.split("\n")
|
||||
.map((line, index) => ({ line, at: `${file}:${index + 1}` }))
|
||||
.filter(({ line }) => /(^|[^\w.])(window\.)?confirm\(/.test(line) && !/await\s+(window\.)?confirm\(/.test(line))
|
||||
.map(({ at }) => at),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user