From 7ae547e1f661372a5ea7ea95e44e9c1a85570220 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 26 Sep 2026 09:30:59 +0900 Subject: [PATCH] =?UTF-8?q?test(resource/broker):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E5=AE=A2=E6=88=B7=E7=AB=AF=E8=AF=95=E7=82=B9?= =?UTF-8?q?=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";