feat(dev-variables): 项目开发变量与测试账户补给(V1–V11) #23
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user