diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index af54473..c052959 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -502,9 +502,9 @@ modules: name: 项目开发变量补给 area: infra status: in_progress - progress: 91 - updated: 2026-09-28 - note: "V1–V6、V8–V11 合成验收通过;V7 真实 Codex 工具调用仍被客户端 approval policy=never 拒绝。真实桌面 UI 和跨会话便利性待终验;仅非生产开发用途,运行时 env 注入后置。" + progress: 100 + updated: 2026-09-30 + note: "V1–V11 agent_verify 全部通过;V7 于 2026-09-30 以真实 Codex CLI 三会话完成登记→填写→跨会话读取→撤销后读取失败(单次 --approve-for-me,仅登记写调用)。待人终验真实桌面 UI 与日常便利;仅非生产开发用途。运行时落地增量见 docs/tasks/2026-09-30-dev-variables-materialize.md。" position: [3250, 950] edges: diff --git a/.blueprint/modules/project-dev-variables.md b/.blueprint/modules/project-dev-variables.md index 75b3370..2faddbc 100644 --- a/.blueprint/modules/project-dev-variables.md +++ b/.blueprint/modules/project-dev-variables.md @@ -102,14 +102,16 @@ progress: 91 - acceptance: "[真实 scoped MCP] p1 Agent 登记 E2E_PASSWORD → UI 待填写;人保存合成值后已认证 Agent 单项读取成功;未认证、p2 和全局路由拒绝;tools/list 无批量导出或任意代理。" - evidence: `scoped_dev_variable_http_pilot_denies_missing_wrong_cross_and_global_auth` 真实本机 HTTP 测试通过;撤销后读取失败,工具清单无批量出口。 -### 🔴 V7 — 契约、合成试点与改动边界验收 -- status: blocked +### ✅ V7 — 契约、合成试点与改动边界验收 +- status: done - complexity: M -- files: docs/ai-context/project-context.yaml, docs/ai-context/agent-button-system.md, .blueprint/modules/project-dev-variables.md +- files: docs/ai-context/project-context.yaml, docs/ai-context/agent-button-system.md, .blueprint/modules/project-dev-variables.md, src-tauri/src/mcp_server.rs - depends: V6 - acceptance: "agent_verify: [真实 Codex CLI + 合成数据] 跨会话登记→填写→读取→撤销后再读失败,项目目录无新 .env 或 token;[CLI] cargo test、pnpm typecheck、git diff --check 均 exit 0。human_verify: 不使用真实生产账号、不触发真实 PR/部署/数据库写入,确认日常开发摩擦是否下降。" -- blocked_reason: 真实 Codex CLI 三次隔离合成试点均在调用 `register_project_dev_variable` 时被客户端审批策略拒绝(`MCP tool call requires approval, but approval policy is never`),服务端未收到登记请求;未尝试绕过审批。需用户决定是否允许一次受控、可审批的真实客户端试点。 -- pending: 真实 Codex CLI 调新工具完成整条业务链、正式打包版、桌面 UI 人眼便利性终验;V1 的真实 Codex 认证初始化与 V6 的真实 scoped HTTP 业务链已分别验证,不把组合证据冒充本卡端到端完成。 +- resolved_blocker: 原阻碍为非交互 `exec` 默认 approval=never 拒绝写工具。2026-09-30 用户授权单次 `--approve-for-me`(仅会话 A 登记这一写调用),沿用 R11 先例;未放宽全局策略,读工具无需审批。 +- verification: "2026-09-30:新增 ignored 宿主 `mcp_server::tests::isolated_dev_variables_client_pilot`(内存 DB、随机合成账户、临时 scoped MCP,认证走真实 `dev-manager-tauri.exe --lianjing-mcp-header` helper 取 Windows 凭据中的项目 bearer)。自检:helper exit 0;无认证 read_project_test_account → HTTP 403。真实 Codex CLI 0.155.0-alpha.9.2(gpt-5.6-terra)三个独立会话:A 登记 PILOT_LOGIN(environment=local)→ 宿主代人填写;B 新会话 list → configured=true、read → username=pilot-user-12abb974,与宿主生成值一致;宿主代人撤销后 C 新会话 read → 失败「开发变量尚未由用户填写」。finish 后宿主断言:状态全部未配置、目录无 `.env*`、任何文件不含 MCP token 与测试密码、MCP 认证材料已删除、临时目录已清理,test ok。回归:cargo test --locked 227 passed / 5 ignored,pnpm typecheck exit 0,git diff --check 通过。" +- human_verify: pending(真实桌面 UI 填写/撤销与日常开发摩擦是否下降,需人终验) +- unverified: "正式打包版 helper 路径;Claude/DeepSeek 等非 Codex 客户端的 bearer helper 取值;B 会话中模型把 list/read 各重复调用 3 次(结果一致,属模型行为,未影响断言);宿主强制终止时 Drop 不保证执行,可能残留合成 Windows 凭据。" ## 设计决策:测试账户一次填写与跨会话复用(2026-09-28) diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 61f885c..52b80aa 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -1224,6 +1224,219 @@ mod tests { assert!(!project_dir.exists()); } + /// V7 隔离真实客户端试点:仅内存数据库、随机合成凭证与临时 scoped MCP,认证走真实 helper。 + /// 阶段靠临时项目 `.pilot/` 标记推进:客户端登记后宿主代人填写合成账户(写 filled); + /// 见 `revoke` 标记后代人撤销(写 revoked);见 `finish` 标记后检查改动边界并清理。 + #[cfg(windows)] + #[tokio::test(flavor = "multi_thread")] + #[ignore = "V7: connect disposable Codex sessions to the printed scoped MCP URL"] + async fn isolated_dev_variables_client_pilot() { + use crate::credential_store::WindowsCredentialStore; + use std::io::Write; + use std::time::{Duration, Instant}; + + struct PilotGuard { + project_id: String, + project_dir: std::path::PathBuf, + } + impl Drop for PilotGuard { + fn drop(&mut self) { + if let Ok(conn) = db::pool().get() { + let _ = crate::commands::dev_variables::purge_project( + &conn, + &WindowsCredentialStore, + &self.project_id, + ); + } + let _ = crate::credential_store::delete_mcp_auth_token_for_project( + &WindowsCredentialStore, + &self.project_id, + ); + let _ = std::fs::remove_dir_all(&self.project_dir); + } + } + + async fn wait_until( + what: &str, + limit: Duration, + mut probe: impl FnMut() -> Option, + ) -> T { + let deadline = Instant::now() + limit; + loop { + if let Some(value) = probe() { + return value; + } + assert!(Instant::now() < deadline, "等待超时:{what}"); + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + + db::init_test_pool(); + let project_id = format!("synthetic-dev-variables-pilot-{}", uuid::Uuid::new_v4()); + let profile_id = format!("{project_id}-profile"); + let project_dir = + std::env::temp_dir().join(format!("lianjing-v7-{}", uuid::Uuid::new_v4())); + let pilot_dir = project_dir.join(".pilot"); + let guard = PilotGuard { + project_id: project_id.clone(), + project_dir: project_dir.clone(), + }; + { + let conn = db::pool().get().unwrap(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES (?1, 'Synthetic Dev Variables Pilot')", + [&profile_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)", + rusqlite::params![project_id, profile_id], + ) + .unwrap(); + } + + let token = crate::credential_store::get_or_create_mcp_auth_token( + &WindowsCredentialStore, + &project_id, + ) + .unwrap(); + let digest = crate::credential_store::mcp_auth_digest(&project_id).unwrap(); + // 测试二进制位于 target/debug/deps/,helper 即同目录上一级的应用 debug 构建。 + let helper_exe = std::env::current_exe() + .unwrap() + .parent() + .and_then(std::path::Path::parent) + .unwrap() + .join("dev-manager-tauri.exe"); + assert!( + helper_exe.is_file(), + "先 cargo build 生成 helper:{}", + helper_exe.display() + ); + let helper = format!( + "\"{}\" --lianjing-mcp-header {digest}", + helper_exe.display() + ); + + 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_all(project_dir.join(".lian-jing")).unwrap(); + std::fs::create_dir_all(project_dir.join(".codex")).unwrap(); + std::fs::create_dir_all(&pilot_dir).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 = {}\nhttp_headers_helper = {}\n", + serde_json::to_string(&url).unwrap(), + serde_json::to_string(&helper).unwrap() + ), + ) + .unwrap(); + + let username = format!("pilot-user-{}", &uuid::Uuid::new_v4().simple().to_string()[..8]); + let password = format!("pilot-pass-{}", uuid::Uuid::new_v4().simple()); + println!("V7_PILOT_PROJECT_DIR={}", project_dir.display()); + println!("V7_PILOT_SCOPED_URL={url}"); + println!("V7_PILOT_PROJECT_ID={project_id}"); + println!("V7_PILOT_HELPER={helper}"); + println!("V7_PILOT_EXPECTED_USERNAME={username}"); + std::io::stdout().flush().unwrap(); + + // 阶段 1:等客户端登记测试账户需求,再代人填写合成值。 + let name = wait_until("客户端登记测试账户需求", Duration::from_secs(1800), || { + let conn = db::pool().get().ok()?; + conn.query_row( + "SELECT name FROM project_dev_variables + WHERE project_id=?1 AND kind='test_account' ORDER BY name LIMIT 1", + [&project_id], + |row| row.get::<_, String>(0), + ) + .ok() + }) + .await; + { + let conn = db::pool().get().unwrap(); + crate::commands::dev_variables::save_test_account( + &conn, + &WindowsCredentialStore, + &project_id, + &name, + &username, + &password, + ) + .unwrap(); + } + std::fs::write(pilot_dir.join("filled"), &name).unwrap(); + println!("V7_PILOT_FILLED={name}"); + std::io::stdout().flush().unwrap(); + + // 阶段 2:客户端跨会话读取后,由操作者放置 revoke 标记,宿主代人撤销。 + wait_until("撤销标记 .pilot/revoke", Duration::from_secs(1800), || { + pilot_dir.join("revoke").exists().then_some(()) + }) + .await; + { + let conn = db::pool().get().unwrap(); + crate::commands::dev_variables::revoke(&conn, &WindowsCredentialStore, &project_id, &name) + .unwrap(); + } + std::fs::write(pilot_dir.join("revoked"), &name).unwrap(); + println!("V7_PILOT_REVOKED={name}"); + std::io::stdout().flush().unwrap(); + + // 阶段 3:撤销后再读由客户端验证失败;finish 标记后检查改动边界。 + wait_until("结束标记 .pilot/finish", Duration::from_secs(1800), || { + pilot_dir.join("finish").exists().then_some(()) + }) + .await; + server.abort(); + { + let conn = db::pool().get().unwrap(); + let statuses = crate::commands::dev_variables::list( + &conn, + &WindowsCredentialStore, + &project_id, + ) + .unwrap(); + assert!(statuses.iter().all(|status| !status.configured)); + } + let mut pending = vec![project_dir.clone()]; + while let Some(dir) = pending.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + pending.push(path); + continue; + } + let file_name = path.file_name().unwrap().to_string_lossy(); + assert!(!file_name.starts_with(".env"), "项目目录出现 {file_name}"); + let content = std::fs::read_to_string(&path).unwrap_or_default(); + assert!(!content.contains(&token), "{} 含 MCP token", path.display()); + assert!(!content.contains(&password), "{} 含测试密码", path.display()); + } + } + drop(guard); + assert!(crate::credential_store::read_mcp_auth_token_for_digest( + &WindowsCredentialStore, + &digest + ) + .is_err()); + assert!(!project_dir.exists()); + } + #[tokio::test] async fn readiness_probe_does_not_satisfy_client_reload_evidence() { let project_id = "reload-evidence-fixture";