From 7ae547e1f661372a5ea7ea95e44e9c1a85570220 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 26 Sep 2026 09:30:59 +0900 Subject: [PATCH 01/14] =?UTF-8?q?test(resource/broker):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E9=9A=94=E7=A6=BB=E5=AE=A2=E6=88=B7=E7=AB=AF=E8=AF=95?= =?UTF-8?q?=E7=82=B9=E5=AE=BF=E4=B8=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提供仅测试可用的 scoped MCP 临时宿主和合成凭证/Gitea fixture;保留 R11 进行中状态,等待真实客户端会话终验。 Rules-Applied: R02 --- .blueprint/manifest.yaml | 8 +- .blueprint/modules/project-resource-broker.md | 14 +- src-tauri/src/mcp_server.rs | 159 ++++++++++++++++++ 3 files changed, 175 insertions(+), 6 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 8890c4a..a623548 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -492,10 +492,10 @@ modules: - id: project-resource-broker name: 项目资源与凭证代办 area: infra - status: done - progress: 100 - updated: 2026-09-24 - note: "R1–R10 的合成路径已验证,含 scoped MCP 到模拟 Gitea 的 PR 全链路;不接管真实生产密钥或部署。" + status: in_progress + progress: 90 + updated: 2026-09-26 + note: "R1–R10 的合成路径已验证;R11 隔离客户端试点进行中,不启动第二个日常 Tauri 实例或接管真实生产密钥。" position: [3000, 950] edges: diff --git a/.blueprint/modules/project-resource-broker.md b/.blueprint/modules/project-resource-broker.md index 9a20fbb..90a1622 100644 --- a/.blueprint/modules/project-resource-broker.md +++ b/.blueprint/modules/project-resource-broker.md @@ -1,8 +1,8 @@ --- id: project-resource-broker name: 项目资源与凭证代办 -status: done -progress: 100 +status: in_progress +progress: 90 --- ## 概述 @@ -68,6 +68,16 @@ progress: 100 ## 任务拆解 +### 🔵 R11 — 隔离真实客户端试点宿主 +- status: in_progress +- 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: pending(需实际客户端连接临时测试宿主;普通 HTTP 回归不能代替) +- verification: "2026-09-26:ignored Rust fixture 与独立协议进程三轮验收,完成 initialize/tools/list/readiness、跨项目拒绝与模拟 PR,返回编号 43 且不含 token;测试正常退出断言 Windows 合成凭证与临时项目目录清理。Codex CLI 以一次性 `-c` 覆盖识别临时 scoped URL;直接 `-C` 临时目录时因项目尚未受信任仍读取既有配置。完整 cargo test 211 passed、2 ignored;typecheck、前端 6 tests、git diff --check 通过。" +- unverified: "Codex/Claude 模型会话实际工具调用与会话无明文终验;强制终止测试进程时 Drop 不保证执行,可能留下无生产权限的随机合成 Windows 凭证;不触碰真实用户数据库或凭证。" + ### ✅ R10 — scoped MCP 合成 PR 端到端回归 - status: done - complexity: M diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index bfacacf..8e54914 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -618,6 +618,165 @@ 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()); + } + #[tokio::test] async fn readiness_probe_does_not_satisfy_client_reload_evidence() { let project_id = "reload-evidence-fixture"; From c2b4c30f5072500736c9aa29664cfbab6194268f Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 26 Sep 2026 09:44:29 +0900 Subject: [PATCH 02/14] =?UTF-8?q?docs(resource/broker):=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=20R11=20=E5=AE=A2=E6=88=B7=E7=AB=AF=E8=AF=95=E7=82=B9?= =?UTF-8?q?=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真实 Codex CLI 经临时 scoped MCP 完成非秘密 readiness、跨项目拒绝与模拟 PR;明确审批门槛及未验证边界。 Rules-Applied: R02,R03 --- .blueprint/manifest.yaml | 6 +++--- .blueprint/modules/project-resource-broker.md | 15 ++++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index a623548..5a278d1 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -492,10 +492,10 @@ modules: - id: project-resource-broker name: 项目资源与凭证代办 area: infra - status: in_progress - progress: 90 + status: done + progress: 100 updated: 2026-09-26 - note: "R1–R10 的合成路径已验证;R11 隔离客户端试点进行中,不启动第二个日常 Tauri 实例或接管真实生产密钥。" + note: "R1–R11 的合成路径和真实 Codex CLI 客户端试点已验证;真实用户凭证迁移、正式版 UI 与生产链路未验证,未启动第二个日常 Tauri 实例。" position: [3000, 950] edges: diff --git a/.blueprint/modules/project-resource-broker.md b/.blueprint/modules/project-resource-broker.md index 90a1622..7920f91 100644 --- a/.blueprint/modules/project-resource-broker.md +++ b/.blueprint/modules/project-resource-broker.md @@ -1,8 +1,8 @@ --- id: project-resource-broker name: 项目资源与凭证代办 -status: in_progress -progress: 90 +status: done +progress: 100 --- ## 概述 @@ -68,15 +68,16 @@ progress: 90 ## 任务拆解 -### 🔵 R11 — 隔离真实客户端试点宿主 -- status: in_progress +### ✅ 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: pending(需实际客户端连接临时测试宿主;普通 HTTP 回归不能代替) -- verification: "2026-09-26:ignored Rust fixture 与独立协议进程三轮验收,完成 initialize/tools/list/readiness、跨项目拒绝与模拟 PR,返回编号 43 且不含 token;测试正常退出断言 Windows 合成凭证与临时项目目录清理。Codex CLI 以一次性 `-c` 覆盖识别临时 scoped URL;直接 `-C` 临时目录时因项目尚未受信任仍读取既有配置。完整 cargo test 211 passed、2 ignored;typecheck、前端 6 tests、git diff --check 通过。" -- unverified: "Codex/Claude 模型会话实际工具调用与会话无明文终验;强制终止测试进程时 Drop 不保证执行,可能留下无生产权限的随机合成 Windows 凭证;不触碰真实用户数据库或凭证。" +- 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 From 2b65c1819325633e2e610c614b8d65689547a8aa Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 26 Sep 2026 16:46:55 +0900 Subject: [PATCH 03/14] feat(resource/dev-variables): add protected project variable core and scoped MCP Keep values in Windows Credential Manager, require a project bearer for single-value reads, and preserve existing non-secret MCP behavior. Feature-Confirmed: true Rules-Applied: R01,R03,R05,R06,R07,R10,R12,R13 --- src-tauri/src/commands/dev_variables.rs | 498 ++++++++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/projects.rs | 6 + src-tauri/src/credential_store.rs | 217 ++++++++++- src-tauri/src/db.rs | 31 +- src-tauri/src/lib.rs | 14 + src-tauri/src/main.rs | 17 + src-tauri/src/mcp_inject.rs | 66 +++- src-tauri/src/mcp_server.rs | 332 +++++++++++++++- src-tauri/src/mcp_tools.rs | 66 ++++ 10 files changed, 1222 insertions(+), 26 deletions(-) create mode 100644 src-tauri/src/commands/dev_variables.rs diff --git a/src-tauri/src/commands/dev_variables.rs b/src-tauri/src/commands/dev_variables.rs new file mode 100644 index 0000000..1353d3d --- /dev/null +++ b/src-tauri/src/commands/dev_variables.rs @@ -0,0 +1,498 @@ +//! 项目级开发变量本体。SQLite 只保存元数据和随机引用;值仅驻留受保护存储。 +use rusqlite::{params, Connection}; +use serde::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 configured: bool, + pub updated_at: String, +} + +fn normalized_name(name: &str) -> Result { + 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, 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()) +} + +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 = purpose.trim(); + if purpose.is_empty() || purpose.chars().count() > 240 || purpose.chars().any(char::is_control) + { + return Err("用途说明须为 1–240 字且不含控制字符".into()); + } + 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())?; + Ok(()) +} + +pub fn list( + conn: &Connection, + store: &impl CredentialStore, + project_id: &str, +) -> Result, String> { + require_project(conn, project_id)?; + let mut statement = conn + .prepare( + "SELECT name,purpose,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::<_, Option>(2)?, + row.get::<_, String>(3)?, + )) + }) + .map_err(|_| "查询开发变量失败".to_string())?; + let mut statuses = Vec::new(); + for row in rows { + let (name, purpose, reference, updated_at) = + row.map_err(|_| "查询开发变量失败".to_string())?; + let configured = match reference { + Some(reference) => { + store + .get(&reference) + .map_err(|_| "开发变量状态 needs_repair:受保护存储不可用".to_string())? + .ok_or_else(|| "开发变量状态 needs_repair:凭证引用缺失".to_string())? + .len() + > 0 + } + None => false, + }; + statuses.push(DevVariableStatus { + project_id: project_id.to_string(), + name, + purpose, + configured, + updated_at, + }); + } + Ok(statuses) +} + +pub fn read( + conn: &Connection, + store: &impl CredentialStore, + project_id: &str, + name: &str, +) -> Result { + require_project(conn, project_id)?; + let name = normalized_name(name)?; + 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> { + require_project(conn, project_id)?; + let name = normalized_name(name)?; + 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 = 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 = 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 = statement + .query_map([project_id], |row| row.get(0)) + .map_err(|_| "清理项目开发变量失败".to_string())? + .collect::>() + .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, 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 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 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(); + } + + #[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(); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1810507..cdf11b5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index 99ce7c6..8764da2 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -325,6 +325,12 @@ pub fn delete_project(id: String, delete_local: Option) -> 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); diff --git a/src-tauri/src/credential_store.rs b/src-tauri/src/credential_store.rs index a3963e4..a46e428 100644 --- a/src-tauri/src/credential_store.rs +++ b/src-tauri/src/credential_store.rs @@ -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 { + 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 { + 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) -> Result { + 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 { + 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 { + 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() { diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 1a16e16..a8e7927 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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,18 @@ 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, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (project_id, name) + );", + ) + .expect("migrate project dev variables"); } #[cfg(test)] @@ -1144,6 +1156,23 @@ 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 = 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 sync_state_touch_then_read() { let conn = conn_with_schema(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 64b7750..38e764d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { + 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,10 @@ 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::revoke_project_dev_variable, // host apps commands::host_apps::get_host_apps, commands::host_apps::add_host_app, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 47cb2f8..5f7e72c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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() } diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index ca37f0f..433563b 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -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); diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 8e54914..5c888c8 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -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,6 +30,7 @@ struct SessionEntry { type Sessions = Arc>>; const MCP_LOOPBACK_HOST: &str = "127.0.0.1"; +const DEV_VARIABLE_READ_TOOL: &str = "read_project_dev_variable"; const MCP_SERVER_INSTRUCTIONS: &str = "先读取 get_diagnostics、verify_onboarding 或项目状态,再执行后续操作。项目级工具必须使用当前项目的 canonical project_id,不得跨项目猜测或改写身份。写操作仅在用户明确确认目标与影响后调用;状态 unavailable/unknown 不得当作成功。"; static INITIALIZED_PROJECT_CLIENTS: OnceLock>> = OnceLock::new(); @@ -195,6 +196,60 @@ 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> { + if req.method != "tools/call" + || req + .params + .as_ref() + .and_then(|params| params.get("name")) + .and_then(Value::as_str) + != Some(DEV_VARIABLE_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 { @@ -234,7 +289,17 @@ async fn process_rpc(req: &RpcRequest, project_scope: Option<&str>) -> Result) -> Response { +async fn handle_mcp(headers: HeaderMap, Json(req): Json) -> 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 +316,19 @@ async fn handle_mcp(Json(req): Json) -> Response { async fn handle_scoped_mcp( Path(project_id): Path, + headers: HeaderMap, Json(req): Json, ) -> 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 +394,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 +405,51 @@ 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 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 +567,189 @@ 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 _ = 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::() + .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 + .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::() + .unwrap(); + assert!(!global_catalog + .to_string() + .contains("read_project_dev_variable")); + let register = ureq::post(&p1_url) + .send_json(call_body( + "register_project_dev_variable", + json!({"name":"E2E_PASSWORD","purpose":"合成验收"}), + )) + .unwrap() + .into_json::() + .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::() + .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::() + .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::() + .unwrap(); + assert_eq!(after_revoke["result"]["isError"], true); + assert!(!after_revoke.to_string().contains("qa-only-http-value")); + server.abort(); + } + #[cfg(windows)] #[tokio::test(flavor = "multi_thread")] async fn scoped_mcp_create_pull_request_uses_synthetic_credential() { @@ -818,8 +1123,20 @@ mod tests { async fn handle_sse_message( State(sessions): State, Query(query): Query, + headers: HeaderMap, Json(req): Json, ) -> 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(); } @@ -857,8 +1174,19 @@ async fn handle_scoped_sse_message( Path(project_id): Path, State(sessions): State, Query(query): Query, + headers: HeaderMap, Json(req): Json, ) -> 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(); } diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 7ad38ee..fe54e1e 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -412,6 +412,33 @@ 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": "list_project_dev_variables", + "description": "查询本项目开发变量的待填写/已配置状态,不返回值或凭证引用。", + "inputSchema": {"type":"object","properties":{"project_id":{"type":"string"}},"required":[]}, + "annotations":{"readOnlyHint":true} + }), + json!({ + "name": "read_project_dev_variable", + "description": "经本机认证后单项读取本项目已由用户填写的开发变量。返回值会进入 Agent 会话;仅用于非生产、低权限、可重置的开发数据。", + "inputSchema": {"type":"object","properties":{ + "project_id":{"type":"string"}, + "name":{"type":"string"} + },"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 +627,16 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option, project_scope: Option { + 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()) + } + "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, + ) + } "get_diagnostics" => match scope_for_call.as_deref() { Some(project_id) => get_scoped_diagnostics(project_id), None => get_diagnostics(), From b1c6eaebac30d91022b93cb9144cd093fe2672c6 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 26 Sep 2026 16:47:24 +0900 Subject: [PATCH 04/14] feat(resource/dev-variables): add per-project human filling panel Expose status-only Tauri types and explicit save/revoke actions without caching entered values in React Query. Feature-Confirmed: true Rules-Applied: R01,R12,R13 --- src/components/dashboard/ProjectCard.tsx | 11 ++ .../dashboard/ProjectDevVariablesPanel.tsx | 119 ++++++++++++++++++ src/lib/commands.ts | 19 +++ 3 files changed, 149 insertions(+) create mode 100644 src/components/dashboard/ProjectDevVariablesPanel.tsx diff --git a/src/components/dashboard/ProjectCard.tsx b/src/components/dashboard/ProjectCard.tsx index 9d29d6d..3636503 100644 --- a/src/components/dashboard/ProjectCard.tsx +++ b/src/components/dashboard/ProjectCard.tsx @@ -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) {
{([ { 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 && ( + setShowDevVariables(false)} + /> + )} + {/* 个人备忘 */} {showTodos && } diff --git a/src/components/dashboard/ProjectDevVariablesPanel.tsx b/src/components/dashboard/ProjectDevVariablesPanel.tsx new file mode 100644 index 0000000..5056397 --- /dev/null +++ b/src/components/dashboard/ProjectDevVariablesPanel.tsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + listProjectDevVariables, + saveProjectDevVariable, + revokeProjectDevVariable, +} from "../../lib/commands"; +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(null); + const [draftValue, setDraftValue] = useState(""); + const [saving, setSaving] = useState(false); + const { data: variables = [], isPending, error } = useQuery({ + queryKey: ["projectDevVariables", projectId], + queryFn: () => listProjectDevVariables(projectId), + retry: false, + }); + const handleSave = async (name: string) => { + if (!draftValue || saving) return; + setSaving(true); + try { + await saveProjectDevVariable(projectId, name, draftValue); + setDraftValue(""); + setEditingName(null); + queryClient.invalidateQueries({ queryKey: ["projectDevVariables", projectId] }); + showToast("开发变量已保存"); + } 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 ( + +
+

+ 仅填写非生产、低权限、可重置的开发值。已配置的值可被此项目的已认证 Agent 单项读取, + 不会写入子项目文件;请勿存放生产密码。 +

+ {isPending &&

正在读取需求…

} + {error &&

读取失败:{String(error)}

} + {!isPending && !error && variables.length === 0 && ( +

暂无变量需求。Agent 登记需要的名称和用途后,会显示在这里等待填写。

+ )} + {variables.map((item) => ( +
+
+ {item.name} + + {item.configured ? "已配置" : "待填写"} + +
+

{item.purpose}

+ {editingName === item.name ? ( +
{ + event.preventDefault(); + void handleSave(item.name); + }} + className="flex flex-wrap gap-2" + > + setDraftValue(event.target.value)} + placeholder="填写开发值" + className="flex-1 min-w-44 border border-gray-300 rounded px-2 py-1.5 text-sm" + /> + + +
+ ) : ( +
+ + {item.configured && ( + + )} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 9aeb4ba..f2a1e8e 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1405,6 +1405,25 @@ export const deleteApiKey = (id: string) => export const revealApiKey = (id: string) => invoke("reveal_api_key", { id }); +// ── Project development variables ────────────────────────────────────────── + +export interface ProjectDevVariableStatus { + project_id: string; + name: string; + purpose: string; + configured: boolean; + updated_at: string; +} + +export const listProjectDevVariables = (projectId: string) => + invoke("list_project_dev_variables", { projectId }); + +export const saveProjectDevVariable = (projectId: string, name: string, value: string) => + invoke("save_project_dev_variable", { projectId, name, value }); + +export const revokeProjectDevVariable = (projectId: string, name: string) => + invoke("revoke_project_dev_variable", { projectId, name }); + // ── SSH Config & VS Code Remote ────────────────────────────────────────────── export interface SshConfigStatus { From fe71536f3388c01e32fb5cabbcb67b9b0785ad76 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 26 Sep 2026 16:48:57 +0900 Subject: [PATCH 05/14] docs(resource/dev-variables): record scoped plaintext exception and validation Document V1-V6 evidence and keep V7 in progress until a real Codex tool-call and human UI pilot complete. Rules-Applied: R03,R05,R10,R13 --- .blueprint/manifest.yaml | 15 ++- .blueprint/modules/project-dev-variables.md | 111 ++++++++++++++++++++ docs/ai-context/agent-button-system.md | 6 +- docs/ai-context/project-brief.md | 1 + docs/ai-context/project-context.yaml | 7 +- 5 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 .blueprint/modules/project-dev-variables.md diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index 5a278d1..bec1ee0 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -6,7 +6,7 @@ description: > 项目自身保留业务规则、测试、部署声明与交付决策。炼境本身亦被纳入其中, 形成可自我进化的开发智能闭环。 iteration: 1 -updated: 2026-09-23 +updated: 2026-09-26 agent_button_carrier: tauri @@ -498,7 +498,20 @@ modules: note: "R1–R11 的合成路径和真实 Codex CLI 客户端试点已验证;真实用户凭证迁移、正式版 UI 与生产链路未验证,未启动第二个日常 Tauri 实例。" position: [3000, 950] + - id: project-dev-variables + name: 项目开发变量补给 + area: infra + status: in_progress + progress: 86 + updated: 2026-09-26 + note: "V1–V6 合成验收通过;V7 的完整真实 Codex 新工具调用与桌面 UI 人眼终验待做。仅非生产开发用途,运行时 env 注入后置。" + position: [3250, 950] + edges: + - from: project-resource-broker + to: project-dev-variables + type: dependency + # 前端依赖 - from: workspace-mgmt to: project-mgmt diff --git a/.blueprint/modules/project-dev-variables.md b/.blueprint/modules/project-dev-variables.md new file mode 100644 index 0000000..3707055 --- /dev/null +++ b/.blueprint/modules/project-dev-variables.md @@ -0,0 +1,111 @@ +--- +id: project-dev-variables +name: 项目开发变量补给 +status: in_progress +progress: 86 +--- + +## 设计决策:人填写、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: in_progress +- complexity: M +- files: docs/ai-context/project-context.yaml, docs/ai-context/agent-button-system.md, .blueprint/modules/project-dev-variables.md +- depends: V6 +- acceptance: "agent_verify: [真实 Codex CLI + 合成数据] 跨会话登记→填写→读取→撤销后再读失败,项目目录无新 .env 或 token;[CLI] cargo test、pnpm typecheck、git diff --check 均 exit 0。human_verify: 不使用真实生产账号、不触发真实 PR/部署/数据库写入,确认日常开发摩擦是否下降。" +- pending: 真实 Codex CLI 调新工具完成整条业务链、正式打包版、桌面 UI 人眼便利性终验;V1 的真实 Codex 认证初始化与 V6 的真实 scoped HTTP 业务链已分别验证,不把组合证据冒充本卡端到端完成。 diff --git a/docs/ai-context/agent-button-system.md b/docs/ai-context/agent-button-system.md index 76e04e0..23ae48f 100644 --- a/docs/ai-context/agent-button-system.md +++ b/docs/ai-context/agent-button-system.md @@ -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` 在服务端验证该项目的 bearer,跨项目、全局 `/mcp`、无认证及错误认证均拒绝。同一 Windows 用户的恶意进程仍可能读取该用户可访问的凭证,本机制不声称提供同用户进程隔离;其他 Agent 客户端未验证前不承诺同等取值能力。 + ## 项目资源与凭证代办边界 +项目开发变量由 Agent 在 scoped MCP 中登记名称和用途,人通过对应项目 UI 明确填写、修改或撤销。SQLite 只保存非秘密元数据与随机凭证引用,值留在 Windows Credential Manager;常态列表只报告待填写/已配置,不回传值。唯一明文出口是已认证的 `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 用户下的进程隔离不是本方案提供的安全保证。 diff --git a/docs/ai-context/project-brief.md b/docs/ai-context/project-brief.md index a1c00c2..942d8cc 100644 --- a/docs/ai-context/project-brief.md +++ b/docs/ai-context/project-brief.md @@ -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/ 项目蓝图 diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index b5594fc..390e10a 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -6,7 +6,7 @@ contracts: - label: API 接口(Axum) paths: - "src-tauri/src/mcp_server.rs" - doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。旧 /mcp 只作兼容迁移。路由变更需同步 Agent 按钮体系文档。" + doc: "MCP 仅监听 127.0.0.1;新项目使用 /mcp/projects/ scoped route,连接 scope 拒绝跨项目参数和全局管理写工具。旧 /mcp 只作兼容迁移。read_project_dev_variable 是独立例外:仅 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 仅返回人显式填写的项目级开发变量,须匹配 scoped 项目及本机认证;登记和列表不返回值,禁止批量导出。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 可显式提交或撤销值但无常态 reveal。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。" architecture_docs: - "AGENTS.md" From 0b0bc18ce49324863d007662e73a15c3539b3d4b Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 26 Sep 2026 16:57:58 +0900 Subject: [PATCH 06/14] docs(resource/dev-variables): record Codex approval blocker Three isolated real-client attempts were denied before dispatch by approval policy=never. Keep V7 blocked pending an explicitly approved pilot. Rules-Applied: R03,R10,R13 --- .blueprint/manifest.yaml | 2 +- .blueprint/modules/project-dev-variables.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index bec1ee0..97b3f23 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -504,7 +504,7 @@ modules: status: in_progress progress: 86 updated: 2026-09-26 - note: "V1–V6 合成验收通过;V7 的完整真实 Codex 新工具调用与桌面 UI 人眼终验待做。仅非生产开发用途,运行时 env 注入后置。" + note: "V1–V6 合成验收通过;V7 真实 Codex 工具调用被客户端 approval policy=never 拒绝,待用户决定受控审批试点;桌面 UI 人眼终验待做。仅非生产开发用途,运行时 env 注入后置。" position: [3250, 950] edges: diff --git a/.blueprint/modules/project-dev-variables.md b/.blueprint/modules/project-dev-variables.md index 3707055..b78475b 100644 --- a/.blueprint/modules/project-dev-variables.md +++ b/.blueprint/modules/project-dev-variables.md @@ -102,10 +102,11 @@ progress: 86 - 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: in_progress +### 🔴 V7 — 契约、合成试点与改动边界验收 +- status: blocked - complexity: M - files: docs/ai-context/project-context.yaml, docs/ai-context/agent-button-system.md, .blueprint/modules/project-dev-variables.md - depends: V6 - acceptance: "agent_verify: [真实 Codex CLI + 合成数据] 跨会话登记→填写→读取→撤销后再读失败,项目目录无新 .env 或 token;[CLI] cargo test、pnpm typecheck、git diff --check 均 exit 0。human_verify: 不使用真实生产账号、不触发真实 PR/部署/数据库写入,确认日常开发摩擦是否下降。" +- blocked_reason: 真实 Codex CLI 三次隔离合成试点均在调用 `register_project_dev_variable` 时被客户端审批策略拒绝(`MCP tool call requires approval, but approval policy is never`),服务端未收到登记请求;未尝试绕过审批。需用户决定是否允许一次受控、可审批的真实客户端试点。 - pending: 真实 Codex CLI 调新工具完成整条业务链、正式打包版、桌面 UI 人眼便利性终验;V1 的真实 Codex 认证初始化与 V6 的真实 scoped HTTP 业务链已分别验证,不把组合证据冒充本卡端到端完成。 From d559b7917b55bb5b942bd7b709cfe836c496a8f4 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 28 Sep 2026 01:44:56 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat(dev-variables/storage):=20=E5=8E=9F?= =?UTF-8?q?=E5=AD=90=E6=89=98=E7=AE=A1=E9=9D=9E=E7=94=9F=E4=BA=A7=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=B4=A6=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增兼容旧表的账户类型与语义登记,账号密码共用受保护引用;覆盖迁移、失败恢复和重开复用。 Rules-Applied: R05,R06,R07 Feature-Confirmed: true --- src-tauri/src/commands/dev_variables.rs | 432 +++++++++++++++++++++++- src-tauri/src/db.rs | 45 +++ 2 files changed, 463 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/commands/dev_variables.rs b/src-tauri/src/commands/dev_variables.rs index 1353d3d..8640fd3 100644 --- a/src-tauri/src/commands/dev_variables.rs +++ b/src-tauri/src/commands/dev_variables.rs @@ -1,6 +1,6 @@ //! 项目级开发变量本体。SQLite 只保存元数据和随机引用;值仅驻留受保护存储。 use rusqlite::{params, Connection}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::credential_store::{ delete_references, put_verified, restore_references, CredentialStore, @@ -11,10 +11,22 @@ pub struct DevVariableStatus { pub project_id: String, pub name: String, pub purpose: String, + pub kind: String, + pub service: Option, + pub environment: Option, pub configured: bool, pub updated_at: String, } +#[derive(Serialize, Deserialize)] +pub struct TestAccountValue { + pub username: String, + pub password: String, +} + +const SCALAR_KIND: &str = "scalar"; +const TEST_ACCOUNT_KIND: &str = "test_account"; + fn normalized_name(name: &str) -> Result { let name = name.trim().to_ascii_uppercase(); if name.is_empty() @@ -52,6 +64,34 @@ fn reference(conn: &Connection, project_id: &str, name: &str) -> Result 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 { + 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, @@ -60,16 +100,58 @@ pub fn register( ) -> Result<(), String> { require_project(conn, project_id)?; let name = normalized_name(name)?; - let purpose = purpose.trim(); - if purpose.is_empty() || purpose.chars().count() > 240 || purpose.chars().any(char::is_control) - { - return Err("用途说明须为 1–240 字且不含控制字符".into()); - } + 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, Option) = 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(()) } @@ -81,7 +163,7 @@ pub fn list( require_project(conn, project_id)?; let mut statement = conn .prepare( - "SELECT name,purpose,credential_ref,updated_at FROM project_dev_variables + "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())?; @@ -90,23 +172,31 @@ pub fn list( Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, String>(3)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, )) }) .map_err(|_| "查询开发变量失败".to_string())?; let mut statuses = Vec::new(); for row in rows { - let (name, purpose, reference, updated_at) = + let (name, purpose, kind, service, environment, reference, updated_at) = row.map_err(|_| "查询开发变量失败".to_string())?; let configured = match reference { Some(reference) => { - store + let bytes = store .get(&reference) .map_err(|_| "开发变量状态 needs_repair:受保护存储不可用".to_string())? - .ok_or_else(|| "开发变量状态 needs_repair:凭证引用缺失".to_string())? - .len() - > 0 + .ok_or_else(|| "开发变量状态 needs_repair:凭证引用缺失".to_string())?; + if kind == TEST_ACCOUNT_KIND { + serde_json::from_slice::(&bytes) + .map(|value| !value.username.is_empty() && !value.password.is_empty()) + .map_err(|_| "测试账户状态 needs_repair:值格式无效".to_string())? + } else { + !bytes.is_empty() + } } None => false, }; @@ -114,6 +204,9 @@ pub fn list( project_id: project_id.to_string(), name, purpose, + kind, + service, + environment, configured, updated_at, }); @@ -126,9 +219,35 @@ pub fn read( store: &impl CredentialStore, project_id: &str, name: &str, +) -> Result { + 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 { + let value = read_value(conn, store, project_id, name, TEST_ACCOUNT_KIND)?; + let account = serde_json::from_str::(&value) + .map_err(|_| "测试账户状态 needs_repair:值格式无效".to_string())?; + if account.username.is_empty() || account.password.is_empty() { + return Err("测试账户状态 needs_repair:字段缺失".into()); + } + Ok(account) +} + +fn read_value( + conn: &Connection, + store: &impl CredentialStore, + project_id: &str, + name: &str, + expected_kind: &str, ) -> Result { 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 @@ -144,9 +263,41 @@ pub fn save( 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()); } @@ -273,6 +424,26 @@ pub fn save_project_dev_variable( ) } +#[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, + ) +} + #[tauri::command] pub fn revoke_project_dev_variable(project_id: String, name: String) -> Result<(), String> { let conn = crate::db::pool() @@ -336,6 +507,137 @@ mod tests { assert!(read(&conn, &store, "p1", "E2E_PASSWORD").is_err()); } + #[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 = 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(); @@ -455,6 +757,58 @@ mod tests { 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() { @@ -495,4 +849,54 @@ mod tests { 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(); + } } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index a8e7927..5fe41c8 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1064,11 +1064,26 @@ pub(crate) fn migrate(conn: &rusqlite::Connection) { 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)] @@ -1173,6 +1188,36 @@ mod tests { 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, Option) = 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(); From 1daef98af30031238e2ec3e121af222f479e0fbe Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 28 Sep 2026 01:45:23 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat(dev-variables/mcp):=20=E6=8F=90?= =?UTF-8?q?=E4=BE=9B=E9=A1=B9=E7=9B=AE=E7=BA=A7=E6=B5=8B=E8=AF=95=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E7=99=BB=E8=AE=B0=E4=B8=8E=E8=AE=A4=E8=AF=81=E8=AF=BB?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scoped MCP 只登记非秘密元数据;读取共用 bearer 门槛,并以真实本机 HTTP 合成链路覆盖授权、复用和撤销。 Rules-Applied: R07,R10 Feature-Confirmed: true --- src-tauri/src/mcp_server.rs | 156 ++++++++++++++++++++++++++++++++++-- src-tauri/src/mcp_tools.rs | 51 +++++++++++- 2 files changed, 198 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 5c888c8..61f885c 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -31,7 +31,8 @@ type Sessions = Arc>>; const MCP_LOOPBACK_HOST: &str = "127.0.0.1"; const DEV_VARIABLE_READ_TOOL: &str = "read_project_dev_variable"; -const MCP_SERVER_INSTRUCTIONS: &str = "先读取 get_diagnostics、verify_onboarding 或项目状态,再执行后续操作。项目级工具必须使用当前项目的 canonical project_id,不得跨项目猜测或改写身份。写操作仅在用户明确确认目标与影响后调用;状态 unavailable/unknown 不得当作成功。"; +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>> = OnceLock::new(); fn initialized_project_clients() -> &'static RwLock> { @@ -203,13 +204,16 @@ fn authorize_sensitive_request( 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" - || req - .params - .as_ref() - .and_then(|params| params.get("name")) - .and_then(Value::as_str) - != Some(DEV_VARIABLE_READ_TOOL) + || !matches!( + requested_tool, + Some(DEV_VARIABLE_READ_TOOL | TEST_ACCOUNT_READ_TOOL) + ) { return Ok(()); } @@ -428,6 +432,21 @@ mod tests { 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!( @@ -584,6 +603,12 @@ mod tests { 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 _ = @@ -655,6 +680,8 @@ mod tests { .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"))); @@ -666,6 +693,9 @@ mod tests { 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", @@ -747,6 +777,118 @@ mod tests { .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .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::() + .unwrap(); + assert_eq!(account_after_revoke["result"]["isError"], true); + assert!(!account_after_revoke + .to_string() + .contains("qa-only-account-password")); server.abort(); } diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index fe54e1e..90f9ccf 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -423,21 +423,42 @@ pub fn tools_list_result_for_project(project_id: &str) -> Value { },"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": "查询本项目开发变量的待填写/已配置状态,不返回值或凭证引用。", + "description": "测试登录前先查询本项目开发需求状态。返回名称、类型、目标系统、环境、用途和待填写/已配置状态,不返回值或凭证引用;已有需求请复用,勿重复登记。", "inputSchema": {"type":"object","properties":{"project_id":{"type":"string"}},"required":[]}, "annotations":{"readOnlyHint":true} }), json!({ "name": "read_project_dev_variable", - "description": "经本机认证后单项读取本项目已由用户填写的开发变量。返回值会进入 Agent 会话;仅用于非生产、低权限、可重置的开发数据。", + "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 { @@ -631,8 +652,10 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option, project_scope: Option { + 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())?; @@ -675,6 +710,18 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option { + 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(), From d926591ace8847ed332ec343867730b54a9e6b4d Mon Sep 17 00:00:00 2001 From: lanrtop Date: Mon, 28 Sep 2026 01:45:49 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat(dev-variables/ui):=20=E4=B8=80?= =?UTF-8?q?=E5=BC=A0=E5=8D=A1=E5=A1=AB=E5=86=99=E6=B5=8B=E8=AF=95=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=92=8C=E5=AF=86=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 项目面板一次提交两个字段到同一 Tauri 本体,列表与 React Query 仅保留非秘密状态。 Rules-Applied: R01,R08 Feature-Confirmed: true --- src-tauri/src/lib.rs | 1 + .../dashboard/ProjectDevVariablesPanel.tsx | 89 ++++++++++++++----- src/lib/commands.ts | 10 +++ 3 files changed, 76 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 38e764d..4a91480 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -405,6 +405,7 @@ pub fn run() { // 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::revoke_project_dev_variable, // host apps commands::host_apps::get_host_apps, diff --git a/src/components/dashboard/ProjectDevVariablesPanel.tsx b/src/components/dashboard/ProjectDevVariablesPanel.tsx index 5056397..0dacf0d 100644 --- a/src/components/dashboard/ProjectDevVariablesPanel.tsx +++ b/src/components/dashboard/ProjectDevVariablesPanel.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { listProjectDevVariables, saveProjectDevVariable, + saveProjectTestAccount, revokeProjectDevVariable, } from "../../lib/commands"; import { useUIStore } from "../../store/ui"; @@ -19,21 +20,32 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr const showToast = useUIStore((state) => state.showToast); const [editingName, setEditingName] = useState(null); const [draftValue, setDraftValue] = useState(""); + const [draftUsername, setDraftUsername] = useState(""); + const [draftPassword, setDraftPassword] = useState(""); const [saving, setSaving] = useState(false); const { data: variables = [], isPending, error } = useQuery({ queryKey: ["projectDevVariables", projectId], queryFn: () => listProjectDevVariables(projectId), retry: false, }); - const handleSave = async (name: string) => { - if (!draftValue || saving) return; + const clearDraft = () => { + setDraftValue(""); + setDraftUsername(""); + setDraftPassword(""); + }; + const handleSave = async (name: string, kind: "scalar" | "test_account") => { + if (saving || (kind === "test_account" ? !draftUsername || !draftPassword : !draftValue)) return; setSaving(true); try { - await saveProjectDevVariable(projectId, name, draftValue); - setDraftValue(""); + 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("开发变量已保存"); + showToast(kind === "test_account" ? "测试账户已保存" : "开发变量已保存"); } catch (cause) { showToast(`保存失败:${cause}`); } finally { @@ -53,7 +65,7 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr

- 仅填写非生产、低权限、可重置的开发值。已配置的值可被此项目的已认证 Agent 单项读取, + 仅填写非生产、低权限、可重置的开发值或测试账户。已配置的值可被此项目的已认证 Agent 单项读取, 不会写入子项目文件;请勿存放生产密码。

{isPending &&

正在读取需求…

} @@ -69,34 +81,63 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr {item.configured ? "已配置" : "待填写"}
+ {item.kind === "test_account" && ( +

测试账户 · {item.service} · {item.environment}

+ )}

{item.purpose}

{editingName === item.name ? (
{ event.preventDefault(); - void handleSave(item.name); + void handleSave(item.name, item.kind); }} - className="flex flex-wrap gap-2" + className="space-y-2" > - setDraftValue(event.target.value)} - placeholder="填写开发值" - className="flex-1 min-w-44 border border-gray-300 rounded px-2 py-1.5 text-sm" - /> - - + {item.kind === "test_account" ? ( +
+ setDraftUsername(event.target.value)} + placeholder="测试账号" + aria-label={`${item.name} 测试账号`} + className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" + /> + setDraftPassword(event.target.value)} + placeholder="测试密码" + aria-label={`${item.name} 测试密码`} + className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" + /> +
+ ) : ( + setDraftValue(event.target.value)} + placeholder="填写开发值" + className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" + /> + )} +
+ + +
) : (
- {item.configured && ( + ); const handleSave = async (name: string, kind: "scalar" | "test_account") => { if (saving || (kind === "test_account" ? !draftUsername || !draftPassword : !draftValue)) return; setSaving(true); @@ -106,26 +138,32 @@ export function ProjectDevVariablesPanel({ projectId, projectName, onClose }: Pr aria-label={`${item.name} 测试账号`} className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" /> - setDraftPassword(event.target.value)} - placeholder="测试密码" - aria-label={`${item.name} 测试密码`} - className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" - /> +
+ 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} +
) : ( - setDraftValue(event.target.value)} - placeholder="填写开发值" - className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" - /> +
+ setDraftValue(event.target.value)} + placeholder="填写开发值" + className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" + /> + {secretToggle} +
)}
+ {item.configured && (