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