test(resource/broker): 添加隔离客户端试点宿主
All checks were successful
Push & PR Check / check (push) Successful in 4m12s
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:
parent
feb030431e
commit
7ae547e1f6
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user