test(resource/broker): 添加隔离客户端试点宿主
All checks were successful
Push & PR Check / check (push) Successful in 4m12s

提供仅测试可用的 scoped MCP 临时宿主和合成凭证/Gitea fixture;保留 R11 进行中状态,等待真实客户端会话终验。

Rules-Applied: R02
This commit is contained in:
lanrtop 2026-09-26 09:30:59 +09:00
parent feb030431e
commit 7ae547e1f6
3 changed files with 175 additions and 6 deletions

View File

@ -492,10 +492,10 @@ modules:
- id: project-resource-broker - id: project-resource-broker
name: 项目资源与凭证代办 name: 项目资源与凭证代办
area: infra area: infra
status: done status: in_progress
progress: 100 progress: 90
updated: 2026-09-24 updated: 2026-09-26
note: "R1–R10 的合成路径已验证,含 scoped MCP 到模拟 Gitea 的 PR 全链路;不接管真实生产密钥或部署。" note: "R1–R10 的合成路径已验证;R11 隔离客户端试点进行中,不启动第二个日常 Tauri 实例或接管真实生产密钥。"
position: [3000, 950] position: [3000, 950]
edges: edges:

View File

@ -1,8 +1,8 @@
--- ---
id: project-resource-broker id: project-resource-broker
name: 项目资源与凭证代办 name: 项目资源与凭证代办
status: done status: in_progress
progress: 100 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 端到端回归 ### ✅ R10 — scoped MCP 合成 PR 端到端回归
- status: done - status: done
- complexity: M - complexity: M

View File

@ -618,6 +618,165 @@ mod tests {
.is_none()); .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] #[tokio::test]
async fn readiness_probe_does_not_satisfy_client_reload_evidence() { async fn readiness_probe_does_not_satisfy_client_reload_evidence() {
let project_id = "reload-evidence-fixture"; let project_id = "reload-evidence-fixture";