Compare commits
No commits in common. "feb030431ef9ae3939a640e3fef88c88c88f3a2e" and "af0adef4f221af1a3cecdf01386c78f69e19b2ef" have entirely different histories.
feb030431e
...
af0adef4f2
@ -494,8 +494,8 @@ modules:
|
|||||||
area: infra
|
area: infra
|
||||||
status: done
|
status: done
|
||||||
progress: 100
|
progress: 100
|
||||||
updated: 2026-09-24
|
updated: 2026-09-23
|
||||||
note: "R1–R10 的合成路径已验证,含 scoped MCP 到模拟 Gitea 的 PR 全链路;不接管真实生产密钥或部署。"
|
note: "R1–R8 按已确认设计逐卡实施;仅受保护本机凭证与有界 Gitea 代办,不接管生产部署凭证。"
|
||||||
position: [3000, 950]
|
position: [3000, 950]
|
||||||
|
|
||||||
edges:
|
edges:
|
||||||
|
|||||||
@ -68,14 +68,6 @@ progress: 100
|
|||||||
|
|
||||||
## 任务拆解
|
## 任务拆解
|
||||||
|
|
||||||
### ✅ R10 — scoped MCP 合成 PR 端到端回归
|
|
||||||
- status: done
|
|
||||||
- complexity: M
|
|
||||||
- files: src-tauri/src/mcp_server.rs
|
|
||||||
- depends: R4, R7, R8
|
|
||||||
- acceptance: "agent_verify: [cargo test] scoped_mcp_create_pull_request_uses_synthetic_credential → 真实 127.0.0.1 MCP initialize/tools/list/tools/call 经项目作用域到模拟 Gitea;请求使用合成受保护凭据,结果仅含 PR 标识、不含 token,跨项目参数拒绝;测试后合成凭据引用被清理。[CLI] cargo test、git diff --check 通过。human_verify: 无(仅合成测试,不接触真实仓库)。"
|
|
||||||
- retrospective: "【复盘】scoped MCP 合成 PR 端到端回归|任务类型: MCP工具|复杂度: M|实际轮数: 2轮(预估 2 轮)|是否返工: 是|返工原因: 初版模拟服务无接入超时且清理守卫建立偏晚|有效策略: 内存数据库 + 随机 Windows 凭据引用 + 127.0.0.1 mock,复用真实 MCP 路由并在 Drop 中清理|遗留风险: 该串联测试仅在 Windows 执行;真实 Gitea 试点仍需单独授权|auto_applied_rules: R13|rule_effectiveness: R13=有效"
|
|
||||||
|
|
||||||
### ✅ R9 — PR 审查修复:缓存失效、凭证删除与 readiness
|
### ✅ R9 — PR 审查修复:缓存失效、凭证删除与 readiness
|
||||||
- status: done
|
- status: done
|
||||||
- complexity: M
|
- complexity: M
|
||||||
@ -148,7 +140,6 @@ progress: 100
|
|||||||
- R3:Windows Credential Manager 随机测试引用跨进程读取并清理;模拟存储的写、读、覆盖、删除与失败注入通过。
|
- R3:Windows Credential Manager 随机测试引用跨进程读取并清理;模拟存储的写、读、覆盖、删除与失败注入通过。
|
||||||
- R4–R6:旧值迁移、存储/数据库失败保留旧值、SQLite 迁移前备份及恢复演练、新建/更新/删除回归通过;模拟 Gitea PR 与 webhook 注册均只访问 `127.0.0.1`。
|
- R4–R6:旧值迁移、存储/数据库失败保留旧值、SQLite 迁移前备份及恢复演练、新建/更新/删除回归通过;模拟 Gitea PR 与 webhook 注册均只访问 `127.0.0.1`。
|
||||||
- R7–R8:内存 Enterprise fixture 经真实 loopback MCP HTTP/SSE 完成 `initialize → tools/list → tools/call`,readiness 不含凭证,跨项目参数被拒绝。
|
- R7–R8:内存 Enterprise fixture 经真实 loopback MCP HTTP/SSE 完成 `initialize → tools/list → tools/call`,readiness 不含凭证,跨项目参数被拒绝。
|
||||||
- R10:Windows 合成项目经真实 scoped MCP `initialize → tools/list → tools/call(create_pull_request)` 到 `127.0.0.1` 模拟 Gitea;代办内部使用受保护存储的合成 token,Agent 结果仅含 PR 信息,跨项目参数被拒绝,测试后凭据引用清理;全量 Rust 测试通过(211 passed、1 ignored)。
|
|
||||||
- 未验证:真实用户 DB/凭证迁移、真实 Gitea/CI/服务器、正式安装版 UI 人工终验和真实 PR/部署;现有备份若不含当前旧值会拒绝迁移并要求人工安排新备份,不会降级明文。
|
- 未验证:真实用户 DB/凭证迁移、真实 Gitea/CI/服务器、正式安装版 UI 人工终验和真实 PR/部署;现有备份若不含当前旧值会拒绝迁移并要求人工安排新备份,不会降级明文。
|
||||||
|
|
||||||
## 阶段退出标准
|
## 阶段退出标准
|
||||||
|
|||||||
@ -445,179 +445,6 @@ mod tests {
|
|||||||
server.abort();
|
server.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
|
||||||
async fn scoped_mcp_create_pull_request_uses_synthetic_credential() {
|
|
||||||
use std::io::{BufRead, BufReader, Read, Write};
|
|
||||||
|
|
||||||
struct SyntheticInstanceGuard(Option<String>);
|
|
||||||
impl Drop for SyntheticInstanceGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
if let Some(instance_id) = self.0.take() {
|
|
||||||
let _ = crate::commands::gitea::gitea_delete_instance(instance_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
db::init_test_pool();
|
|
||||||
let project_id = format!("synthetic-broker-{}", uuid::Uuid::new_v4());
|
|
||||||
let profile_id = format!("{project_id}-profile");
|
|
||||||
let token = "qa-only-synthetic-mcp-token";
|
|
||||||
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: "synthetic MCP PR".into(),
|
|
||||||
url: format!("http://{remote_address}"),
|
|
||||||
access_token: token.into(),
|
|
||||||
is_default: Some(false),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let mut instance_guard = SyntheticInstanceGuard(Some(instance.id.clone()));
|
|
||||||
let reference = {
|
|
||||||
let conn = db::pool().get().unwrap();
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT access_token_ref FROM gitea_instances WHERE id = ?1",
|
|
||||||
[&instance.id],
|
|
||||||
|row| row.get::<_, String>(0),
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
};
|
|
||||||
{
|
|
||||||
let conn = db::pool().get().unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO project_profiles (id, name) VALUES (?1, 'Synthetic MCP PR')",
|
|
||||||
[&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 = format!("http://127.0.0.1:{port}/mcp/projects/{project_id}");
|
|
||||||
let call = |method: &str, params: Value| -> Value {
|
|
||||||
ureq::post(&url)
|
|
||||||
.send_json(json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}))
|
|
||||||
.unwrap()
|
|
||||||
.into_json()
|
|
||||||
.unwrap()
|
|
||||||
};
|
|
||||||
|
|
||||||
let init = call("initialize", json!({}));
|
|
||||||
assert_eq!(init["result"]["lianJing"]["project_id"], project_id);
|
|
||||||
let catalog = call("tools/list", json!({}));
|
|
||||||
assert!(catalog["result"]["tools"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.any(|tool| tool["name"] == "create_pull_request"));
|
|
||||||
let readiness = call(
|
|
||||||
"tools/call",
|
|
||||||
json!({"name":"get_project_resource_status","arguments":{}}),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
readiness["result"]["structuredContent"]["gitea_pr"]["state"],
|
|
||||||
"configured"
|
|
||||||
);
|
|
||||||
assert!(!readiness.to_string().contains(token));
|
|
||||||
let denied = call(
|
|
||||||
"tools/call",
|
|
||||||
json!({"name":"create_pull_request","arguments":{"project_id":"other-project","head":"feature","base":"main","title":"synthetic"}}),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
denied["result"]["structuredContent"]["code"],
|
|
||||||
"scope_violation"
|
|
||||||
);
|
|
||||||
|
|
||||||
remote.set_nonblocking(true).unwrap();
|
|
||||||
let mock = std::thread::spawn(move || {
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
||||||
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(10));
|
|
||||||
}
|
|
||||||
Err(error) => panic!("模拟 Gitea 未收到请求: {error}"),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
stream
|
|
||||||
.set_read_timeout(Some(std::time::Duration::from_secs(3)))
|
|
||||||
.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("authorization: token qa-only-synthetic-mcp-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 未收到受保护存储中的合成 token");
|
|
||||||
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"], "feature");
|
|
||||||
let response =
|
|
||||||
r#"{"number":42,"html_url":"https://example.invalid/pr/42","title":"synthetic"}"#;
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
let created = call(
|
|
||||||
"tools/call",
|
|
||||||
json!({"name":"create_pull_request","arguments":{"head":"feature","base":"main","title":"synthetic"}}),
|
|
||||||
);
|
|
||||||
mock.join().unwrap();
|
|
||||||
assert_ne!(created["result"]["isError"], true);
|
|
||||||
let result = created["result"]["content"][0]["text"].as_str().unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_str::<Value>(result).unwrap()["pr_number"],
|
|
||||||
42
|
|
||||||
);
|
|
||||||
assert!(!created.to_string().contains(token));
|
|
||||||
assert!(!created.to_string().contains("access_token"));
|
|
||||||
server.abort();
|
|
||||||
|
|
||||||
crate::commands::gitea::gitea_delete_instance(instance.id).unwrap();
|
|
||||||
instance_guard.0 = None;
|
|
||||||
assert!(crate::credential_store::CredentialStore::get(
|
|
||||||
&crate::credential_store::WindowsCredentialStore,
|
|
||||||
&reference,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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