test(dev-variables/mcp): 以隔离宿主完成 V7 真实 Codex 跨会话试点
新增 ignored 宿主 isolated_dev_variables_client_pilot:内存 DB + 随机合成账户 + 临时 scoped MCP,认证走真实 helper。真实 Codex CLI 三会话完成登记→代填→跨会话 读取→撤销后读取失败,宿主断言目录无 .env/token/密码且凭据清理。 Rules-Applied: R03,R05,R10,R13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4a6fbf9863
commit
9211883a9b
@ -502,9 +502,9 @@ modules:
|
|||||||
name: 项目开发变量补给
|
name: 项目开发变量补给
|
||||||
area: infra
|
area: infra
|
||||||
status: in_progress
|
status: in_progress
|
||||||
progress: 91
|
progress: 100
|
||||||
updated: 2026-09-28
|
updated: 2026-09-30
|
||||||
note: "V1–V6、V8–V11 合成验收通过;V7 真实 Codex 工具调用仍被客户端 approval policy=never 拒绝。真实桌面 UI 和跨会话便利性待终验;仅非生产开发用途,运行时 env 注入后置。"
|
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]
|
position: [3250, 950]
|
||||||
|
|
||||||
edges:
|
edges:
|
||||||
|
|||||||
@ -102,14 +102,16 @@ progress: 91
|
|||||||
- acceptance: "[真实 scoped MCP] p1 Agent 登记 E2E_PASSWORD → UI 待填写;人保存合成值后已认证 Agent 单项读取成功;未认证、p2 和全局路由拒绝;tools/list 无批量导出或任意代理。"
|
- 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 测试通过;撤销后读取失败,工具清单无批量出口。
|
- evidence: `scoped_dev_variable_http_pilot_denies_missing_wrong_cross_and_global_auth` 真实本机 HTTP 测试通过;撤销后读取失败,工具清单无批量出口。
|
||||||
|
|
||||||
### 🔴 V7 — 契约、合成试点与改动边界验收
|
### ✅ V7 — 契约、合成试点与改动边界验收
|
||||||
- status: blocked
|
- status: done
|
||||||
- complexity: M
|
- 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
|
- depends: V6
|
||||||
- acceptance: "agent_verify: [真实 Codex CLI + 合成数据] 跨会话登记→填写→读取→撤销后再读失败,项目目录无新 .env 或 token;[CLI] cargo test、pnpm typecheck、git diff --check 均 exit 0。human_verify: 不使用真实生产账号、不触发真实 PR/部署/数据库写入,确认日常开发摩擦是否下降。"
|
- 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`),服务端未收到登记请求;未尝试绕过审批。需用户决定是否允许一次受控、可审批的真实客户端试点。
|
- resolved_blocker: 原阻碍为非交互 `exec` 默认 approval=never 拒绝写工具。2026-09-30 用户授权单次 `--approve-for-me`(仅会话 A 登记这一写调用),沿用 R11 先例;未放宽全局策略,读工具无需审批。
|
||||||
- pending: 真实 Codex CLI 调新工具完成整条业务链、正式打包版、桌面 UI 人眼便利性终验;V1 的真实 Codex 认证初始化与 V6 的真实 scoped HTTP 业务链已分别验证,不把组合证据冒充本卡端到端完成。
|
- 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)
|
## 设计决策:测试账户一次填写与跨会话复用(2026-09-28)
|
||||||
|
|
||||||
|
|||||||
@ -1224,6 +1224,219 @@ mod tests {
|
|||||||
assert!(!project_dir.exists());
|
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<T>(
|
||||||
|
what: &str,
|
||||||
|
limit: Duration,
|
||||||
|
mut probe: impl FnMut() -> Option<T>,
|
||||||
|
) -> 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]
|
#[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