feat(resource/broker): 实现受保护凭证代办与项目 readiness #20

Merged
lanrtop merged 5 commits from feat/project-resource-broker into master 2026-09-23 20:36:21 +08:00
18 changed files with 2569 additions and 255 deletions

View File

@ -489,6 +489,15 @@ modules:
note: "A1-A9 已完成:双项目原生 scoped MCP、开发态托盘常驻、设置页真实 readiness 与正式安装版 0.1.19 自启动启停均已验收;最终自启动关闭。Windows 注销后的自动拉起尚未实测。" note: "A1-A9 已完成:双项目原生 scoped MCP、开发态托盘常驻、设置页真实 readiness 与正式安装版 0.1.19 自启动启停均已验收;最终自启动关闭。Windows 注销后的自动拉起尚未实测。"
position: [2750, 950] position: [2750, 950]
- id: project-resource-broker
name: 项目资源与凭证代办
area: infra
status: done
progress: 100
updated: 2026-09-23
note: "R1–R8 按已确认设计逐卡实施;仅受保护本机凭证与有界 Gitea 代办,不接管生产部署凭证。"
position: [3000, 950]
edges: edges:
# 前端依赖 # 前端依赖
- from: workspace-mgmt - from: workspace-mgmt

View File

@ -0,0 +1,149 @@
---
id: project-resource-broker
name: 项目资源与凭证代办
status: done
progress: 100
---
## 概述
让项目 Agent 发现“某项资源是否可用”,并通过炼境调用有限、可审计的操作;Agent 不需在对话、MCP 输出、项目文件中接收原始凭证。统一的是入口与操作契约,不是把所有项目密钥集中到炼境一处。
## 设计决策:按用途存放,按操作代办(2026-09-23)
### 背景与证据
- `api_keys.api_key`、`gitea_instances.access_token`、`gitea_repos.webhook_secret` 目前是 SQLite 文本列;`get_api_keys`、`gitea_list_instances`、部分仓库 DTO 会将原值返回 Tauri 前端。`ApiKeysPanel` 的遮罩发生在前端,不能当作后端不暴露。
- 现有 MCP `create_pull_request(project_id, ...)` 已由炼境内部使用 Gitea token,调用方不需要提取 token。应以此为首个资源代办样板,不另造 PR 接口。
- 现有 `/mcp/projects/<project_id>` 只提供单机误操作隔离,并非认证或不同 OS 进程间的秘密隔离。Agent 若与用户共享 Windows 账户,不能宣称其物理上绝对读不到本机凭证。
- Enterprise System 的 production 链路保留 Gitea Actions、项目部署脚本和服务器运行环境;炼境当前只读同 SHA 交付证据,不得由本模块扩张为 SSH、数据库、migration 或部署执行者。
### 方案选择
| 方案 | 收益 | 代价 / 风险 | 结论 |
| --- | --- | --- | --- |
| 将全部 `.env`、CI、服务器凭证导入炼境,提供通用 `get_secret` | 表面上配置统一 | 单点风险、同用户 Agent 可读取、破坏 CI/运行时现有信任边界 | 否决 |
| 维持现状,仅增加“不要读取密钥”的 Skill 提醒 | 改动少 | 明文存储与原值 DTO 仍在,无法形成可验证边界 | 否决 |
| **按执行用途保留凭证原位;炼境仅托管本机代办凭证引用和最小操作** | 复用已有 PR 能力,渐进迁移,Agent 只见状态和操作结果 | 需兼容旧数据,且同用户本机威胁模型有限 | **选定** |
### 凭证归属与边界
| 用途 | 凭证权威位置 | 炼境职责 | Agent 可见 |
| --- | --- | --- | --- |
| 本机 Gitea API 代办 | Windows 用户保护的本机凭证存储;SQLite 仅保存稳定引用与非秘密元数据 | 在项目已绑定 Gitea 仓库且操作被允许时,代办现有 PR/状态调用 | `configured / unavailable / needs_repair` 与 PR 结果;无 token |
| Gitea Actions 中的 CI/部署 | Gitea Actions secrets | 只读检查 CI 与交付证据 | 状态与证据引用;无 secret |
| 项目进程的数据库、第三方服务、服务器运行时 | 项目自己的运行环境 / 云端现有机制 | 首轮只报告资源是否已配置,不接管值 | 非秘密 readiness;无连接串、账号密码 |
| 开发者手工保存的通用 API Key | 炼境本机受保护存储(通过单独迁移卡处理) | 保留人的显式展示/复制操作;不提供 MCP 原值读取 | 常态列表仅元数据;Agent 不获 reveal 工具 |
首轮不增加任意 URL/命令代理,不增加 `get_secret` / `export_env` / `run_sql` / `ssh_exec` / `deploy`。新操作的授权必须在能力本体服务层检查项目绑定、目标、动作;不能靠前端置灰或 MCP 描述约束。写操作沿用现有确认与 Git/PR 规则。读取的资源状态不得含凭证、用户名、连接串、敏感目标详情。若将来要求抵抗同一 OS 用户下的恶意进程,需另立认证与进程隔离设计,不能拿 loopback/scoped URL 充当安全边界。
### 受影响文件与数据流
- 现有读取:`src-tauri/src/commands/api_keys.rs`、`src-tauri/src/commands/gitea.rs`、`src-tauri/src/db.rs`;前端契约 `src/lib/commands.ts`,展示 `src/components/dashboard/ApiKeysPanel.tsx`、`src/components/dashboard/GiteaPanel.tsx`。
- 建议新增:`src-tauri/src/credential_store.rs`,隔离 Windows 凭证存储与可注入测试接口;实际 API/依赖在卡 R3 的技术验证后固定。Rust 模块入口需在 `src-tauri/src/lib.rs` 注册。
- 建议新增只读状态:后端项目资源 readiness 聚合函数,并由 `src-tauri/src/mcp_tools.rs` 公开项目 scoped 查询;`src/lib/commands.ts` 提供同一本体的人类投影。v1 仅 Gitea 绑定、凭证可用性等非秘密状态,不泛化所有 provider。
- 外部契约变化须同步 `docs/ai-context/project-context.yaml` 和相关调用说明。任何 SQLite schema 改动须在 `src-tauri/src/db.rs::migrate()` 中添加兼容迁移,不能更改旧安装数据的含义或静默清空。
### 迁移与失败模型
1. 先增加脱敏 DTO 与回归测试;人的 API Key 展示/复制改为显式动作,常态列表不携带原值。此步骤不删 SQLite 原值。
2. 以 Windows Credential Manager 为优先候选,在隔离测试中验证写入、读取、覆盖、删除、用户上下文、重启后读取与错误返回;若不满足要求,停止迁移并重新决策,不静默退回明文 SQLite。
3. 迁移每条旧凭证时采取“写新存储 → 读取校验 → 切换引用 → 再清除旧列”的可恢复顺序;失败保留旧值并显示 `needs_repair`,不把失败伪装为 `not_configured`。设计并演练备份/恢复后再动真实用户数据;旧数据库备份可能仍含明文,不能宣称迁移后历史明文已消失。
4. 凭证存储不可用时,代办操作 fail closed,项目自有 CI/运行链路照常;状态明确提示缺少/不可用/需修复,日志、错误文本和测试快照均不得回显原值。
### 验证路径
- 载体:Rust 纯函数及带内存数据库/模拟凭证存储的 `cargo test`;MCP 只读工具做真实 loopback/scoped 握手验证;前端展示由人终验。
- 示例:`[cargo test] gitea_list_instances()` → JSON 不含 `access_token`;`get_project_resource_status(p1)` → 仅非秘密状态,p2 参数被 scope 拒绝。
- 示例:`[cargo test] migrate_legacy_credential(模拟存储失败)` → 旧值仍可恢复、状态为 `needs_repair`、不泄露凭证。
- 示例:`[MCP] create_pull_request(p1, head, base, title)` → 返回 PR 标识或可行动错误,不返回 token;不把生产部署作为测试副作用。
### 风险与决策闸门
- **同一 Windows 用户可读性**:本方案减少凭证进入 Agent 上下文与普通 DTO 的机会,不提供强进程隔离;验收不能写成“Agent 绝对无法获取”。
- **旧用户数据迁移风险**:先用复制的匿名/模拟 DB 演练失败与回滚,再对真实数据执行;无验证、无备份方案不得迁移。
- **功能回退风险**:API Key 显示/复制保留为人的显式操作;Gitea PR 创建和 webhook 签名必须回归。
- **`Cargo.toml` 当前有既有未提交改动**:实施存储 adapter 若需改依赖,先确定改动归属并使用隔离工作区;不得混合提交或覆盖。
- **范围膨胀风险**:Enterprise 非生产样板只验证一个真实操作;是否扩展第二种资源,由减少 Agent 阻塞次数、人工取密次数、失败定位耗时的实测结果决定。
## 任务拆解
### ✅ R9 — PR 审查修复:缓存失效、凭证删除与 readiness
- status: done
- complexity: M
- files: src/components/dashboard/ApiKeysPanel.tsx, src/components/dashboard/ApiKeysPanel.test.ts, src-tauri/src/credential_store.rs, src-tauri/src/commands/api_keys.rs, src-tauri/src/commands/gitea.rs, src/lib/commands.ts
- acceptance: "agent_verify: [cargo test] 凭证删除失败可恢复且不会假成功,legacy readiness 为 pending_migration 而非 configured;[vitest] copyCurrentApiKey 编辑后复制最新值;[CLI] typecheck、测试与 git diff --check 通过。human_verify: 正式安装版编辑后显示/复制终验。"
- human_verify: pending(正式安装版 UI 未在本轮重跑;agent 路径已通过)
### ✅ R1 — Gitea 与 webhook 输出脱敏
- status: done
- complexity: M
- files: src-tauri/src/commands/gitea.rs, src/lib/commands.ts
- depends: gitea-integration
- acceptance: "agent_verify: [cargo test] gitea_list_instances / gitea_repo DTO 序列化 → 不含 access_token 或 webhook_secret;原有 Gitea 连接测试仍通过。human_verify: 设置页能显示实例并执行改 Token,不在常态 UI 或调试响应显示原值。"
- human_verify: pending(需用户在隔离功能交付后终验真实 UI;agent 验证已通过)
### ✅ R2 — API Key 常态列表脱敏与人的显式查看
- status: done
- complexity: M
- files: src-tauri/src/commands/api_keys.rs, src/lib/commands.ts, src/components/dashboard/ApiKeysPanel.tsx
- depends: R1
- acceptance: "agent_verify: [cargo test] get_api_keys() → 列表序列化不含 api_key,显式 reveal 按不存在 id 返回错误,MCP tools/list 无 reveal 工具。human_verify: 人主动点击后仍可查看/复制,默认列表不显示原值。"
- human_verify: pending(需用户终验显式查看/复制;agent 验证已通过)
### ✅ R3 — Windows 本机凭证存储可行性与适配接口
- status: done
- complexity: M
- files: src-tauri/src/credential_store.rs, src-tauri/src/lib.rs
- depends: R2
- acceptance: "[cargo test] 凭证存储适配器在模拟存储上完成 put/get/replace/delete 与失败注入;Windows 隔离测试重启后按引用读取同一测试值,失败时不回显值;不满足则停止后续迁移并记录证据。"
### ✅ R4 — Gitea 凭证兼容迁移与代办回归
- status: done
- complexity: M
- files: src-tauri/src/db.rs, src-tauri/src/commands/gitea.rs, src-tauri/src/commands/git_ops.rs, src-tauri/src/credential_store.rs
- depends: R1, R3
- acceptance: "[cargo test] 旧 Gitea token 在模拟数据库中 write→verify→switch→clear;注入存储失败时旧值可恢复且状态 needs_repair;create_pull_request 与 webhook 注册仍使用有效凭证,响应不含 token。"
### ✅ R5 — webhook 签名凭证兼容迁移
- status: done
- complexity: M
- files: src-tauri/src/db.rs, src-tauri/src/commands/gitea.rs, src-tauri/src/mcp_webhook.rs, src-tauri/src/credential_store.rs
- depends: R1, R3, R4
- acceptance: "[cargo test] 已登记 webhook_secret 的旧仓库经 write→verify→switch→clear 后仍可验证 webhook 签名;模拟存储失败时旧值可恢复,仓库 DTO 与日志均不含 secret。"
### ✅ R6 — API Key 凭证兼容迁移与显式查看回归
- status: done
- complexity: M
- files: src-tauri/src/db.rs, src-tauri/src/commands/api_keys.rs, src-tauri/src/credential_store.rs, src/components/dashboard/ApiKeysPanel.tsx, src/lib/commands.ts
- depends: R2, R3
- acceptance: "[cargo test] 旧 API Key 迁移后列表仍无原值,人的显式 reveal 返回原值;模拟迁移失败保留可恢复数据且不产生半迁移的假成功。"
### ✅ R7 — 项目资源只读 readiness 与操作边界
- status: done
- complexity: M
- files: src-tauri/src/commands/gitea.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/mcp_server.rs, src-tauri/src/lib.rs, src/lib/commands.ts
- depends: R4, R5
- acceptance: "[cargo test] get_project_resource_status(p1) → configured/unavailable/needs_repair 有来源且不含凭证;p2 越界被拒绝;MCP tools/list 无 get_secret、任意 HTTP/SSH/SQL 代理。"
### ✅ R8 — 契约文档与 Enterprise 非生产样板终验
- status: done
- complexity: M
- files: docs/ai-context/project-context.yaml, docs/ai-context/agent-button-system.md, docs/ai-context/project-brief.md, AGENTS.md, .blueprint/manifest.yaml, .blueprint/modules/project-resource-broker.md
- depends: R6, R7
- acceptance: "agent_verify: [MCP] Enterprise 项目调用只读 resource status → 返回非秘密 readiness;[cargo test] 模拟 Gitea 调用 create_pull_request → 仅返回 PR 标识/可行动错误,日志与调用结果无原始凭证;[CLI] git diff --check → exit 0。human_verify: 确认 Gitea/CI/服务器凭证仍在原用途位置,未创建真实 PR、触发生产部署或数据库写入。"
- human_verify: pending(真实 UI 与凭证归属留待用户终验;agent 非生产路径已通过)
## 实施验证(合成数据,未接触真实生产密钥)
- R1–R2:DTO/API Key 常态列表序列化测试通过;人的显式 API Key reveal 只在本机 Tauri command,MCP 不暴露。
- R3:Windows Credential Manager 随机测试引用跨进程读取并清理;模拟存储的写、读、覆盖、删除与失败注入通过。
- R4–R6:旧值迁移、存储/数据库失败保留旧值、SQLite 迁移前备份及恢复演练、新建/更新/删除回归通过;模拟 Gitea PR 与 webhook 注册均只访问 `127.0.0.1`。
- R7–R8:内存 Enterprise fixture 经真实 loopback MCP HTTP/SSE 完成 `initialize → tools/list → tools/call`,readiness 不含凭证,跨项目参数被拒绝。
- 未验证:真实用户 DB/凭证迁移、真实 Gitea/CI/服务器、正式安装版 UI 人工终验和真实 PR/部署;现有备份若不含当前旧值会拒绝迁移并要求人工安排新备份,不会降级明文。
## 阶段退出标准
- P0(R1–R2):普通列表/DTO 不再携带原始值;不误称数据库已加密。
- P1(R3–R6):本机代办凭证的受保护存储与兼容迁移可恢复,现有 Gitea/API Key/webhook 能力不回退。
- P2(R7–R8):Agent 能获取 Enterprise 的非秘密 readiness,PR 代办通过模拟 Gitea 验证;真实 PR 试用需另行按交付流程授权。量化是否真减少人工传密和失败定位时间;未达到收益门槛,不扩成通用 Vault。

View File

@ -27,6 +27,7 @@ Git 多空间开发管理器,同时也是**项目 Agent 化的本地控制面*
src/lib/commands.ts 所有 Tauri command 的前端封装(唯一调用入口) src/lib/commands.ts 所有 Tauri command 的前端封装(唯一调用入口)
src-tauri/src/commands/ Rust 后端实现(每功能一个 .rs 文件) src-tauri/src/commands/ Rust 后端实现(每功能一个 .rs 文件)
src-tauri/src/db.rs SQLite 建表 + migration 逻辑 src-tauri/src/db.rs SQLite 建表 + migration 逻辑
src-tauri/src/credential_store.rs Windows 本机受保护凭证存储与旧值兼容迁移(SQLite 仅保存引用)
src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口) src-tauri/src/mcp_server.rs MCP Server 路由层 + SSE 协议(agent 调用炼境的入口)
src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准) src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具清单以 tools_list_result 为准)
src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮)

View File

@ -139,6 +139,12 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
这套 scope 是单用户本机的误操作隔离,不是多租户认证。威胁模型变化前,不向项目配置写持久 token。 这套 scope 是单用户本机的误操作隔离,不是多租户认证。威胁模型变化前,不向项目配置写持久 token。
## 项目资源与凭证代办边界
`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 用户下的进程隔离不是本方案提供的安全保证。
## production 交付观测边界 ## production 交付观测边界
项目可自主管理 `.lian-jing/capabilities.yaml` 的 `delivery.targets.production`,声明 Gitea Actions 的**精确 status context** 与公开 HTTP JSON 版本探针。炼境只读该声明;接入包不生成或覆盖项目的 production 配置。探针不接受 URL 凭证、认证头、查询参数、非 HTTP(S) 协议或本机/内网地址;连接时过滤域名解析出的私网地址,不走环境代理、不跟随重定向,并限制请求时间与响应大小。 项目可自主管理 `.lian-jing/capabilities.yaml` 的 `delivery.targets.production`,声明 Gitea Actions 的**精确 status context** 与公开 HTTP JSON 版本探针。炼境只读该声明;接入包不生成或覆盖项目的 production 配置。探针不接受 URL 凭证、认证头、查询参数、非 HTTP(S) 协议或本机/内网地址;连接时过滤域名解析出的私网地址,不走环境代理、不跟随重定向,并限制请求时间与响应大小。

View File

@ -26,6 +26,7 @@
src/lib/commands.ts 前后端契约(唯一前端入口) src/lib/commands.ts 前后端契约(唯一前端入口)
src-tauri/src/commands/ Rust 后端实现(一功能一文件) src-tauri/src/commands/ Rust 后端实现(一功能一文件)
src-tauri/src/db.rs 数据库 schema + migration 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_server.rs MCP Server(agent 调用炼境的入口)
src-tauri/src/mcp_tools.rs MCP tool schema 与 dispatch src-tauri/src/mcp_tools.rs MCP tool schema 与 dispatch
src-tauri/src/commands/delivery_status.rs 项目交付信号聚合(Git / Gitea / 部署证据) src-tauri/src/commands/delivery_status.rs 项目交付信号聚合(Git / Gitea / 部署证据)

View File

@ -15,7 +15,7 @@ contracts:
- "src-tauri/src/commands/toolchain.rs" - "src-tauri/src/commands/toolchain.rs"
- "src-tauri/src/commands/cicd.rs" - "src-tauri/src/commands/cicd.rs"
- "src-tauri/src/commands/gitea.rs" - "src-tauri/src/commands/gitea.rs"
doc: "工具 schema、dispatch 与调用说明必须同步;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 或部署。" 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 或部署。"
- label: 被管理项目身份上下文 - label: 被管理项目身份上下文
paths: paths:
@ -30,7 +30,7 @@ contracts:
paths: paths:
- "src-tauri/src/commands/**/*.rs" - "src-tauri/src/commands/**/*.rs"
- "src/lib/commands.ts" - "src/lib/commands.ts"
doc: "新增 command 时前后端必须同步。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 仅返回非秘密状态;旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。"
architecture_docs: architecture_docs:
- "AGENTS.md" - "AGENTS.md"

View File

@ -6,7 +6,6 @@ pub struct ApiKey {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub provider: String, pub provider: String,
pub api_key: String,
pub base_url: Option<String>, pub base_url: Option<String>,
pub model: Option<String>, pub model: Option<String>,
pub notes: Option<String>, pub notes: Option<String>,
@ -29,17 +28,15 @@ fn row_to_api_key(row: &rusqlite::Row<'_>) -> rusqlite::Result<ApiKey> {
id: row.get(0)?, id: row.get(0)?,
name: row.get(1)?, name: row.get(1)?,
provider: row.get(2)?, provider: row.get(2)?,
api_key: row.get(3)?, base_url: row.get(3)?,
base_url: row.get(4)?, model: row.get(4)?,
model: row.get(5)?, notes: row.get(5)?,
notes: row.get(6)?, created_at: row.get(6)?,
created_at: row.get(7)?, updated_at: row.get(7)?,
updated_at: row.get(8)?,
}) })
} }
const SELECT_COLS: &str = const SELECT_COLS: &str = "id, name, provider, base_url, model, notes, created_at, updated_at";
"id, name, provider, api_key, base_url, model, notes, created_at, updated_at";
pub fn list_api_keys(conn: &Connection) -> Vec<ApiKey> { pub fn list_api_keys(conn: &Connection) -> Vec<ApiKey> {
let sql = format!("SELECT {SELECT_COLS} FROM api_keys ORDER BY updated_at DESC"); let sql = format!("SELECT {SELECT_COLS} FROM api_keys ORDER BY updated_at DESC");
@ -52,23 +49,34 @@ pub fn list_api_keys(conn: &Connection) -> Vec<ApiKey> {
.unwrap_or_default() .unwrap_or_default()
} }
pub fn create_api_key(conn: &Connection, input: &SaveApiKeyInput) -> Result<ApiKey, String> { pub fn create_api_key(
conn: &Connection,
store: &impl crate::credential_store::CredentialStore,
input: &SaveApiKeyInput,
) -> Result<ApiKey, String> {
let id = format!("ak-{}", uuid::Uuid::new_v4()); let id = format!("ak-{}", uuid::Uuid::new_v4());
conn.execute( let reference = crate::credential_store::versioned_reference(
"INSERT INTO api_keys (id, name, provider, api_key, base_url, model, notes) crate::credential_store::LegacyCredentialKind::ApiKey,
VALUES (?1,?2,?3,?4,?5,?6,?7)", &id,
params![ );
id, crate::credential_store::put_verified(store, &reference, input.api_key.as_bytes())?;
input.name, let result = (|| {
input.provider, let tx = conn
input.api_key, .unchecked_transaction()
input.base_url, .map_err(|_| "创建 API Key 失败".to_string())?;
input.model, tx.execute(
input.notes "INSERT INTO api_keys (id, name, provider, api_key, credential_ref, base_url, model, notes)
], VALUES (?1,?2,?3,'',?4,?5,?6,?7)",
) params![id, input.name, input.provider, reference, input.base_url, input.model, input.notes],
.map_err(|e| e.to_string())?; ).map_err(|_| "创建 API Key 失败".to_string())?;
get_api_key(conn, &id).ok_or_else(|| "创建后查询失败".into()) let key = get_api_key(&tx, &id).ok_or_else(|| "创建后查询失败".to_string())?;
tx.commit().map_err(|_| "创建 API Key 失败".to_string())?;
Ok(key)
})();
if result.is_err() {
let _ = store.delete(&reference);
}
result
} }
pub fn get_api_key(conn: &Connection, id: &str) -> Option<ApiKey> { pub fn get_api_key(conn: &Connection, id: &str) -> Option<ApiKey> {
@ -78,24 +86,69 @@ pub fn get_api_key(conn: &Connection, id: &str) -> Option<ApiKey> {
pub fn update_api_key( pub fn update_api_key(
conn: &Connection, conn: &Connection,
store: &impl crate::credential_store::CredentialStore,
id: &str, id: &str,
input: &SaveApiKeyInput, input: &SaveApiKeyInput,
) -> Result<ApiKey, String> { ) -> Result<ApiKey, String> {
let rows = conn.execute( let old_reference: Option<String> = conn
"UPDATE api_keys SET name=?1, provider=?2, api_key=?3, base_url=?4, model=?5, notes=?6, updated_at=datetime('now') .query_row(
WHERE id=?7", "SELECT credential_ref FROM api_keys WHERE id = ?1",
params![input.name, input.provider, input.api_key, input.base_url, input.model, input.notes, id], params![id],
) |row| row.get(0),
.map_err(|e| e.to_string())?; )
if rows == 0 { .map_err(|_| "API Key 不存在".to_string())?;
return Err(format!("API Key {} 不存在", id)); let reference = crate::credential_store::versioned_reference(
crate::credential_store::LegacyCredentialKind::ApiKey,
id,
);
crate::credential_store::put_verified(store, &reference, input.api_key.as_bytes())?;
let result = (|| {
let tx = conn
.unchecked_transaction()
.map_err(|_| "更新 API Key 失败".to_string())?;
let rows = tx.execute(
"UPDATE api_keys SET name=?1, provider=?2, api_key='', credential_ref=?3, base_url=?4, model=?5, notes=?6, updated_at=datetime('now')
WHERE id=?7 AND credential_ref IS ?8",
params![input.name, input.provider, reference, input.base_url, input.model, input.notes, id, old_reference],
).map_err(|_| "更新 API Key 失败".to_string())?;
if rows != 1 {
return Err("更新 API Key 失败:凭证引用已变化".into());
}
let key = get_api_key(&tx, id).ok_or_else(|| "更新后查询失败".to_string())?;
tx.commit().map_err(|_| "更新 API Key 失败".to_string())?;
Ok(key)
})();
if result.is_err() {
let _ = store.delete(&reference);
} else if let Some(old) = old_reference {
let _ = store.delete(&old);
} }
get_api_key(conn, id).ok_or_else(|| "更新后查询失败".into()) result
} }
pub fn delete_api_key_fn(conn: &Connection, id: &str) -> Result<(), String> { pub fn delete_api_key_fn(
conn.execute("DELETE FROM api_keys WHERE id=?1", params![id]) conn: &Connection,
.map_err(|e| e.to_string())?; store: &impl crate::credential_store::CredentialStore,
id: &str,
) -> Result<(), String> {
let tx = conn
.unchecked_transaction()
.map_err(|_| "删除 API Key 失败".to_string())?;
let reference: Option<String> = tx
.query_row(
"SELECT credential_ref FROM api_keys WHERE id=?1",
params![id],
|row| row.get(0),
)
.map_err(|_| "API Key 不存在".to_string())?;
tx.execute("DELETE FROM api_keys WHERE id=?1", params![id])
.map_err(|_| "删除 API Key 失败".to_string())?;
let references: Vec<String> = reference.into_iter().collect();
let snapshots = crate::credential_store::delete_references(store, &references)?;
if tx.commit().is_err() {
crate::credential_store::restore_references(store, &snapshots)?;
return Err("删除 API Key 失败:数据库提交失败".into());
}
Ok(()) Ok(())
} }
@ -110,17 +163,167 @@ pub fn get_api_keys() -> Result<Vec<ApiKey>, String> {
#[tauri::command] #[tauri::command]
pub fn add_api_key(input: SaveApiKeyInput) -> Result<ApiKey, String> { pub fn add_api_key(input: SaveApiKeyInput) -> Result<ApiKey, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
create_api_key(&conn, &input) create_api_key(
&conn,
&crate::credential_store::WindowsCredentialStore,
&input,
)
} }
#[tauri::command] #[tauri::command]
pub fn edit_api_key(id: String, input: SaveApiKeyInput) -> Result<ApiKey, String> { pub fn edit_api_key(id: String, input: SaveApiKeyInput) -> Result<ApiKey, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
update_api_key(&conn, &id, &input) update_api_key(
&conn,
&crate::credential_store::WindowsCredentialStore,
&id,
&input,
)
} }
#[tauri::command] #[tauri::command]
pub fn delete_api_key(id: String) -> Result<(), String> { pub fn delete_api_key(id: String) -> Result<(), String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?; let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
delete_api_key_fn(&conn, &id) delete_api_key_fn(&conn, &crate::credential_store::WindowsCredentialStore, &id)
}
pub fn reveal_api_key_for_conn(
conn: &Connection,
store: &impl crate::credential_store::CredentialStore,
id: &str,
) -> Result<String, String> {
let bytes = crate::credential_store::get_or_migrate_legacy(
conn,
store,
crate::credential_store::LegacyCredentialKind::ApiKey,
id,
)?;
String::from_utf8(bytes).map_err(|_| "凭证状态 needs_repair:API Key 编码无效".to_string())
}
/// 仅供本机 UI 在用户显式请求查看或复制时调用;MCP 不公开此 command。
#[tauri::command]
pub fn reveal_api_key(id: String) -> Result<String, String> {
let conn = crate::db::pool().get().map_err(|e| e.to_string())?;
reveal_api_key_for_conn(&conn, &crate::credential_store::WindowsCredentialStore, &id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::credential_store::CredentialStore;
#[test]
fn list_hides_key_and_explicit_reveal_requires_existing_id() {
let conn = crate::db::conn_with_schema();
let store = crate::credential_store::MemoryCredentialStore::new();
conn.execute("INSERT INTO api_keys (id, name, provider, api_key) VALUES ('k1', 'key', 'test', 'secret')", []).unwrap();
let listed = list_api_keys(&conn);
assert!(serde_json::to_string(&listed).unwrap().contains("secret") == false);
assert_eq!(
reveal_api_key_for_conn(&conn, &store, "k1").unwrap(),
"secret"
);
assert!(reveal_api_key_for_conn(&conn, &store, "missing").is_err());
let (legacy, reference): (String, String) = conn
.query_row(
"SELECT api_key, credential_ref FROM api_keys WHERE id='k1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert!(legacy.is_empty());
assert!(store.get(&reference).unwrap().is_some());
}
#[test]
fn api_key_write_failure_preserves_legacy_and_metadata() {
let conn = crate::db::conn_with_schema();
conn.execute("INSERT INTO api_keys (id, name, provider, api_key) VALUES ('k1', 'old', 'test', 'synthetic-old-key')", []).unwrap();
let failing = crate::credential_store::MemoryCredentialStore::failing();
let input = SaveApiKeyInput {
name: "new".into(),
provider: "test".into(),
api_key: "synthetic-new-key".into(),
base_url: None,
model: None,
notes: None,
};
let error = update_api_key(&conn, &failing, "k1", &input).unwrap_err();
assert!(error.contains("needs_repair"));
assert!(!error.contains("synthetic-new-key"));
let (name, old, reference): (String, String, Option<String>) = conn
.query_row(
"SELECT name, api_key, credential_ref FROM api_keys WHERE id='k1'",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
assert_eq!(name, "old");
assert_eq!(old, "synthetic-old-key");
assert!(reference.is_none());
let store = crate::credential_store::MemoryCredentialStore::new();
update_api_key(&conn, &store, "k1", &input).unwrap();
let listed = list_api_keys(&conn);
assert!(!serde_json::to_string(&listed)
.unwrap()
.contains("synthetic-new-key"));
assert_eq!(
reveal_api_key_for_conn(&conn, &store, "k1").unwrap(),
"synthetic-new-key"
);
}
#[test]
fn new_api_key_keeps_only_reference_in_database_and_delete_cleans_store() {
let conn = crate::db::conn_with_schema();
let store = crate::credential_store::MemoryCredentialStore::new();
let input = SaveApiKeyInput {
name: "test".into(),
provider: "test".into(),
api_key: "synthetic-new-key".into(),
base_url: None,
model: None,
notes: None,
};
let key = create_api_key(&conn, &store, &input).unwrap();
let (legacy, reference): (String, String) = conn
.query_row(
"SELECT api_key, credential_ref FROM api_keys WHERE id=?1",
params![key.id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert!(legacy.is_empty());
assert_eq!(
store.get(&reference).unwrap().as_deref(),
Some(b"synthetic-new-key".as_slice())
);
assert_eq!(
reveal_api_key_for_conn(&conn, &store, &key.id).unwrap(),
"synthetic-new-key"
);
delete_api_key_fn(&conn, &store, &key.id).unwrap();
assert!(store.get(&reference).unwrap().is_none());
}
#[test]
fn delete_failure_keeps_api_key_and_credential_reference() {
let conn = crate::db::conn_with_schema();
let store = crate::credential_store::MemoryCredentialStore::failing_deletes();
store.put("api-key/k1/v1", b"synthetic-key").unwrap();
conn.execute(
"INSERT INTO api_keys (id, name, provider, api_key, credential_ref) VALUES ('k1', 'key', 'test', '', 'api-key/k1/v1')",
[],
).unwrap();
assert!(delete_api_key_fn(&conn, &store, "k1")
.unwrap_err()
.contains("needs_repair"));
assert!(get_api_key(&conn, "k1").is_some());
assert_eq!(
store.get("api-key/k1/v1").unwrap().as_deref(),
Some(b"synthetic-key".as_slice())
);
}
} }

View File

@ -15,22 +15,69 @@ pub struct GitStatus {
/// 按 URL 前缀匹配已注册的 Gitea 实例,返回其 access_token /// 按 URL 前缀匹配已注册的 Gitea 实例,返回其 access_token
fn gitea_token_for_url(url: &str) -> Option<String> { fn gitea_token_for_url(url: &str) -> Option<String> {
let conn = db::pool().get().ok()?; let conn = db::pool().get().ok()?;
let mut stmt = conn let instances: Vec<(String, String)> = {
.prepare("SELECT url, access_token FROM gitea_instances") let mut stmt = conn.prepare("SELECT id, url FROM gitea_instances").ok()?;
.ok()?; let rows = stmt
let rows = stmt .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))) .ok()?
.ok()?; .filter_map(Result::ok)
for row in rows.flatten() { .collect();
let (base, token) = row; rows
let base = base.trim_end_matches('/'); };
if !base.is_empty() && url.starts_with(base) { for (instance_id, base) in instances {
return Some(token); if gitea_url_matches(&base, url) {
return crate::commands::gitea::instance_auth(&conn, &instance_id)
.ok()
.map(|(_, token)| token);
} }
} }
None None
} }
fn gitea_url_matches(base: &str, remote: &str) -> bool {
let (Ok(base), Ok(remote)) = (tauri::Url::parse(base), tauri::Url::parse(remote)) else {
return false;
};
if !matches!(base.scheme(), "http" | "https")
|| base.scheme() != remote.scheme()
|| base.host_str() != remote.host_str()
|| base.port_or_known_default() != remote.port_or_known_default()
{
return false;
}
let prefix = base.path().trim_end_matches('/');
prefix.is_empty() || remote.path() == prefix || remote.path().starts_with(&format!("{prefix}/"))
}
#[cfg(test)]
mod credential_match_tests {
use super::gitea_url_matches;
#[test]
fn gitea_token_url_match_requires_same_origin_and_path_boundary() {
assert!(gitea_url_matches(
"https://gitea.example.com",
"https://gitea.example.com/o/r.git"
));
assert!(gitea_url_matches(
"https://gitea.example.com/git",
"https://gitea.example.com/git/o/r.git"
));
assert!(!gitea_url_matches(
"https://gitea.example.com",
"https://gitea.example.com.evil/o/r.git"
));
assert!(!gitea_url_matches(
"https://gitea.example.com/git",
"https://gitea.example.com/github/o/r.git"
));
assert!(!gitea_url_matches(
"https://gitea.example.com",
"http://gitea.example.com/o/r.git"
));
}
}
/// 凭据链:ssh agent → git credential helper(与 CLI 同一凭据源)→ Gitea 实例 token → default /// 凭据链:ssh agent → git credential helper(与 CLI 同一凭据源)→ Gitea 实例 token → default
/// 不再依赖 GitHub 登录,任何 remote(Gitea / GitHub / 其他)走系统已有凭据 /// 不再依赖 GitHub 登录,任何 remote(Gitea / GitHub / 其他)走系统已有凭据
fn make_callbacks() -> RemoteCallbacks<'static> { fn make_callbacks() -> RemoteCallbacks<'static> {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,737 @@
//! 本机用户级凭证存储。生产 Windows 构建使用 Credential Manager;其他平台 fail closed。
#[cfg(test)]
use std::collections::HashMap;
#[cfg(test)]
use std::sync::Mutex;
fn ensure_legacy_backup(
conn: &rusqlite::Connection,
table: &str,
id_column: &str,
secret_column: &str,
id: &str,
legacy: &str,
) -> Result<(), String> {
let database_path: String = conn
.query_row("PRAGMA database_list", [], |row| row.get(2))
.map_err(|_| "凭证状态 needs_repair:无法确定数据库备份位置".to_string())?;
if database_path.is_empty() {
// 仅测试用的内存数据库无需磁盘备份。
return Ok(());
}
let path = std::path::Path::new(&database_path);
let backup = path.with_extension("pre-credential-migration.db");
if !backup.exists() {
conn.execute("VACUUM main INTO ?1", [backup.to_string_lossy().as_ref()])
.map_err(|_| "凭证状态 needs_repair:迁移前备份失败".to_string())?;
}
let backup_conn =
rusqlite::Connection::open_with_flags(&backup, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.map_err(|_| "凭证状态 needs_repair:迁移前备份不可读取".to_string())?;
let check: String = backup_conn
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
.map_err(|_| "凭证状态 needs_repair:迁移前备份校验失败".to_string())?;
if check != "ok" {
return Err("凭证状态 needs_repair:迁移前备份完整性检查失败".into());
}
// 防止恢复过旧数据库后误把一份不含当前旧值的备份当作可恢复证据。
let check_sql = format!(
"SELECT EXISTS(SELECT 1 FROM {table} WHERE {id_column} = ?1 AND {secret_column} = ?2)"
);
let contains_value: bool = backup_conn
.query_row(&check_sql, rusqlite::params![id, legacy], |row| row.get(0))
.map_err(|_| "凭证状态 needs_repair:迁移前备份内容无法验证".to_string())?;
if !contains_value {
return Err("凭证状态 needs_repair:现有备份不含当前旧值;请先安排新的可恢复备份".into());
}
Ok(())
}
pub trait CredentialStore {
fn put(&self, reference: &str, value: &[u8]) -> Result<(), String>;
fn get(&self, reference: &str) -> Result<Option<Vec<u8>>, String>;
fn delete(&self, reference: &str) -> Result<(), String>;
}
#[derive(Debug, Clone, Copy)]
pub enum LegacyCredentialKind {
GiteaAccessToken,
GiteaWebhookSecret,
ApiKey,
}
pub fn reference_for(kind: LegacyCredentialKind, id: &str) -> String {
match kind {
LegacyCredentialKind::GiteaAccessToken => format!("gitea/access-token/{id}"),
LegacyCredentialKind::GiteaWebhookSecret => format!("gitea/webhook-secret/{id}"),
LegacyCredentialKind::ApiKey => format!("api-key/{id}"),
}
}
pub fn versioned_reference(kind: LegacyCredentialKind, id: &str) -> String {
format!("{}/{}", reference_for(kind, id), uuid::Uuid::new_v4())
}
pub fn put_verified(
store: &impl CredentialStore,
reference: &str,
value: &[u8],
) -> Result<(), String> {
store
.put(reference, value)
.map_err(|_| "凭证状态 needs_repair:受保护存储写入失败".to_string())?;
let verified = store.get(reference);
match verified {
Ok(Some(bytes)) if bytes == value => Ok(()),
other => {
let _ = store.delete(reference);
match other {
Err(_) => Err("凭证状态 needs_repair:写入后读取校验失败".into()),
Ok(None) => Err("凭证状态 needs_repair:写入后无法读取校验".into()),
Ok(Some(_)) => Err("凭证状态 needs_repair:写入后校验不匹配".into()),
}
}
}
}
/// 在数据库删除事务提交前移除凭证;若中途失败,用快照恢复已删除的引用。
pub fn delete_references(
store: &impl CredentialStore,
references: &[String],
) -> Result<Vec<(String, Option<Vec<u8>>)>, String> {
let mut snapshots = Vec::new();
for reference in references {
if !snapshots.iter().any(|(saved, _)| saved == reference) {
let value = store
.get(reference)
.map_err(|_| "凭证状态 needs_repair:删除前无法读取受保护存储".to_string())?;
snapshots.push((reference.clone(), value));
}
}
for index in 0..snapshots.len() {
if store.delete(&snapshots[index].0).is_err() {
restore_references(store, &snapshots[..=index])?;
return Err("凭证状态 needs_repair:受保护存储删除失败,数据库记录已保留".into());
}
}
Ok(snapshots)
}
pub fn restore_references(
store: &impl CredentialStore,
snapshots: &[(String, Option<Vec<u8>>)],
) -> Result<(), String> {
for (reference, value) in snapshots {
if let Some(value) = value {
put_verified(store, reference, value)
.map_err(|_| "凭证状态 needs_repair:受保护存储凭证恢复失败".to_string())?;
}
}
Ok(())
}
impl LegacyCredentialKind {
fn columns(self) -> (&'static str, &'static str, &'static str, &'static str, bool) {
match self {
Self::GiteaAccessToken => (
"gitea_instances",
"id",
"access_token",
"access_token_ref",
false,
),
Self::GiteaWebhookSecret => (
"gitea_repos",
"project_id",
"webhook_secret",
"webhook_secret_ref",
true,
),
Self::ApiKey => ("api_keys", "id", "api_key", "credential_ref", false),
}
}
}
/// 读取已迁移凭证,或以 write→verify→transactional switch→clear 顺序升级旧记录。
/// 仅接受枚举指定的表/列;错误文本固定为非秘密描述。
pub fn get_or_migrate_legacy(
conn: &rusqlite::Connection,
store: &impl CredentialStore,
kind: LegacyCredentialKind,
id: &str,
) -> Result<Vec<u8>, String> {
let (table, id_column, secret_column, reference_column, nullable) = kind.columns();
let query =
format!("SELECT {secret_column}, {reference_column} FROM {table} WHERE {id_column} = ?1");
let (legacy, reference): (Option<String>, Option<String>) = conn
.query_row(&query, [id], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(|_| "凭证记录不存在或无法读取".to_string())?;
if let Some(reference) = reference.filter(|value| !value.is_empty()) {
let value = store
.get(&reference)
.map_err(|_| "凭证状态 needs_repair:受保护存储不可用".to_string())?
.ok_or_else(|| "凭证状态 needs_repair:受保护存储中缺少引用值".to_string())?;
return if value.is_empty() {
Err("凭证状态 needs_repair:受保护存储中的凭证为空".into())
} else {
Ok(value)
};
}
let legacy = legacy
.filter(|value| !value.is_empty())
.ok_or_else(|| "凭证状态 not_configured:没有可迁移的凭证".to_string())?;
ensure_legacy_backup(conn, table, id_column, secret_column, id, &legacy)?;
let reference = versioned_reference(kind, id);
let bytes = legacy.as_bytes();
put_verified(store, &reference, bytes)?;
let result = (|| {
let tx = conn
.unchecked_transaction()
.map_err(|_| "凭证状态 needs_repair:数据库切换失败".to_string())?;
let clear = if nullable { "NULL" } else { "''" };
let update = format!(
"UPDATE {table} SET {reference_column} = ?1, {secret_column} = {clear} \
WHERE {id_column} = ?2 AND {secret_column} = ?3 AND \
({reference_column} IS NULL OR {reference_column} = '')"
);
let changed = tx
.execute(&update, rusqlite::params![reference, id, legacy])
.map_err(|_| "凭证状态 needs_repair:数据库切换失败".to_string())?;
if changed != 1 {
return Err("凭证状态 needs_repair:记录在迁移期间发生变化".into());
}
tx.commit()
.map_err(|_| "凭证状态 needs_repair:数据库切换失败".to_string())
})();
if result.is_err() {
let _ = store.delete(&reference);
}
result?;
Ok(bytes.to_vec())
}
#[cfg(windows)]
#[derive(Debug, Default, Clone, Copy)]
pub struct WindowsCredentialStore;
#[cfg(windows)]
impl CredentialStore for WindowsCredentialStore {
fn put(&self, reference: &str, value: &[u8]) -> Result<(), String> {
windows_store::put(reference, value)
}
fn get(&self, reference: &str) -> Result<Option<Vec<u8>>, String> {
windows_store::get(reference)
}
fn delete(&self, reference: &str) -> Result<(), String> {
windows_store::delete(reference)
}
}
#[cfg(not(windows))]
#[derive(Debug, Default, Clone, Copy)]
pub struct WindowsCredentialStore;
#[cfg(not(windows))]
impl CredentialStore for WindowsCredentialStore {
fn put(&self, _reference: &str, _value: &[u8]) -> Result<(), String> {
Err("Windows Credential Manager 不可用".into())
}
fn get(&self, _reference: &str) -> Result<Option<Vec<u8>>, String> {
Err("Windows Credential Manager 不可用".into())
}
fn delete(&self, _reference: &str) -> Result<(), String> {
Err("Windows Credential Manager 不可用".into())
}
}
#[cfg(windows)]
mod windows_store {
use std::ffi::c_void;
use std::ptr::null_mut;
const CRED_TYPE_GENERIC: u32 = 1;
const CRED_PERSIST_LOCAL_MACHINE: u32 = 2;
const CRED_MAX_CREDENTIAL_BLOB_SIZE: usize = 5 * 512;
const ERROR_NOT_FOUND: i32 = 1168;
#[repr(C)]
struct FileTime {
low: u32,
high: u32,
}
#[repr(C)]
struct CredentialAttributeW {
keyword: *mut u16,
flags: u32,
value_size: u32,
value: *mut u8,
}
#[repr(C)]
struct CredentialW {
flags: u32,
kind: u32,
target_name: *mut u16,
comment: *mut u16,
last_written: FileTime,
credential_blob_size: u32,
credential_blob: *mut u8,
persist: u32,
attribute_count: u32,
attributes: *mut CredentialAttributeW,
target_alias: *mut u16,
user_name: *mut u16,
}
#[link(name = "Advapi32")]
unsafe extern "system" {
fn CredWriteW(credential: *const CredentialW, flags: u32) -> i32;
fn CredReadW(
target: *const u16,
kind: u32,
flags: u32,
output: *mut *mut CredentialW,
) -> i32;
fn CredDeleteW(target: *const u16, kind: u32, flags: u32) -> i32;
fn CredFree(buffer: *mut c_void);
}
fn target(reference: &str) -> Result<Vec<u16>, String> {
if reference.is_empty() || reference.len() > 240 || reference.chars().any(char::is_control)
{
return Err("凭证引用格式无效".into());
}
Ok(format!("dev-manager-tauri/{reference}")
.encode_utf16()
.chain(Some(0))
.collect())
}
pub fn put(reference: &str, value: &[u8]) -> Result<(), String> {
if value.is_empty() || value.len() > CRED_MAX_CREDENTIAL_BLOB_SIZE {
return Err("凭证值长度超出 Credential Manager 限制".into());
}
let target = target(reference)?;
let mut blob = value.to_vec();
let credential = CredentialW {
flags: 0,
kind: CRED_TYPE_GENERIC,
target_name: target.as_ptr() as *mut u16,
comment: null_mut(),
last_written: FileTime { low: 0, high: 0 },
credential_blob_size: blob.len() as u32,
credential_blob: blob.as_mut_ptr(),
persist: CRED_PERSIST_LOCAL_MACHINE,
attribute_count: 0,
attributes: null_mut(),
target_alias: null_mut(),
user_name: null_mut(),
};
let ok = unsafe { CredWriteW(&credential, 0) };
blob.fill(0);
if ok == 0 {
Err("Credential Manager 写入失败".into())
} else {
Ok(())
}
}
pub fn get(reference: &str) -> Result<Option<Vec<u8>>, String> {
let target = target(reference)?;
let mut raw = null_mut();
let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut raw) };
if ok == 0 {
let error = std::io::Error::last_os_error();
return if error.raw_os_error() == Some(ERROR_NOT_FOUND) {
Ok(None)
} else {
Err("Credential Manager 读取失败".into())
};
}
if raw.is_null() {
return Err("Credential Manager 返回无效凭证".into());
}
let credential = unsafe { &*raw };
let result = if credential.credential_blob_size as usize > CRED_MAX_CREDENTIAL_BLOB_SIZE {
Err("Credential Manager 凭证长度无效".into())
} else if credential.credential_blob_size > 0 && credential.credential_blob.is_null() {
Err("Credential Manager 返回无效凭证".into())
} else if credential.credential_blob_size == 0 {
Ok(Some(Vec::new()))
} else {
let bytes = unsafe {
std::slice::from_raw_parts(
credential.credential_blob,
credential.credential_blob_size as usize,
)
};
Ok(Some(bytes.to_vec()))
};
unsafe {
CredFree(raw.cast());
}
result
}
pub fn delete(reference: &str) -> Result<(), String> {
let target = target(reference)?;
let ok = unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) };
if ok != 0 {
return Ok(());
}
if std::io::Error::last_os_error().raw_os_error() == Some(ERROR_NOT_FOUND) {
Ok(())
} else {
Err("Credential Manager 删除失败".into())
}
}
}
#[cfg(test)]
pub(crate) struct MemoryCredentialStore {
values: Mutex<HashMap<String, Vec<u8>>>,
fail_writes: bool,
fail_reads: bool,
fail_deletes: bool,
fail_delete_reference: Option<String>,
}
#[cfg(test)]
impl MemoryCredentialStore {
pub(crate) fn new() -> Self {
Self {
values: Mutex::new(HashMap::new()),
fail_writes: false,
fail_reads: false,
fail_deletes: false,
fail_delete_reference: None,
}
}
pub(crate) fn failing() -> Self {
Self {
values: Mutex::new(HashMap::new()),
fail_writes: true,
fail_reads: true,
fail_deletes: true,
fail_delete_reference: None,
}
}
pub(crate) fn failing_deletes() -> Self {
Self {
values: Mutex::new(HashMap::new()),
fail_writes: false,
fail_reads: false,
fail_deletes: true,
fail_delete_reference: None,
}
}
pub(crate) fn failing_delete_for(reference: &str) -> Self {
Self {
values: Mutex::new(HashMap::new()),
fail_writes: false,
fail_reads: false,
fail_deletes: false,
fail_delete_reference: Some(reference.into()),
}
}
pub(crate) fn contains_value(&self, value: &[u8]) -> bool {
self.values
.lock()
.map(|values| values.values().any(|stored| stored == value))
.unwrap_or(false)
}
}
#[cfg(test)]
impl CredentialStore for MemoryCredentialStore {
fn put(&self, reference: &str, value: &[u8]) -> Result<(), String> {
if self.fail_writes {
return Err("模拟凭证存储写入失败".into());
}
self.values
.lock()
.map_err(|_| "凭证存储锁不可用".to_string())?
.insert(reference.into(), value.into());
Ok(())
}
fn get(&self, reference: &str) -> Result<Option<Vec<u8>>, String> {
if self.fail_reads {
return Err("模拟凭证存储读取失败".into());
}
Ok(self
.values
.lock()
.map_err(|_| "凭证存储锁不可用".to_string())?
.get(reference)
.cloned())
}
fn delete(&self, reference: &str) -> Result<(), String> {
if self.fail_deletes || self.fail_delete_reference.as_deref() == Some(reference) {
return Err("模拟凭证存储删除失败".into());
}
self.values
.lock()
.map_err(|_| "凭证存储锁不可用".to_string())?
.remove(reference);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::{
get_or_migrate_legacy, CredentialStore, LegacyCredentialKind, MemoryCredentialStore,
};
use rusqlite::Connection;
fn migration_db() -> Connection {
let conn = crate::db::conn_with_schema();
conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('instance-test', 'fake', 'https://example.invalid', 'synthetic-test-token')", []).unwrap();
conn.execute(
"INSERT INTO projects (id, name) VALUES ('project-test', 'fake')",
[],
)
.unwrap();
conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret) VALUES ('repo-test', 'instance-test', 'project-test', 'owner', 'repo', 'synthetic-webhook-secret')", []).unwrap();
conn.execute("INSERT INTO api_keys (id, name, provider, api_key) VALUES ('key-test', 'fake', 'test', 'synthetic-api-key')", []).unwrap();
conn
}
#[test]
fn memory_adapter_supports_put_replace_get_delete_and_failures() {
let store = MemoryCredentialStore::new();
store.put("test/ref", b"first").unwrap();
assert_eq!(
store.get("test/ref").unwrap().as_deref(),
Some(b"first".as_slice())
);
store.put("test/ref", b"second").unwrap();
assert_eq!(
store.get("test/ref").unwrap().as_deref(),
Some(b"second".as_slice())
);
store.delete("test/ref").unwrap();
assert_eq!(store.get("test/ref").unwrap(), None);
let failing = MemoryCredentialStore::failing();
let error = failing.put("test/ref", b"do-not-print-this").unwrap_err();
assert!(!error.contains("do-not-print-this"));
}
#[test]
fn legacy_migration_verifies_then_switches_reference_and_clears_old_value() {
let conn = migration_db();
let store = MemoryCredentialStore::new();
let value = get_or_migrate_legacy(
&conn,
&store,
LegacyCredentialKind::GiteaAccessToken,
"instance-test",
)
.unwrap();
assert_eq!(value, b"synthetic-test-token");
let (legacy, reference): (String, String) = conn.query_row("SELECT access_token, access_token_ref FROM gitea_instances WHERE id='instance-test'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap();
assert!(legacy.is_empty());
assert_eq!(
store.get(&reference).unwrap().as_deref(),
Some(b"synthetic-test-token".as_slice())
);
let webhook = get_or_migrate_legacy(
&conn,
&store,
LegacyCredentialKind::GiteaWebhookSecret,
"project-test",
)
.unwrap();
assert_eq!(webhook, b"synthetic-webhook-secret");
let old_webhook: Option<String> = conn
.query_row(
"SELECT webhook_secret FROM gitea_repos WHERE project_id='project-test'",
[],
|row| row.get(0),
)
.unwrap();
assert!(old_webhook.is_none());
let key =
get_or_migrate_legacy(&conn, &store, LegacyCredentialKind::ApiKey, "key-test").unwrap();
assert_eq!(key, b"synthetic-api-key");
}
#[test]
fn failed_legacy_migration_keeps_old_value_and_returns_repair_state_without_secret() {
let conn = migration_db();
let store = MemoryCredentialStore::failing();
let error = get_or_migrate_legacy(
&conn,
&store,
LegacyCredentialKind::GiteaAccessToken,
"instance-test",
)
.unwrap_err();
assert!(error.contains("needs_repair"));
assert!(!error.contains("synthetic-test-token"));
let (legacy, reference): (String, Option<String>) = conn.query_row("SELECT access_token, access_token_ref FROM gitea_instances WHERE id='instance-test'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap();
assert_eq!(legacy, "synthetic-test-token");
assert!(reference.is_none());
}
#[test]
fn migration_database_failure_keeps_legacy_secret_and_allows_retry() {
let conn = migration_db();
let store = MemoryCredentialStore::new();
conn.execute_batch("CREATE TRIGGER fail_credential_switch BEFORE UPDATE OF access_token_ref ON gitea_instances BEGIN SELECT RAISE(ABORT, 'injected database failure'); END;").unwrap();
let error = get_or_migrate_legacy(
&conn,
&store,
LegacyCredentialKind::GiteaAccessToken,
"instance-test",
)
.unwrap_err();
assert!(error.contains("needs_repair"));
let (legacy, reference): (String, Option<String>) = conn.query_row("SELECT access_token, access_token_ref FROM gitea_instances WHERE id='instance-test'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap();
assert_eq!(legacy, "synthetic-test-token");
assert!(reference.is_none());
conn.execute_batch("DROP TRIGGER fail_credential_switch;")
.unwrap();
assert_eq!(
get_or_migrate_legacy(
&conn,
&store,
LegacyCredentialKind::GiteaAccessToken,
"instance-test"
)
.unwrap(),
b"synthetic-test-token"
);
let (legacy, reference): (String, String) = conn.query_row("SELECT access_token, access_token_ref FROM gitea_instances WHERE id='instance-test'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap();
assert!(legacy.is_empty());
assert_eq!(
store.get(&reference).unwrap().as_deref(),
Some(b"synthetic-test-token".as_slice())
);
}
#[test]
fn disk_migration_requires_verified_backup_and_backup_restores_old_value() {
let directory = std::env::temp_dir().join(format!(
"lian-jing-credential-backup-test-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir(&directory).unwrap();
let path = directory.join("synthetic.db");
let backup = path.with_extension("pre-credential-migration.db");
let conn = Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE api_keys (id TEXT PRIMARY KEY, api_key TEXT NOT NULL, credential_ref TEXT);").unwrap();
conn.execute(
"INSERT INTO api_keys (id, api_key) VALUES ('k', 'synthetic-backup-key')",
[],
)
.unwrap();
let store = MemoryCredentialStore::new();
std::fs::create_dir(&backup).unwrap();
let error =
get_or_migrate_legacy(&conn, &store, LegacyCredentialKind::ApiKey, "k").unwrap_err();
assert!(error.contains("needs_repair"));
let old: String = conn
.query_row("SELECT api_key FROM api_keys WHERE id='k'", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(old, "synthetic-backup-key");
std::fs::remove_dir(&backup).unwrap();
assert_eq!(
get_or_migrate_legacy(&conn, &store, LegacyCredentialKind::ApiKey, "k").unwrap(),
b"synthetic-backup-key"
);
let live: String = conn
.query_row("SELECT api_key FROM api_keys WHERE id='k'", [], |row| {
row.get(0)
})
.unwrap();
assert!(live.is_empty());
let restored =
Connection::open_with_flags(&backup, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.unwrap();
let backup_value: String = restored
.query_row("SELECT api_key FROM api_keys WHERE id='k'", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(backup_value, "synthetic-backup-key");
conn.execute(
"INSERT INTO api_keys (id, api_key) VALUES ('later', 'synthetic-later-key')",
[],
)
.unwrap();
let stale = get_or_migrate_legacy(&conn, &store, LegacyCredentialKind::ApiKey, "later")
.unwrap_err();
assert!(stale.contains("needs_repair"));
let still_legacy: String = conn
.query_row("SELECT api_key FROM api_keys WHERE id='later'", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(still_legacy, "synthetic-later-key");
drop(restored);
drop(conn);
std::fs::remove_file(&backup).unwrap();
std::fs::remove_file(&path).unwrap();
std::fs::remove_dir(&directory).unwrap();
}
}
#[cfg(all(test, windows))]
mod windows_tests {
use super::{windows_store, CredentialStore, WindowsCredentialStore};
use std::process::Command;
const CHILD_ENV: &str = "DEV_MANAGER_CREDENTIAL_STORE_CHILD_TEST";
#[test]
fn windows_credential_manager_persists_across_processes() {
let reference = format!("isolated-test/{}", uuid::Uuid::new_v4());
let value = format!("credential-store-test-{}", uuid::Uuid::new_v4()).into_bytes();
let store = WindowsCredentialStore;
store.put(&reference, &value).unwrap();
let child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"credential_store::windows_tests::credential_child_probe",
"--ignored",
"--nocapture",
])
.env(CHILD_ENV, &reference)
.env(
"DEV_MANAGER_CREDENTIAL_STORE_EXPECTED",
String::from_utf8(value.clone()).unwrap(),
)
.status();
let cleanup = store.delete(&reference);
assert!(cleanup.is_ok(), "测试凭证清理失败");
assert!(
child.is_ok_and(|status| status.success()),
"Credential Manager 子进程读取测试失败"
);
}
#[test]
#[ignore]
fn credential_child_probe() {
let Ok(reference) = std::env::var(CHILD_ENV) else {
return;
};
let expected = std::env::var("DEV_MANAGER_CREDENTIAL_STORE_EXPECTED").unwrap();
assert_eq!(
windows_store::get(&reference).unwrap().as_deref(),
Some(expected.as_bytes())
);
}
}

View File

@ -860,6 +860,7 @@ fn migrate(conn: &rusqlite::Connection) {
", ",
) )
.expect("create api_keys table"); .expect("create api_keys table");
let _ = conn.execute("ALTER TABLE api_keys ADD COLUMN credential_ref TEXT", []);
// 增量迁移:宿主机部署应用 // 增量迁移:宿主机部署应用
conn.execute_batch( conn.execute_batch(
@ -1048,6 +1049,33 @@ fn migrate(conn: &rusqlite::Connection) {
", ",
) )
.expect("create gitea tables"); .expect("create gitea tables");
let _ = conn.execute(
"ALTER TABLE gitea_instances ADD COLUMN access_token_ref TEXT",
[],
);
let _ = conn.execute(
"ALTER TABLE gitea_repos ADD COLUMN webhook_secret_ref TEXT",
[],
);
}
#[cfg(test)]
pub(crate) fn init_test_pool() {
if POOL.get().is_some() {
return;
}
let manager = SqliteConnectionManager::memory();
let pool = Pool::builder()
.max_size(1)
.build(manager)
.expect("create in-memory test pool");
{
let conn = pool.get().expect("get in-memory test connection");
conn.execute_batch("PRAGMA foreign_keys=ON;")
.expect("pragma");
migrate(&conn);
}
let _ = POOL.set(pool);
} }
#[cfg(test)] #[cfg(test)]

View File

@ -1,4 +1,5 @@
mod commands; mod commands;
mod credential_store;
mod db; mod db;
mod mcp_inject; mod mcp_inject;
mod mcp_server; mod mcp_server;
@ -390,6 +391,7 @@ pub fn run() {
commands::api_keys::add_api_key, commands::api_keys::add_api_key,
commands::api_keys::edit_api_key, commands::api_keys::edit_api_key,
commands::api_keys::delete_api_key, commands::api_keys::delete_api_key,
commands::api_keys::reveal_api_key,
// host apps // host apps
commands::host_apps::get_host_apps, commands::host_apps::get_host_apps,
commands::host_apps::add_host_app, commands::host_apps::add_host_app,
@ -406,6 +408,7 @@ pub fn run() {
commands::gitea::gitea_delete_instance, commands::gitea::gitea_delete_instance,
commands::gitea::gitea_update_instance, commands::gitea::gitea_update_instance,
commands::gitea::gitea_test_connection, commands::gitea::gitea_test_connection,
commands::gitea::get_project_resource_status,
commands::gitea::gitea_list_linked_repos, commands::gitea::gitea_list_linked_repos,
commands::gitea::gitea_get_repo_link, commands::gitea::gitea_get_repo_link,
commands::gitea::gitea_create_repo, commands::gitea::gitea_create_repo,

View File

@ -122,27 +122,7 @@ pub fn read_port() -> u16 {
/// 启动 MCP HTTP 服务(同时支持 Streamable HTTP 和 SSE 两种传输) /// 启动 MCP HTTP 服务(同时支持 Streamable HTTP 和 SSE 两种传输)
pub async fn start(port: u16) { pub async fn start(port: u16) {
let sessions: Sessions = Arc::new(Mutex::new(HashMap::new())); let sessions: Sessions = Arc::new(Mutex::new(HashMap::new()));
let app = mcp_router(sessions);
let app = Router::new()
.route("/health", get(health))
// 单一全局服务器路由(lian-jing):寻址不依赖 group_id,工具显式传 project_id
.route("/mcp", post(handle_mcp).get(handle_sse_connect))
.route(
"/mcp/projects/:project_id",
post(handle_scoped_mcp).get(handle_scoped_sse_connect),
)
// SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回
.route("/mcp/messages", post(handle_sse_message))
.route(
"/mcp/projects/:project_id/messages",
post(handle_scoped_sse_message),
)
// Gitea webhook:接收 push/pull_request 事件写入飞轮
.route(
"/webhook/gitea/:project_id",
post(crate::mcp_webhook::handle_gitea_webhook),
)
.with_state(sessions);
let addr = mcp_bind_addr(port); let addr = mcp_bind_addr(port);
match tokio::net::TcpListener::bind(addr).await { match tokio::net::TcpListener::bind(addr).await {
@ -171,6 +151,29 @@ pub async fn start(port: u16) {
} }
} }
fn mcp_router(sessions: Sessions) -> Router {
Router::new()
.route("/health", get(health))
// 单一全局服务器路由(lian-jing):寻址不依赖 group_id,工具显式传 project_id
.route("/mcp", post(handle_mcp).get(handle_sse_connect))
.route(
"/mcp/projects/:project_id",
post(handle_scoped_mcp).get(handle_scoped_sse_connect),
)
// SSE 消息端点:客户端通过此 URL 发送 JSON-RPC,响应经 SSE 推回
.route("/mcp/messages", post(handle_sse_message))
.route(
"/mcp/projects/:project_id/messages",
post(handle_scoped_sse_message),
)
// Gitea webhook:接收 push/pull_request 事件写入飞轮
.route(
"/webhook/gitea/:project_id",
post(crate::mcp_webhook::handle_gitea_webhook),
)
.with_state(sessions)
}
async fn health() -> &'static str { async fn health() -> &'static str {
"炼境 MCP Server running" "炼境 MCP Server running"
} }
@ -366,6 +369,82 @@ mod tests {
assert!(!list.iter().any(|tool| tool["name"] == "unregister_project")); assert!(!list.iter().any(|tool| tool["name"] == "unregister_project"));
} }
#[tokio::test(flavor = "multi_thread")]
async fn scoped_enterprise_fixture_reads_non_secret_resource_status_over_mcp() {
db::init_test_pool();
let listener = tokio::net::TcpListener::bind(mcp_bind_addr(0))
.await
.unwrap();
let port = listener.local_addr().unwrap().port();
{
let conn = db::pool().get().unwrap();
conn.execute("INSERT OR IGNORE INTO project_profiles (id, name) VALUES ('enterprise-fixture-profile', 'Enterprise fixture')", []).unwrap();
conn.execute("INSERT OR IGNORE INTO project_workspaces (id, profile_id) VALUES ('enterprise-fixture', 'enterprise-fixture-profile')", []).unwrap();
conn.execute(
"UPDATE settings SET value=?1 WHERE key='mcp_port'",
[port.to_string()],
)
.unwrap();
}
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 = format!("http://127.0.0.1:{port}/mcp/projects/enterprise-fixture");
let call = |method: &str, params: Value| -> Value {
ureq::post(&url)
.send_json(json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}))
.unwrap()
.into_json()
.unwrap()
};
let init = call("initialize", json!({}));
assert_eq!(
init["result"]["lianJing"]["project_id"],
"enterprise-fixture"
);
let catalog = call("tools/list", json!({}));
assert!(catalog["result"]["tools"]
.as_array()
.unwrap()
.iter()
.any(|tool| tool["name"] == "get_project_resource_status"));
let result = call(
"tools/call",
json!({"name":"get_project_resource_status","arguments":{}}),
);
assert_eq!(
result["result"]["structuredContent"]["project_id"],
"enterprise-fixture"
);
assert_eq!(
result["result"]["structuredContent"]["gitea_pr"]["state"],
"not_configured"
);
assert!(!result.to_string().contains("access_token"));
let denied = call(
"tools/call",
json!({"name":"get_project_resource_status","arguments":{"project_id":"other-project"}}),
);
assert_eq!(
denied["result"]["structuredContent"]["code"],
"scope_violation"
);
use std::io::BufRead;
let sse = ureq::get(&url).call().unwrap();
assert!(sse
.header("Content-Type")
.unwrap_or("")
.contains("text/event-stream"));
let mut line = String::new();
std::io::BufReader::new(sse.into_reader())
.read_line(&mut line)
.unwrap();
assert!(line.contains("endpoint"));
server.abort();
}
#[tokio::test] #[tokio::test]
async fn readiness_probe_does_not_satisfy_client_reload_evidence() { async fn readiness_probe_does_not_satisfy_client_reload_evidence() {
let project_id = "reload-evidence-fixture"; let project_id = "reload-evidence-fixture";

View File

@ -134,6 +134,17 @@ pub fn tools_list_result() -> Value {
"required": ["project_id", "head", "base", "title"] "required": ["project_id", "head", "base", "title"]
} }
}, },
{
"name": "get_project_resource_status",
"description": "只读查询项目 Gitea PR 与 webhook readiness;仅返回状态和来源,不返回凭证、URL 或连接参数。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID" }
},
"required": ["project_id"]
}
},
{ {
"name": "get_project_snapshots", "name": "get_project_snapshots",
"description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。", "description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。",
@ -534,6 +545,14 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String {
value["resolver"]["status"].as_str().unwrap_or("unknown"), value["resolver"]["status"].as_str().unwrap_or("unknown"),
value["conflicts"].as_array().map(Vec::len).unwrap_or(0) value["conflicts"].as_array().map(Vec::len).unwrap_or(0)
), ),
"get_project_resource_status" => format!(
"项目资源:project_id={},Gitea PR={},webhook={}",
value["project_id"].as_str().unwrap_or("?"),
value["gitea_pr"]["state"].as_str().unwrap_or("unknown"),
value["gitea_webhook"]["state"]
.as_str()
.unwrap_or("unknown")
),
"verify_onboarding" => value["summary"] "verify_onboarding" => value["summary"]
.as_str() .as_str()
.unwrap_or("接入验收结构化结果已返回") .unwrap_or("接入验收结构化结果已返回")
@ -546,6 +565,7 @@ fn tool_success_response(tool_name: &str, text: String) -> Value {
const STRUCTURED_TOOLS: &[&str] = &[ const STRUCTURED_TOOLS: &[&str] = &[
"get_delivery_status", "get_delivery_status",
"get_toolchain_status", "get_toolchain_status",
"get_project_resource_status",
"verify_onboarding", "verify_onboarding",
]; ];
if STRUCTURED_TOOLS.contains(&tool_name) { if STRUCTURED_TOOLS.contains(&tool_name) {
@ -669,6 +689,11 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
let body = args.get("body").and_then(|v| v.as_str()).unwrap_or(""); let body = args.get("body").and_then(|v| v.as_str()).unwrap_or("");
crate::commands::gitea::gitea_api_create_pull_request(pid, head, base, title, body) crate::commands::gitea::gitea_api_create_pull_request(pid, head, base, title, body)
} }
"get_project_resource_status" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
crate::commands::gitea::get_project_resource_status(pid.to_string())
.and_then(|status| serde_json::to_string(&status).map_err(|_| "项目资源状态序列化失败".to_string()))
}
"poll_now" => crate::commands::gitea::poll_all_repos() "poll_now" => crate::commands::gitea::poll_all_repos()
.map(|(polled, ingested)| format!("轮询完成:{polled} 个仓库,新 commit {ingested} 条,PR 事件已同步")), .map(|(polled, ingested)| format!("轮询完成:{polled} 个仓库,新 commit {ingested} 条,PR 事件已同步")),
"setup_webhook" => { "setup_webhook" => {
@ -2042,10 +2067,30 @@ mod tests {
.iter() .iter()
.any(|tool| tool["name"] == "unregister_project")); .any(|tool| tool["name"] == "unregister_project"));
assert!(!tools.iter().any(|tool| tool["name"] == "list_all_projects")); assert!(!tools.iter().any(|tool| tool["name"] == "list_all_projects"));
let readiness = tools
.iter()
.find(|tool| tool["name"] == "get_project_resource_status")
.unwrap();
assert_eq!(
readiness["inputSchema"]["properties"]["project_id"]["const"],
"p1"
);
for forbidden in ["get_secret", "ssh_exec", "run_sql", "http_proxy", "deploy"] {
assert!(!tools.iter().any(|tool| tool["name"] == forbidden));
}
} }
#[tokio::test] #[tokio::test]
async fn scoped_calls_reject_cross_project_and_global_write_before_io() { async fn scoped_calls_reject_cross_project_and_global_write_before_io() {
let readiness_mismatch = tools_call_for_project(
Some(&json!({"name":"get_project_resource_status","arguments":{"project_id":"p2"}})),
"p1",
)
.await;
assert_eq!(
readiness_mismatch["structuredContent"]["code"],
"scope_violation"
);
let mismatch = tools_call_for_project( let mismatch = tools_call_for_project(
Some(&json!({ Some(&json!({
"name": "get_delivery_status", "name": "get_delivery_status",

View File

@ -76,23 +76,36 @@ pub async fn handle_gitea_webhook(
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
) -> Response { ) -> Response {
let secret: Option<String> = db::pool().get().ok().and_then(|c| { let conn = match db::pool().get() {
c.query_row( Ok(conn) => conn,
"SELECT webhook_secret FROM gitea_repos WHERE project_id = ?1", Err(_) => {
[&project_id], return (
|r| r.get(0), StatusCode::SERVICE_UNAVAILABLE,
) Json(json!({"error": "webhook 状态 unavailable"})),
.ok() )
}); .into_response()
let secret = match secret { }
Some(s) => s, };
None => { let secret = match webhook_secret_for_conn(
&conn,
&crate::credential_store::WindowsCredentialStore,
&project_id,
) {
Ok(secret) => secret,
Err(error) if error.contains("not_configured") || error.contains("不存在") => {
return ( return (
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
Json(json!({"error": "项目未绑定 Gitea 仓库或未设置 webhook"})), Json(json!({"error": "项目未绑定 Gitea 仓库或未设置 webhook"})),
) )
.into_response() .into_response()
} }
Err(_) => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"error": "webhook 状态 needs_repair"})),
)
.into_response()
}
}; };
let sig = extract_signature(&headers); let sig = extract_signature(&headers);
@ -129,6 +142,20 @@ pub async fn handle_gitea_webhook(
} }
} }
fn webhook_secret_for_conn(
conn: &rusqlite::Connection,
store: &impl crate::credential_store::CredentialStore,
project_id: &str,
) -> Result<String, String> {
let bytes = crate::credential_store::get_or_migrate_legacy(
conn,
store,
crate::credential_store::LegacyCredentialKind::GiteaWebhookSecret,
project_id,
)?;
String::from_utf8(bytes).map_err(|_| "webhook 状态 needs_repair:凭证编码无效".to_string())
}
async fn handle_push(project_id: &str, body: &Bytes) -> Response { async fn handle_push(project_id: &str, body: &Bytes) -> Response {
let commits = match parse_push_payload(body) { let commits = match parse_push_payload(body) {
Ok(c) => c, Ok(c) => c,
@ -285,6 +312,45 @@ mod tests {
assert!(!verify_hmac_sha256("my-secret", body, "deadbeef")); assert!(!verify_hmac_sha256("my-secret", body, "deadbeef"));
} }
#[test]
fn legacy_webhook_migration_keeps_signature_valid_and_failure_recoverable() {
use crate::credential_store::MemoryCredentialStore;
let conn = crate::db::conn_with_schema();
conn.execute("INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i', 'test', 'https://example.invalid', 'synthetic-token')", []).unwrap();
conn.execute("INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name, webhook_secret) VALUES ('r', 'i', 'p', 'owner', 'repo', 'synthetic-webhook-secret')", []).unwrap();
let failing = MemoryCredentialStore::failing();
let error = webhook_secret_for_conn(&conn, &failing, "p").unwrap_err();
assert!(error.contains("needs_repair"));
assert!(!error.contains("synthetic-webhook-secret"));
let legacy: String = conn
.query_row(
"SELECT webhook_secret FROM gitea_repos WHERE project_id='p'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(legacy, "synthetic-webhook-secret");
let store = MemoryCredentialStore::new();
let secret = webhook_secret_for_conn(&conn, &store, "p").unwrap();
let body = br#"{"commits":[]}"#;
assert!(verify_hmac_sha256(
&secret,
body,
&sign("synthetic-webhook-secret", body)
));
let (legacy, reference): (Option<String>, String) = conn
.query_row(
"SELECT webhook_secret, webhook_secret_ref FROM gitea_repos WHERE project_id='p'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert!(legacy.is_none());
assert!(!reference.is_empty());
assert_eq!(webhook_secret_for_conn(&conn, &store, "p").unwrap(), secret);
}
#[test] #[test]
fn parse_push_payload_extracts_commits() { fn parse_push_payload_extracts_commits() {
let body = br#"{ let body = br#"{

View File

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { copyCurrentApiKey } from "./ApiKeysPanel";
describe("copyCurrentApiKey", () => {
it("reads the current credential instead of reusing a previously revealed value", async () => {
let current = "synthetic-old-key";
let copied = "";
const reveal = async (id: string) => {
expect(id).toBe("k1");
return current;
};
const write = async (value: string) => { copied = value; };
await copyCurrentApiKey("k1", reveal, write);
expect(copied).toBe("synthetic-old-key");
current = "synthetic-new-key";
await copyCurrentApiKey("k1", reveal, write);
expect(copied).toBe("synthetic-new-key");
});
});

View File

@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { import {
getApiKeys, addApiKey, editApiKey, deleteApiKey, getApiKeys, addApiKey, editApiKey, deleteApiKey, revealApiKey,
type ApiKey, type SaveApiKeyInput, type ApiKey, type SaveApiKeyInput,
} from "../../lib/commands"; } from "../../lib/commands";
@ -18,6 +18,14 @@ const PROVIDERS = [
{ value: "custom", label: "自定义", color: "bg-gray-100 text-gray-600 border-gray-200" }, { value: "custom", label: "自定义", color: "bg-gray-100 text-gray-600 border-gray-200" },
]; ];
export async function copyCurrentApiKey(
id: string,
reveal: (id: string) => Promise<string>,
write: (value: string) => Promise<void>,
): Promise<void> {
await write(await reveal(id));
}
function getProviderStyle(provider: string) { function getProviderStyle(provider: string) {
return PROVIDERS.find((p) => p.value === provider)?.color ?? PROVIDERS[PROVIDERS.length - 1].color; return PROVIDERS.find((p) => p.value === provider)?.color ?? PROVIDERS[PROVIDERS.length - 1].color;
} }
@ -43,7 +51,7 @@ function CopyBtn({ value }: { value: string }) {
// ── 模态框表单 ──────────────────────────────────────────────────────────────── // ── 模态框表单 ────────────────────────────────────────────────────────────────
function ApiKeyModal({ initial, onSave, onClose, saving }: { function ApiKeyModal({ initial, onSave, onClose, saving }: {
initial?: ApiKey; initial?: ApiKey & { api_key?: string };
onSave: (input: SaveApiKeyInput) => void; onSave: (input: SaveApiKeyInput) => void;
onClose: () => void; onClose: () => void;
saving: boolean; saving: boolean;
@ -99,7 +107,7 @@ function ApiKeyModal({ initial, onSave, onClose, saving }: {
<label className="text-[11px] text-gray-400 text-right self-start mt-2">备注</label> <label className="text-[11px] text-gray-400 text-right self-start mt-2">备注</label>
<div className="space-y-1"> <div className="space-y-1">
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} <textarea value={notes} onChange={(e) => setNotes(e.target.value)}
placeholder={"支持 Markdown 语法,例如:\n# 使用方式\n- `export API_KEY=\"sk-xxx\"`\n- 启用 provider"} rows={4} placeholder={"支持 Markdown 语法;请勿在备注中粘贴密钥或连接串"} rows={4}
className="w-full text-xs px-2.5 py-2 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 resize-y min-h-[72px] font-mono" /> className="w-full text-xs px-2.5 py-2 rounded border border-gray-200 bg-gray-50 focus:outline-none focus:border-blue-300 resize-y min-h-[72px] font-mono" />
<span className="text-[10px] text-gray-300">支持 Markdown:标题、列表、代码块、表格等</span> <span className="text-[10px] text-gray-300">支持 Markdown:标题、列表、代码块、表格等</span>
</div> </div>
@ -131,9 +139,25 @@ function ApiKeyCard({ item, onEdit, onDelete }: {
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const maskedKey = item.api_key.length > 8 const [secret, setSecret] = useState<string>();
? item.api_key.slice(0, 4) + "••••••••" + item.api_key.slice(-4) const [error, setError] = useState<string>();
: "••••••••"; const reveal = async () => {
try {
setSecret(await revealApiKey(item.id));
setShowKey(true);
setError(undefined);
} catch (cause) {
setError(String(cause));
}
};
const copy = async () => {
try {
await copyCurrentApiKey(item.id, revealApiKey, (value) => navigator.clipboard.writeText(value));
setError(undefined);
} catch (cause) {
setError(String(cause));
}
};
return ( return (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm"> <div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
@ -193,14 +217,16 @@ function ApiKeyCard({ item, onEdit, onDelete }: {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-[11px] text-gray-400 w-16 shrink-0 text-right">API Key</span> <span className="text-[11px] text-gray-400 w-16 shrink-0 text-right">API Key</span>
<span className="text-xs font-mono text-gray-700 flex-1 min-w-0 truncate"> <span className="text-xs font-mono text-gray-700 flex-1 min-w-0 truncate">
{showKey ? item.api_key : maskedKey} {showKey && secret ? secret : "••••••••"}
</span> </span>
<button onClick={() => setShowKey((v) => !v)} <button onClick={() => { if (showKey) setShowKey(false); else void reveal(); }}
className="text-[10px] text-gray-400 hover:text-gray-600 transition-colors shrink-0"> className="text-[10px] text-gray-400 hover:text-gray-600 transition-colors shrink-0">
{showKey ? "隐藏" : "显示"} {showKey ? "隐藏" : "显示"}
</button> </button>
<CopyBtn value={item.api_key} /> <button onClick={() => void copy()}
className="shrink-0 px-1.5 py-0.5 rounded text-[11px] border border-gray-200 text-gray-400 hover:text-blue-500">复制</button>
</div> </div>
{error && <p role="alert" className="text-xs text-red-600">凭证读取失败:{error}</p>}
{/* Base URL */} {/* Base URL */}
{item.base_url && ( {item.base_url && (
@ -229,7 +255,9 @@ function ApiKeyCard({ item, onEdit, onDelete }: {
export function ApiKeysPanel() { export function ApiKeysPanel() {
const qc = useQueryClient(); const qc = useQueryClient();
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<ApiKey | null>(null); const [editingItem, setEditingItem] = useState<(ApiKey & { api_key: string }) | null>(null);
const [editError, setEditError] = useState<string>();
const [keyRevision, setKeyRevision] = useState(0);
const { data: keys = [] } = useQuery({ const { data: keys = [] } = useQuery({
queryKey: ["api-keys"], queryKey: ["api-keys"],
@ -243,7 +271,7 @@ export function ApiKeysPanel() {
const editMut = useMutation({ const editMut = useMutation({
mutationFn: ({ id, input }: { id: string; input: SaveApiKeyInput }) => editApiKey(id, input), mutationFn: ({ id, input }: { id: string; input: SaveApiKeyInput }) => editApiKey(id, input),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["api-keys"] }); setEditingItem(null); }, onSuccess: () => { setKeyRevision((revision) => revision + 1); qc.invalidateQueries({ queryKey: ["api-keys"] }); setEditingItem(null); },
}); });
const deleteMut = useMutation({ const deleteMut = useMutation({
@ -269,6 +297,7 @@ export function ApiKeysPanel() {
</div> </div>
{/* 列表 */} {/* 列表 */}
{editError && <p role="alert" className="text-xs text-red-600">无法编辑凭证:{editError}</p>}
{keys.length === 0 && ( {keys.length === 0 && (
<div className="text-center py-24 text-gray-400"> <div className="text-center py-24 text-gray-400">
<p className="text-4xl mb-4">🔑</p> <p className="text-4xl mb-4">🔑</p>
@ -279,9 +308,16 @@ export function ApiKeysPanel() {
{keys.map((k) => ( {keys.map((k) => (
<ApiKeyCard <ApiKeyCard
key={k.id} key={`${k.id}:${keyRevision}`}
item={k} item={k}
onEdit={() => setEditingItem(k)} onEdit={() => { void (async () => {
try {
setEditingItem({ ...k, api_key: await revealApiKey(k.id) });
setEditError(undefined);
} catch (cause) {
setEditError(String(cause));
}
})(); }}
onDelete={() => deleteMut.mutate(k.id)} onDelete={() => deleteMut.mutate(k.id)}
/> />
))} ))}

View File

@ -1374,7 +1374,6 @@ export interface ApiKey {
id: string; id: string;
name: string; name: string;
provider: string; provider: string;
api_key: string;
base_url?: string; base_url?: string;
model?: string; model?: string;
notes?: string; notes?: string;
@ -1403,6 +1402,9 @@ export const editApiKey = (id: string, input: SaveApiKeyInput) =>
export const deleteApiKey = (id: string) => export const deleteApiKey = (id: string) =>
invoke<void>("delete_api_key", { id }); invoke<void>("delete_api_key", { id });
export const revealApiKey = (id: string) =>
invoke<string>("reveal_api_key", { id });
// ── SSH Config & VS Code Remote ────────────────────────────────────────────── // ── SSH Config & VS Code Remote ──────────────────────────────────────────────
export interface SshConfigStatus { export interface SshConfigStatus {
@ -1745,7 +1747,6 @@ export interface GiteaInstance {
id: string; id: string;
name: string; name: string;
url: string; url: string;
access_token: string;
is_default: boolean; is_default: boolean;
created_at?: string; created_at?: string;
} }
@ -1801,6 +1802,20 @@ export const giteaUpdateInstance = (
export const giteaTestConnection = (instanceId: string) => export const giteaTestConnection = (instanceId: string) =>
invoke<GiteaUser>("gitea_test_connection", { instanceId }); invoke<GiteaUser>("gitea_test_connection", { instanceId });
export interface ResourceReadiness {
state: "configured" | "pending_migration" | "not_configured" | "unavailable" | "needs_repair";
source: "protected_store" | "legacy_pending_migration" | "gitea_binding";
}
export interface ProjectResourceStatus {
project_id: string;
gitea_pr: ResourceReadiness;
gitea_webhook: ResourceReadiness;
}
export const getProjectResourceStatus = (projectId: string) =>
invoke<ProjectResourceStatus>("get_project_resource_status", { projectId });
export const giteaListLinkedRepos = () => export const giteaListLinkedRepos = () =>
invoke<GiteaLinkedRepo[]>("gitea_list_linked_repos"); invoke<GiteaLinkedRepo[]>("gitea_list_linked_repos");
@ -1812,7 +1827,6 @@ export interface GiteaRepoLink {
gitea_owner: string; gitea_owner: string;
gitea_repo_name: string; gitea_repo_name: string;
clone_url?: string; clone_url?: string;
webhook_secret?: string;
webhook_id?: number; webhook_id?: number;
linked_at?: string; linked_at?: string;
} }