feat(dev-variables): 项目开发变量与测试账户补给(V1–V11) #23

Merged
lanrtop merged 15 commits from feat/project-dev-variables into master 2026-09-30 13:35:23 +08:00
3 changed files with 223 additions and 8 deletions
Showing only changes of commit 9211883a9b - Show all commits

View File

@ -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:

View File

@ -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)

View File

@ -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<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]
async fn readiness_probe_does_not_satisfy_client_reload_evidence() {
let project_id = "reload-evidence-fixture";