feat(onboard/parity): 写入汇 WriteSink 与写入前计划 plan_onboarding,处置独立审查(P8c) #36
@ -1311,14 +1311,18 @@ fn upsert_managed_block_with(
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sync_blueprint_rules(project_path: String) -> Result<SyncResult, String> {
|
||||
sync_blueprint_rules_with(Path::new(&project_path), &crate::DiskSink)
|
||||
Ok(sync_blueprint_rules_with(
|
||||
Path::new(&project_path),
|
||||
&crate::DiskSink,
|
||||
)?)
|
||||
}
|
||||
|
||||
/// sync_blueprint_rules 的写入汇注入版:plan 用记录汇得到同一份结果而不落盘(P8c)。
|
||||
/// 失败带出真正出错的文件(CONVENTIONS.md、AGENTS.md 或 CLAUDE.md),计划的阻断项据此指向它。
|
||||
pub(crate) fn sync_blueprint_rules_with(
|
||||
root: &Path,
|
||||
sink: &dyn crate::WriteSink,
|
||||
) -> Result<SyncResult, String> {
|
||||
) -> Result<SyncResult, crate::WriteFailure> {
|
||||
let bp_dir = root.join(".blueprint");
|
||||
let project_name = root
|
||||
.file_name()
|
||||
@ -1328,28 +1332,31 @@ pub(crate) fn sync_blueprint_rules_with(
|
||||
|
||||
// 1. 写入 CONVENTIONS.md(使用当前生效版本:用户版优先,fallback 内置;内容不变则不写;
|
||||
// .blueprint/ 由写入汇按需创建)
|
||||
let conventions_path = bp_dir.join("CONVENTIONS.md");
|
||||
let conventions_written = sink
|
||||
.write(
|
||||
&bp_dir.join("CONVENTIONS.md"),
|
||||
&effective_conventions_content(),
|
||||
)
|
||||
.map_err(|e| format!("写入 CONVENTIONS.md 失败: {e}"))?;
|
||||
.write(&conventions_path, &effective_conventions_content())
|
||||
.map_err(|e| format!("写入 CONVENTIONS.md 失败: {e}"))
|
||||
.map_err(crate::WriteFailure::at(&conventions_path))?;
|
||||
|
||||
// 2. AGENTS.md:完整蓝图工作流(跨工具单一来源,Qwen/Cursor/Copilot 均读)
|
||||
let agents_path = root.join("AGENTS.md");
|
||||
let agents_action = upsert_managed_block_with(
|
||||
&root.join("AGENTS.md"),
|
||||
&agents_path,
|
||||
&agents_managed_template(),
|
||||
&format!("# AGENTS.md — {project_name}\n\n> 跨工具 AI 上下文入口(Claude / Qwen / Cursor / Copilot 等均读此文件)。"),
|
||||
sink,
|
||||
)?;
|
||||
)
|
||||
.map_err(crate::WriteFailure::at(&agents_path))?;
|
||||
|
||||
// 3. CLAUDE.md:薄引用(Claude 经 @AGENTS.md 接入单一来源)
|
||||
let claude_path = root.join("CLAUDE.md");
|
||||
let claude_action = upsert_managed_block_with(
|
||||
&root.join("CLAUDE.md"),
|
||||
&claude_path,
|
||||
&claude_managed_template(),
|
||||
&format!("# {project_name}"),
|
||||
sink,
|
||||
)?;
|
||||
)
|
||||
.map_err(crate::WriteFailure::at(&claude_path))?;
|
||||
|
||||
Ok(SyncResult {
|
||||
success: true,
|
||||
|
||||
@ -55,6 +55,7 @@ impl TemplateResult {
|
||||
}
|
||||
|
||||
/// 分发单个模板:存在则跳过(绝不覆盖),不存在则写入。
|
||||
/// 只判存在、不读内容:已存在的非 UTF-8 文件(UTF-16、GBK 等项目自有文件)照旧跳过。
|
||||
fn dispatch_one(
|
||||
root: &Path,
|
||||
relative: &str,
|
||||
@ -62,10 +63,8 @@ fn dispatch_one(
|
||||
sink: &dyn crate::WriteSink,
|
||||
) -> TemplateResult {
|
||||
let target = root.join(relative);
|
||||
match sink.read(&target) {
|
||||
Ok(Some(_)) => return TemplateResult::new(relative, "skipped", "已存在,未覆盖"),
|
||||
Ok(None) => {}
|
||||
Err(e) => return TemplateResult::new(relative, "failed", format!("读取失败: {e}")),
|
||||
if sink.exists(&target) {
|
||||
return TemplateResult::new(relative, "skipped", "已存在,未覆盖");
|
||||
}
|
||||
match sink.write(&target, content) {
|
||||
Ok(_) => TemplateResult::new(relative, "written", "已写入"),
|
||||
@ -76,6 +75,7 @@ fn dispatch_one(
|
||||
/// 全托管文件分发(三档升级语义之②,U1):炼境独占管理、用户不定制(文件头有托管声明)。
|
||||
/// 不存在→written;与内嵌模板一致→skipped;不一致→覆盖 updated。
|
||||
/// 比较忽略行尾(子项目 git autocrlf 可能把 LF 检出为 CRLF,避免永久 updated 抖动)。
|
||||
/// 已存在但读不出(非 UTF-8、无权限)记 failed,不盲目覆盖(P8c 起;原为直接覆盖)。
|
||||
fn dispatch_fully_managed(
|
||||
root: &Path,
|
||||
relative: &str,
|
||||
@ -83,8 +83,10 @@ fn dispatch_fully_managed(
|
||||
sink: &dyn crate::WriteSink,
|
||||
) -> TemplateResult {
|
||||
let target = root.join(relative);
|
||||
if !matches!(sink.read(&target), Ok(Some(_))) {
|
||||
return dispatch_one(root, relative, content, sink);
|
||||
match sink.read(&target) {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => return dispatch_one(root, relative, content, sink),
|
||||
Err(e) => return TemplateResult::new(relative, "failed", format!("读取失败: {e}")),
|
||||
}
|
||||
match sink.write(&target, content) {
|
||||
Ok(false) => TemplateResult::new(relative, "skipped", "内容已是最新模板"),
|
||||
|
||||
@ -112,6 +112,34 @@ pub fn read_mcp_auth_token_for_digest(
|
||||
decode_mcp_auth_token(bytes)
|
||||
}
|
||||
|
||||
/// 项目 MCP token 在受保护存储中的状态(只读,不生成)。分支与 get_or_create_mcp_auth_token 一一对应:
|
||||
/// 读存储失败或 token 损坏时后者返回 needs_repair(不会重新生成),只有「未配置」才会生成。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum McpAuthTokenState {
|
||||
Present,
|
||||
Missing,
|
||||
NeedsRepair(String),
|
||||
}
|
||||
|
||||
pub fn mcp_auth_token_state(store: &impl CredentialStore, project_id: &str) -> McpAuthTokenState {
|
||||
let reference = match mcp_auth_digest(project_id)
|
||||
.and_then(|digest| mcp_auth_reference_for_digest(&digest))
|
||||
{
|
||||
Ok(reference) => reference,
|
||||
Err(reason) => return McpAuthTokenState::NeedsRepair(reason),
|
||||
};
|
||||
match store.get(&reference) {
|
||||
Err(_) => {
|
||||
McpAuthTokenState::NeedsRepair("MCP 认证材料 needs_repair:受保护存储不可用".into())
|
||||
}
|
||||
Ok(None) => McpAuthTokenState::Missing,
|
||||
Ok(Some(bytes)) => match decode_mcp_auth_token(bytes) {
|
||||
Ok(_) => McpAuthTokenState::Present,
|
||||
Err(reason) => McpAuthTokenState::NeedsRepair(reason),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_mcp_auth_token_for_project(
|
||||
store: &impl CredentialStore,
|
||||
project_id: &str,
|
||||
@ -629,6 +657,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 一键接入计划的 token 步据此预告 apply:只有 Missing 会生成,NeedsRepair 时 apply 报错、不重新生成。
|
||||
#[test]
|
||||
fn mcp_auth_token_state_matches_get_or_create_branches() {
|
||||
use super::{mcp_auth_reference_for_digest, mcp_auth_token_state, McpAuthTokenState};
|
||||
let store = MemoryCredentialStore::new();
|
||||
assert_eq!(
|
||||
mcp_auth_token_state(&store, "p1"),
|
||||
McpAuthTokenState::Missing
|
||||
);
|
||||
get_or_create_mcp_auth_token(&store, "p1").unwrap();
|
||||
assert_eq!(
|
||||
mcp_auth_token_state(&store, "p1"),
|
||||
McpAuthTokenState::Present
|
||||
);
|
||||
|
||||
let corrupt = mcp_auth_reference_for_digest(&mcp_auth_digest("p2").unwrap()).unwrap();
|
||||
store.put(&corrupt, b"not-a-64-hex-token").unwrap();
|
||||
assert!(matches!(
|
||||
mcp_auth_token_state(&store, "p2"),
|
||||
McpAuthTokenState::NeedsRepair(reason) if reason.contains("needs_repair")
|
||||
));
|
||||
assert!(
|
||||
get_or_create_mcp_auth_token(&store, "p2")
|
||||
.unwrap_err()
|
||||
.contains("needs_repair"),
|
||||
"token 损坏时 apply 不会重新生成"
|
||||
);
|
||||
|
||||
let failing = MemoryCredentialStore::failing();
|
||||
assert!(matches!(
|
||||
mcp_auth_token_state(&failing, "p1"),
|
||||
McpAuthTokenState::NeedsRepair(reason) if reason.contains("受保护存储不可用")
|
||||
));
|
||||
assert!(get_or_create_mcp_auth_token(&failing, "p1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_migration_verifies_then_switches_reference_and_clears_old_value() {
|
||||
let conn = migration_db();
|
||||
|
||||
@ -49,12 +49,42 @@ pub(crate) trait WriteSink {
|
||||
fn read(&self, path: &std::path::Path) -> std::io::Result<Option<String>>;
|
||||
/// 内容(忽略行尾)未变返回 Ok(false);否则写入(必要时先建父目录)并返回 Ok(true)。
|
||||
fn write(&self, path: &std::path::Path, content: &str) -> std::io::Result<bool>;
|
||||
/// 是否存在(含本汇里的待写内容)。「存在即跳过」的分发只需判存在,不读内容——
|
||||
/// 子项目自有的非 UTF-8 文件(如 PowerShell 5 重定向生成的 UTF-16 lefthook.yml)照旧跳过,不报读取失败。
|
||||
fn exists(&self, path: &std::path::Path) -> bool;
|
||||
}
|
||||
|
||||
/// 写入序列中某个文件读写失败:`path` 指向真正出错的文件(计划的阻断项据此给出目标),
|
||||
/// `reason` 为原有错误文案;转成 String 时只取文案,原有返回 String 的入口行为不变。
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WriteFailure {
|
||||
pub(crate) path: std::path::PathBuf,
|
||||
pub(crate) reason: String,
|
||||
}
|
||||
|
||||
impl WriteFailure {
|
||||
pub(crate) fn at(path: &std::path::Path) -> impl FnOnce(String) -> WriteFailure + '_ {
|
||||
move |reason| WriteFailure {
|
||||
path: path.to_path_buf(),
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WriteFailure> for String {
|
||||
fn from(failure: WriteFailure) -> String {
|
||||
failure.reason
|
||||
}
|
||||
}
|
||||
|
||||
/// 真实写入:内容不变不重写(write_if_content_changed),父目录按需创建。
|
||||
pub(crate) struct DiskSink;
|
||||
|
||||
impl WriteSink for DiskSink {
|
||||
fn exists(&self, path: &std::path::Path) -> bool {
|
||||
path.exists()
|
||||
}
|
||||
|
||||
fn read(&self, path: &std::path::Path) -> std::io::Result<Option<String>> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => Ok(Some(text)),
|
||||
|
||||
@ -304,26 +304,26 @@ pub(crate) fn write_settings_compat_with(
|
||||
write_json_with(&path, &settings, "settings.json", sink)
|
||||
}
|
||||
|
||||
/// 项目侧 MCP 文件(canonical context、Claude 兼容投影、Codex 托管块)一次写齐;
|
||||
/// inject 与 onboard::plan 共用同一序列,计划与实际写入的结果一致。返回 Codex 托管块结果。
|
||||
pub(crate) fn write_project_mcp_files_with(
|
||||
/// 项目身份与兼容投影(canonical context、settings.json 兼容条目、Claude 兼容上下文)按固定顺序写齐。
|
||||
/// inject_project_mcp_locked 与 onboard::plan::write_project_files 都只调这一个函数,写入序列只有一份;
|
||||
/// Codex 托管块另由 upsert_codex_mcp_config_with 写(inject 经 CodexAdapter 调,plan 直接调)。
|
||||
/// 失败时带出真正出错的文件,供计划的阻断项指向它。
|
||||
pub(crate) fn write_project_identity_files_with(
|
||||
root: &std::path::Path,
|
||||
project_id: &str,
|
||||
port: u16,
|
||||
server_url: &str,
|
||||
header_helper: &str,
|
||||
sink: &dyn crate::WriteSink,
|
||||
) -> Result<ClientOutcome, String> {
|
||||
write_canonical_context_with(root, project_id, sink)?;
|
||||
write_settings_compat_with(root, server_url, sink)?;
|
||||
write_liangjing_context_with(root, project_id, port, sink)?;
|
||||
Ok(upsert_codex_mcp_config_with(
|
||||
&root.join(".codex").join("config.toml"),
|
||||
server_url,
|
||||
header_helper,
|
||||
sink,
|
||||
)
|
||||
.unwrap_or_else(|reason| ClientOutcome::Failed { reason }))
|
||||
) -> Result<(), crate::WriteFailure> {
|
||||
let context = root.join(".lian-jing").join("context.json");
|
||||
write_canonical_context_with(root, project_id, sink)
|
||||
.map_err(crate::WriteFailure::at(&context))?;
|
||||
let settings = root.join(".claude").join("settings.json");
|
||||
write_settings_compat_with(root, server_url, sink)
|
||||
.map_err(crate::WriteFailure::at(&settings))?;
|
||||
let liangjing = root.join(".claude").join("liangjing.json");
|
||||
write_liangjing_context_with(root, project_id, port, sink)
|
||||
.map_err(crate::WriteFailure::at(&liangjing))
|
||||
}
|
||||
|
||||
/// 查找 claude CLI 路径(优先环境变量 CLAUDE_BIN,然后常用安装目录,最后 where 命令)
|
||||
@ -1490,7 +1490,7 @@ fn upsert_codex_mcp_config(
|
||||
upsert_codex_mcp_config_with(path, server_url, header_helper, &crate::DiskSink)
|
||||
}
|
||||
|
||||
fn upsert_codex_mcp_config_with(
|
||||
pub(crate) fn upsert_codex_mcp_config_with(
|
||||
path: &std::path::Path,
|
||||
server_url: &str,
|
||||
header_helper: &str,
|
||||
@ -1624,10 +1624,8 @@ fn inject_project_mcp_locked(
|
||||
ensure_header_shim()?;
|
||||
let header_helper = shim_helper_command(&digest);
|
||||
let root = project_root(project_id)?;
|
||||
write_canonical_context_with(&root, project_id, &crate::DiskSink)?;
|
||||
write_settings_compat_with(&root, &server_url, &crate::DiskSink)?;
|
||||
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
|
||||
write_liangjing_context_with(&root, project_id, port, &crate::DiskSink)?;
|
||||
write_project_identity_files_with(&root, project_id, port, &server_url, &crate::DiskSink)?;
|
||||
|
||||
let facts = project_facts(project_id, &root);
|
||||
let desired = DesiredMcp {
|
||||
@ -1701,8 +1699,8 @@ pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
|
||||
|
||||
// ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ──────────────────────────
|
||||
|
||||
/// 一键接入写入前计划(只读:不写文件、不起客户端 CLI;混合文件预警会起只读的
|
||||
/// git status --no-optional-locks 与 git show)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。
|
||||
/// 一键接入写入前计划(只读:不写文件、不起客户端 CLI;混合文件预警会起只读的 git status
|
||||
/// --no-optional-locks 与 git show,Codex 信任预判会起只读的 git rev-parse)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn plan_onboarding(
|
||||
project_id: &str,
|
||||
@ -1721,36 +1719,20 @@ pub(crate) fn plan_onboarding(
|
||||
Ok(crate::onboard::plan::plan_onboarding(
|
||||
&root,
|
||||
&project,
|
||||
plan_machine_steps(project_id, &root),
|
||||
plan_machine_steps(project_id),
|
||||
codex_trust(&root),
|
||||
))
|
||||
}
|
||||
|
||||
/// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)、Codex 信任。
|
||||
/// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)。
|
||||
/// Codex 信任不在此列(不影响炼境写入,见 codex_trust)。
|
||||
#[allow(dead_code)]
|
||||
fn plan_machine_steps(
|
||||
project_id: &str,
|
||||
root: &std::path::Path,
|
||||
) -> Vec<crate::onboard::plan::MachineStep> {
|
||||
use crate::onboard::plan::MachineStep;
|
||||
let step = |step: &str, status: &str, detail: String| MachineStep {
|
||||
step: step.into(),
|
||||
status: status.into(),
|
||||
detail,
|
||||
};
|
||||
fn plan_machine_steps(project_id: &str) -> Vec<crate::onboard::plan::MachineStep> {
|
||||
let mut steps = vec![
|
||||
if is_project_injected(&crate::credential_store::WindowsCredentialStore, project_id) {
|
||||
step(
|
||||
"token",
|
||||
"noop",
|
||||
"项目 MCP 认证 token 已在本机受保护存储".into(),
|
||||
)
|
||||
} else {
|
||||
step(
|
||||
"token",
|
||||
"apply",
|
||||
"将在本机受保护存储生成项目 MCP 认证 token".into(),
|
||||
)
|
||||
},
|
||||
token_step(crate::credential_store::mcp_auth_token_state(
|
||||
&crate::credential_store::WindowsCredentialStore,
|
||||
project_id,
|
||||
)),
|
||||
shim_step(),
|
||||
];
|
||||
for report in client_static_status(project_id) {
|
||||
@ -1759,24 +1741,41 @@ fn plan_machine_steps(
|
||||
ClientId::Codex => "codex",
|
||||
ClientId::Dsh => "dsh",
|
||||
};
|
||||
steps.push(step(
|
||||
name,
|
||||
outcome_status(&report.outcome),
|
||||
report.outcome.describe(),
|
||||
));
|
||||
steps.push(crate::onboard::plan::MachineStep {
|
||||
step: name.into(),
|
||||
status: outcome_status(&report.outcome).into(),
|
||||
detail: report.outcome.describe(),
|
||||
});
|
||||
}
|
||||
steps.push(match codex_trusts(root) {
|
||||
Some(true) => step("codex_trust", "noop", "Codex 已信任该目录".into()),
|
||||
Some(false) => step(
|
||||
"codex_trust",
|
||||
"needs_human",
|
||||
"Codex 只在目录受信任后才加载项目 .codex/config.toml:请在 Codex 里信任该目录(炼境不改 Codex 信任表)".into(),
|
||||
),
|
||||
None => step("codex_trust", "not_installed", "未发现 Codex 配置".into()),
|
||||
});
|
||||
steps
|
||||
}
|
||||
|
||||
/// token 步的预计动作,与 apply 时 get_or_create_mcp_auth_token 的分支一一对应:只有「未配置」会生成;
|
||||
/// 存储不可用或 token 损坏时 apply 报 needs_repair、不会重新生成,计划如实记 needs_human(阻断确认)。
|
||||
/// 「已接入」判据仍是 is_project_injected,这里不复用它——它把「未配置」与 needs_repair 合成了一个 false。
|
||||
#[allow(dead_code)]
|
||||
fn token_step(
|
||||
state: crate::credential_store::McpAuthTokenState,
|
||||
) -> crate::onboard::plan::MachineStep {
|
||||
use crate::credential_store::McpAuthTokenState;
|
||||
let (status, detail) = match state {
|
||||
McpAuthTokenState::Present => ("noop", "项目 MCP 认证 token 已在本机受保护存储".to_string()),
|
||||
McpAuthTokenState::Missing => (
|
||||
"apply",
|
||||
"将在本机受保护存储生成项目 MCP 认证 token".to_string(),
|
||||
),
|
||||
McpAuthTokenState::NeedsRepair(reason) => (
|
||||
"needs_human",
|
||||
format!("{reason};apply 的 token 步会失败(不会重新生成),须先修复本机受保护存储中的项目 token"),
|
||||
),
|
||||
};
|
||||
crate::onboard::plan::MachineStep {
|
||||
step: "token".into(),
|
||||
status: status.into(),
|
||||
detail,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn shim_step() -> crate::onboard::plan::MachineStep {
|
||||
let (status, detail) = match (helper_bin_dir(), std::env::current_exe()) {
|
||||
@ -1818,46 +1817,185 @@ fn outcome_status(outcome: &ClientOutcome) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex 用户配置里是否把该目录(或其祖先目录)标为 trusted;未发现 Codex 配置返回 None。
|
||||
/// 只读、只认 `[projects.'<路径>']` 段下的 `trust_level = "trusted"`(P4-0 H-P8-1:未信任目录不加载项目配置)。
|
||||
#[allow(dead_code)]
|
||||
fn codex_trusts(root: &std::path::Path) -> Option<bool> {
|
||||
let home = std::env::var_os("CODEX_HOME")
|
||||
/// Codex 配置目录(CODEX_HOME 或 ~/.codex)。
|
||||
fn codex_home() -> Option<PathBuf> {
|
||||
std::env::var_os("CODEX_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".codex"))
|
||||
})?;
|
||||
let text = std::fs::read_to_string(home.join("config.toml")).ok()?;
|
||||
Some(codex_config_trusts(&text, &root.display().to_string()))
|
||||
.or_else(|| std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".codex")))
|
||||
}
|
||||
|
||||
/// Codex 是否信任项目目录(只读预判,P4-0 H-P8-1:未信任目录不加载项目 .codex/config.toml)。
|
||||
/// 候选按 Codex 的判定方式取:项目根目录本身,再取 git 主仓库根(worktree 由此继承主仓库的信任,
|
||||
/// 仓库子目录取所在仓库根);任一在 ~/.codex/config.toml 的 [projects] 表里 trust_level = "trusted" 即已信任。
|
||||
/// 不逐级查祖先目录(P8c 审查:Codex 不这样判定)。权威结论以 P4a 的 `codex mcp get --json` 自省为准。
|
||||
#[allow(dead_code)]
|
||||
fn codex_trust(root: &std::path::Path) -> crate::onboard::plan::CodexTrust {
|
||||
use crate::onboard::plan::CodexTrust;
|
||||
let Some(home) = codex_home().filter(|dir| dir.is_dir()) else {
|
||||
return CodexTrust::NotInstalled;
|
||||
};
|
||||
let config = match std::fs::read_to_string(home.join("config.toml")) {
|
||||
Ok(text) => text,
|
||||
// 装了 Codex 但还没有用户配置:没有任何目录受信任
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(_) => return CodexTrust::Unknown,
|
||||
};
|
||||
let mut candidates = vec![root.to_path_buf()];
|
||||
candidates.extend(git_main_worktree_root(root));
|
||||
if codex_config_trusts(&config, &candidates) {
|
||||
CodexTrust::Trusted
|
||||
} else {
|
||||
CodexTrust::Untrusted
|
||||
}
|
||||
}
|
||||
|
||||
/// git 主仓库根:`git rev-parse --git-common-dir` 的上级目录(普通仓库为仓库根,worktree 为主仓库根,
|
||||
/// 仓库子目录为所在仓库根)。git 不可用或不在 git 仓库内返回 None。
|
||||
#[allow(dead_code)]
|
||||
fn git_main_worktree_root(root: &std::path::Path) -> Option<PathBuf> {
|
||||
let output = crate::silent_cmd("git")
|
||||
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let common = String::from_utf8(output.stdout).ok()?;
|
||||
PathBuf::from(common.trim()).parent().map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn codex_config_trusts(config: &str, root: &str) -> bool {
|
||||
let normalize = |path: &str| {
|
||||
path.replace('/', "\\")
|
||||
.trim_end_matches('\\')
|
||||
.to_lowercase()
|
||||
};
|
||||
let root = normalize(root);
|
||||
let mut section: Option<String> = None;
|
||||
fn codex_config_trusts(config: &str, candidates: &[PathBuf]) -> bool {
|
||||
let trusted = codex_trusted_projects(config);
|
||||
candidates
|
||||
.iter()
|
||||
.any(|candidate| trusted.contains(&normalize_trust_path(&candidate.to_string_lossy())))
|
||||
}
|
||||
|
||||
/// Windows 路径比较口径:去 \\?\ 前缀、统一反斜杠、去尾部分隔符、不区分大小写。
|
||||
fn normalize_trust_path(path: &str) -> String {
|
||||
path.strip_prefix(r"\\?\")
|
||||
.unwrap_or(path)
|
||||
.replace('/', "\\")
|
||||
.trim_end_matches('\\')
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
/// ~/.codex/config.toml 中 trust_level = "trusted" 的项目路径(已归一化)。不引入 TOML 依赖的最小解析:
|
||||
/// 认 `[projects.'<路径>']`(字面量字符串)与 `[projects."<路径>"]`(基本字符串,按 TOML 规则反转义)段,
|
||||
/// 以及 `[projects]` 段内的内联表 `'<路径>' = { trust_level = "trusted" }` 与点号键写法。
|
||||
/// 键里有非法转义(TOML 本身不合法,Codex 也加载不了)时忽略该项。
|
||||
fn codex_trusted_projects(config: &str) -> Vec<String> {
|
||||
// None:不在 projects 段;Some(None):[projects];Some(Some(key)):[projects.<key>]
|
||||
let mut section: Option<Option<String>> = None;
|
||||
let mut trusted = Vec::new();
|
||||
for line in config.lines().map(str::trim) {
|
||||
if line.starts_with('[') {
|
||||
section = line
|
||||
.strip_prefix("[projects.")
|
||||
.and_then(|rest| rest.strip_suffix(']'))
|
||||
.map(|key| normalize(key.trim_matches(['\'', '"'])));
|
||||
section = parse_projects_header(line);
|
||||
continue;
|
||||
}
|
||||
let Some(key) = §ion else { continue };
|
||||
let covers = &root == key || root.starts_with(&format!("{key}\\"));
|
||||
let trusted = line.split_once('=').is_some_and(|(name, value)| {
|
||||
name.trim() == "trust_level" && value.trim().trim_matches('"') == "trusted"
|
||||
});
|
||||
if covers && trusted {
|
||||
return true;
|
||||
match §ion {
|
||||
Some(Some(key)) if is_trusted_assignment(line) => {
|
||||
trusted.push(normalize_trust_path(key));
|
||||
}
|
||||
Some(None) => {
|
||||
let Some((key, rest)) = parse_toml_key(line) else {
|
||||
continue;
|
||||
};
|
||||
let rest = rest.trim_start();
|
||||
let here = if let Some(dotted) = rest.strip_prefix('.') {
|
||||
is_trusted_assignment(dotted.trim_start())
|
||||
} else if let Some(value) = rest.strip_prefix('=') {
|
||||
value
|
||||
.trim()
|
||||
.strip_prefix('{')
|
||||
.and_then(|inner| inner.split('}').next())
|
||||
.is_some_and(|inner| {
|
||||
inner
|
||||
.split(',')
|
||||
.any(|pair| is_trusted_assignment(pair.trim()))
|
||||
})
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if here {
|
||||
trusted.push(normalize_trust_path(&key));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
trusted
|
||||
}
|
||||
|
||||
/// `[projects]` → Some(None);`[projects.<键>]` → Some(Some(键));其他段(含数组表)→ None。
|
||||
fn parse_projects_header(line: &str) -> Option<Option<String>> {
|
||||
let inner = line.strip_prefix('[')?.trim_start();
|
||||
if inner.starts_with('[') {
|
||||
return None;
|
||||
}
|
||||
let rest = inner.strip_prefix("projects")?.trim_start();
|
||||
if rest.starts_with(']') {
|
||||
return Some(None);
|
||||
}
|
||||
let (key, after) = parse_toml_key(rest.strip_prefix('.')?.trim_start())?;
|
||||
after.trim_start().starts_with(']').then_some(Some(key))
|
||||
}
|
||||
|
||||
/// `trust_level = "trusted"`(允许单引号与行尾注释)。
|
||||
fn is_trusted_assignment(text: &str) -> bool {
|
||||
text.split_once('=').is_some_and(|(name, value)| {
|
||||
let value = value.trim_start();
|
||||
name.trim() == "trust_level"
|
||||
&& (value.starts_with("\"trusted\"") || value.starts_with("'trusted'"))
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析行首的一个 TOML 键(字面量字符串、基本字符串或裸键),返回(键,剩余文本)。
|
||||
/// 基本字符串按 TOML 规则反转义;非法转义或未闭合返回 None。
|
||||
fn parse_toml_key(text: &str) -> Option<(String, &str)> {
|
||||
if let Some(rest) = text.strip_prefix('\'') {
|
||||
let end = rest.find('\'')?;
|
||||
return Some((rest[..end].to_string(), &rest[end + 1..]));
|
||||
}
|
||||
if let Some(rest) = text.strip_prefix('"') {
|
||||
let mut key = String::new();
|
||||
let mut chars = rest.char_indices();
|
||||
while let Some((index, ch)) = chars.next() {
|
||||
match ch {
|
||||
'"' => return Some((key, &rest[index + 1..])),
|
||||
'\\' => {
|
||||
let (_, escaped) = chars.next()?;
|
||||
let decoded = match escaped {
|
||||
'\\' => '\\',
|
||||
'"' => '"',
|
||||
'b' => '\u{8}',
|
||||
't' => '\t',
|
||||
'n' => '\n',
|
||||
'f' => '\u{c}',
|
||||
'r' => '\r',
|
||||
'e' => '\u{1b}',
|
||||
'u' | 'U' => {
|
||||
let len = if escaped == 'u' { 4 } else { 8 };
|
||||
let hex = (0..len)
|
||||
.map(|_| chars.next().map(|(_, c)| c))
|
||||
.collect::<Option<String>>()?;
|
||||
char::from_u32(u32::from_str_radix(&hex, 16).ok()?)?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
key.push(decoded);
|
||||
}
|
||||
_ => key.push(ch),
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let end = text
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))
|
||||
.unwrap_or(text.len());
|
||||
(end > 0).then(|| (text[..end].to_string(), &text[end..]))
|
||||
}
|
||||
|
||||
// ── 三端客户端适配器(注册表见 onboard::clients::registry)──────────────────────
|
||||
@ -1960,12 +2098,7 @@ impl ClientAdapter for CodexAdapter {
|
||||
|
||||
/// Codex 配置目录(CODEX_HOME 或 ~/.codex)存在即视为已装;不起子进程。
|
||||
fn detect(&self) -> bool {
|
||||
std::env::var_os("CODEX_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".codex"))
|
||||
})
|
||||
.is_some_and(|dir| dir.is_dir())
|
||||
codex_home().is_some_and(|dir| dir.is_dir())
|
||||
}
|
||||
|
||||
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
|
||||
@ -2965,27 +3098,135 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// Codex 信任表解析:字面量键、按 TOML 规则转义的基本字符串键、[projects] 段内的内联表与点号键;
|
||||
/// 只认精确目录(及调用方给出的 git 主仓库根候选),不逐级查祖先目录。
|
||||
#[test]
|
||||
fn codex_trust_reads_projects_table_including_ancestors() {
|
||||
let config = "model = \"x\"\n\n[projects.'e:\\code-project\\enterprise-system']\ntrust_level = \"trusted\"\n\n[projects.\"D:\\Work\"]\ntrust_level = \"trusted\"\n\n[projects.'e:\\other']\ntrust_level = \"untrusted\"\n";
|
||||
assert!(codex_config_trusts(
|
||||
config,
|
||||
"E:\\code-project\\enterprise-system"
|
||||
));
|
||||
assert!(codex_config_trusts(
|
||||
config,
|
||||
"E:/code-project/enterprise-system/"
|
||||
));
|
||||
fn codex_trust_matches_exact_dirs_with_real_toml_keys() {
|
||||
let config = r#"model = "x"
|
||||
|
||||
[projects.'e:\code-project\enterprise-system']
|
||||
trust_level = "trusted"
|
||||
|
||||
[projects."D:\\Work"]
|
||||
trust_level = "trusted" # 基本字符串里的反斜杠必须转义
|
||||
|
||||
[projects."D:\Bad"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[projects.'e:\other']
|
||||
trust_level = "untrusted"
|
||||
|
||||
[projects]
|
||||
'f:\inline' = { trust_level = "trusted" }
|
||||
"f:\\dotted".trust_level = 'trusted'
|
||||
'f:\inline-untrusted' = { trust_level = "untrusted" }
|
||||
|
||||
[[profiles]]
|
||||
trust_level = "trusted"
|
||||
"#;
|
||||
let trusts = |paths: &[&str]| {
|
||||
codex_config_trusts(config, &paths.iter().map(PathBuf::from).collect::<Vec<_>>())
|
||||
};
|
||||
assert!(trusts(&[r"E:\code-project\enterprise-system"]));
|
||||
assert!(trusts(&["E:/code-project/enterprise-system/"]));
|
||||
assert!(trusts(&[r"\\?\E:\code-project\enterprise-system"]));
|
||||
assert!(trusts(&[r"D:\Work"]), "转义的基本字符串键按反转义后匹配");
|
||||
assert!(
|
||||
codex_config_trusts(config, "d:\\work\\sub\\proj"),
|
||||
"祖先目录受信任"
|
||||
!trusts(&[r"D:\Work\sub\proj"]),
|
||||
"受信任的祖先目录不覆盖子目录(只认精确目录与 git 主仓库根)"
|
||||
);
|
||||
assert!(!codex_config_trusts(config, "E:\\other"), "untrusted 不算");
|
||||
assert!(!trusts(&[r"D:\Bad"]), "非法转义的键忽略");
|
||||
assert!(!trusts(&[r"E:\other"]), "untrusted 不算");
|
||||
assert!(
|
||||
!codex_config_trusts(config, "E:\\code-project\\enterprise-system-wt"),
|
||||
"前缀不是路径边界"
|
||||
!trusts(&[r"E:\code-project\enterprise-system-wt"]),
|
||||
"前缀不是同一目录"
|
||||
);
|
||||
assert!(trusts(&[r"F:\inline"]), "[projects] 内联表");
|
||||
assert!(trusts(&[r"F:\dotted"]), "[projects] 点号键");
|
||||
assert!(!trusts(&[r"F:\inline-untrusted"]));
|
||||
assert!(!trusts(&[r"C:\new"]));
|
||||
assert!(
|
||||
trusts(&[r"C:\wt", r"E:\code-project\enterprise-system"]),
|
||||
"任一候选(git 主仓库根)受信任即可"
|
||||
);
|
||||
assert!(!codex_config_trusts(
|
||||
"",
|
||||
&[PathBuf::from(r"E:\code-project\enterprise-system")]
|
||||
));
|
||||
}
|
||||
|
||||
/// git 主仓库根:普通仓库为仓库根,worktree 为主仓库根(继承信任),仓库子目录为所在仓库根。
|
||||
#[test]
|
||||
fn git_main_worktree_root_resolves_worktree_and_subdir() {
|
||||
let base = std::env::temp_dir().join(format!("lj_p8c_gitroot_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let main = base.join("main");
|
||||
std::fs::create_dir_all(main.join("sub")).unwrap();
|
||||
let git = |dir: &std::path::Path, args: &[&str]| {
|
||||
let out = std::process::Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
])
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git 可执行");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?}: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
};
|
||||
git(&main, &["init", "-q"]);
|
||||
std::fs::write(main.join("sub").join("a.txt"), "a\n").unwrap();
|
||||
git(&main, &["add", "-A"]);
|
||||
git(&main, &["commit", "-q", "--no-verify", "-m", "init"]);
|
||||
git(&main, &["worktree", "add", "-q", "../wt", "-b", "wt"]);
|
||||
|
||||
let canonical = |path: PathBuf| std::fs::canonicalize(path).unwrap();
|
||||
let expected = canonical(main.clone());
|
||||
assert_eq!(canonical(git_main_worktree_root(&main).unwrap()), expected);
|
||||
assert_eq!(
|
||||
canonical(git_main_worktree_root(&base.join("wt")).unwrap()),
|
||||
expected,
|
||||
"worktree 取主仓库根"
|
||||
);
|
||||
assert_eq!(
|
||||
canonical(git_main_worktree_root(&main.join("sub")).unwrap()),
|
||||
expected,
|
||||
"仓库子目录取所在仓库根"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_step_tells_missing_from_needs_repair() {
|
||||
use crate::credential_store::McpAuthTokenState;
|
||||
use crate::onboard::plan::{machine_step_blocker, BlockReason};
|
||||
let present = token_step(McpAuthTokenState::Present);
|
||||
assert_eq!(
|
||||
(present.step.as_str(), present.status.as_str()),
|
||||
("token", "noop")
|
||||
);
|
||||
assert!(machine_step_blocker(&present).is_none());
|
||||
let missing = token_step(McpAuthTokenState::Missing);
|
||||
assert_eq!(missing.status, "apply", "只有未配置才会生成");
|
||||
assert!(machine_step_blocker(&missing).is_none());
|
||||
let repair = token_step(McpAuthTokenState::NeedsRepair(
|
||||
"MCP 认证材料 needs_repair".into(),
|
||||
));
|
||||
assert_eq!(repair.status, "needs_human");
|
||||
assert!(repair.detail.contains("needs_repair") && repair.detail.contains("不会重新生成"));
|
||||
let blocked = machine_step_blocker(&repair).expect("needs_repair 阻断确认");
|
||||
assert_eq!(
|
||||
(blocked.target.as_str(), blocked.reason),
|
||||
("token", BlockReason::NeedsHuman)
|
||||
);
|
||||
assert!(!codex_config_trusts(config, "C:\\new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -4,6 +4,11 @@
|
||||
//! 为什么:一键接入改写的是子项目受版本管理的文件,人在确认前必须看得到改什么;
|
||||
//! apply 时带上确认过的 digest,计划被改(文件变了、本机状态变了)就拒绝写入。
|
||||
//! 计划与 apply 走同一段分发代码(只换写入汇),所以「看到的」就是「将写的」。
|
||||
//!
|
||||
//! digest 的准确语义:绑定计划**会写入或核对过**的每个文件的(路径、动作、写入后内容哈希,update 另加
|
||||
//! 写入前内容哈希)、本机步骤与状态、阻断项。计划不会触碰的目标——已存在的只建不改文件(lefthook.yml、
|
||||
//! mise.toml)、语义相同的 JSON、托管块版本相同的文件——不列出、不进 digest:它们怎么变,apply 都不写。
|
||||
//! Codex 信任与 next_actions 不影响写入内容,也不进 digest。
|
||||
|
||||
use crate::WriteSink;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@ -11,6 +16,9 @@ use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// 项目 Codex 配置(文件级托管块与本机 codex 步的只读判定都来自它)。
|
||||
const CODEX_PROJECT_CONFIG: &str = ".codex/config.toml";
|
||||
|
||||
/// 记录汇:只记录不落盘;读取优先返回本汇里的待写内容。
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PlanSink {
|
||||
@ -19,6 +27,10 @@ pub(crate) struct PlanSink {
|
||||
}
|
||||
|
||||
impl WriteSink for PlanSink {
|
||||
fn exists(&self, path: &Path) -> bool {
|
||||
self.pending.borrow().contains_key(path) || path.exists()
|
||||
}
|
||||
|
||||
fn read(&self, path: &Path) -> std::io::Result<Option<String>> {
|
||||
if let Some(content) = self.pending.borrow().get(path) {
|
||||
return Ok(Some(content.clone()));
|
||||
@ -79,9 +91,13 @@ pub struct PlannedFile {
|
||||
pub warning: Option<String>,
|
||||
/// 计划写入内容的 sha256(noop 为当前内容的 sha256),进入 digest
|
||||
pub content_sha256: String,
|
||||
/// update 时写入前(当前磁盘)内容的 sha256,进入 digest:人确认之后、apply 之前文件又被改过,
|
||||
/// 即使写入后内容不变也会因 digest 不符被拒(否则会覆盖掉人没在 diff 里见过的改动)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub before_sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BlockReason {
|
||||
/// 托管块文件块外有未提交改动,本次又要改写托管块
|
||||
@ -103,8 +119,9 @@ pub struct BlockedItem {
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// 本机侧(不进仓库)的预计动作:token、稳定入口、三端客户端配置、Codex 信任等。
|
||||
/// status:noop(已就绪)| apply(apply 时会写)| not_installed | not_applicable | needs_human | unavailable。
|
||||
/// 本机侧(不进仓库)的预计动作:token、稳定入口、三端客户端配置。
|
||||
/// status:noop(已就绪)| apply(apply 时会写)| not_installed | not_applicable | unavailable
|
||||
/// | manual_kept | other_project | needs_human(后三者阻断确认,见 machine_step_blocker)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MachineStep {
|
||||
pub step: String,
|
||||
@ -112,13 +129,34 @@ pub struct MachineStep {
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// Codex 是否信任项目目录(设计 §4 第 1 步单列、决策 Q5)。只决定 Codex 是否加载项目 .codex/config.toml,
|
||||
/// 不影响炼境写入什么:不阻断、不进 digest;未信任或无法判定时列入 next_actions,由人在 Codex 里信任。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CodexTrust {
|
||||
Trusted,
|
||||
Untrusted,
|
||||
NotInstalled,
|
||||
/// Codex 用户配置读不出
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// 机器只列出、由人在别处完成的事项(设计 §4「人确认 C4」);不阻断、不进 digest。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NextAction {
|
||||
pub action: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OnboardingPlan {
|
||||
pub project_id: String,
|
||||
pub files: Vec<PlannedFile>,
|
||||
pub machine_changes: Vec<MachineStep>,
|
||||
pub blocked: Vec<BlockedItem>,
|
||||
/// 覆盖项目文件新内容哈希、本机动作与阻断项;apply 须带回同一 digest
|
||||
pub codex_trust: CodexTrust,
|
||||
pub next_actions: Vec<NextAction>,
|
||||
/// 语义见模块文档;apply 须带回同一 digest
|
||||
pub digest: String,
|
||||
}
|
||||
|
||||
@ -130,13 +168,19 @@ pub struct ProjectWrite<'a> {
|
||||
pub header_helper: &'a str,
|
||||
}
|
||||
|
||||
/// 一键接入的项目文件写入序列(接入包模板 → 蓝图规则 → 项目身份与 MCP 投影),plan 与 apply 共用。
|
||||
/// 返回分发过程中的失败与需人工项(不中断其余写入)。
|
||||
/// 一键接入的项目文件写入序列(接入包模板 → 蓝图规则 → 项目身份与兼容投影 → Codex 托管块),
|
||||
/// plan 与 apply 共用。返回分发过程中的失败与需人工项(不中断其余写入),target 为真正出错的文件。
|
||||
/// 身份与兼容投影、Codex 托管块与 inject_project_mcp 调的是同一组函数(写入序列只有一份)。
|
||||
pub(crate) fn write_project_files(
|
||||
root: &Path,
|
||||
project: &ProjectWrite,
|
||||
sink: &dyn WriteSink,
|
||||
) -> Vec<BlockedItem> {
|
||||
let needs_human = |failure: crate::WriteFailure| BlockedItem {
|
||||
target: relative_path(root, &failure.path),
|
||||
reason: BlockReason::NeedsHuman,
|
||||
detail: failure.reason,
|
||||
};
|
||||
let mut issues = Vec::new();
|
||||
for result in crate::commands::onboarding::dispatch_templates_with(root, sink) {
|
||||
if result.action == "failed" {
|
||||
@ -147,29 +191,32 @@ pub(crate) fn write_project_files(
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Err(error) = crate::commands::blueprint::sync_blueprint_rules_with(root, sink) {
|
||||
issues.push(BlockedItem {
|
||||
target: ".blueprint/CONVENTIONS.md".into(),
|
||||
reason: BlockReason::NeedsHuman,
|
||||
detail: error,
|
||||
});
|
||||
if let Err(failure) = crate::commands::blueprint::sync_blueprint_rules_with(root, sink) {
|
||||
issues.push(needs_human(failure));
|
||||
}
|
||||
use super::clients::ClientOutcome;
|
||||
match crate::mcp_inject::write_project_mcp_files_with(
|
||||
if let Err(failure) = crate::mcp_inject::write_project_identity_files_with(
|
||||
root,
|
||||
project.project_id,
|
||||
project.port,
|
||||
project.server_url,
|
||||
sink,
|
||||
) {
|
||||
issues.push(needs_human(failure));
|
||||
}
|
||||
use super::clients::ClientOutcome;
|
||||
match crate::mcp_inject::upsert_codex_mcp_config_with(
|
||||
&root.join(CODEX_PROJECT_CONFIG),
|
||||
project.server_url,
|
||||
project.header_helper,
|
||||
sink,
|
||||
) {
|
||||
Ok(ClientOutcome::ManualKept) => issues.push(BlockedItem {
|
||||
target: ".codex/config.toml".into(),
|
||||
target: CODEX_PROJECT_CONFIG.into(),
|
||||
reason: BlockReason::ManualEntry,
|
||||
detail: "已手工定义 [mcp_servers.lian-jing],炼境不会覆盖".into(),
|
||||
}),
|
||||
Ok(ClientOutcome::Failed { reason }) | Err(reason) => issues.push(BlockedItem {
|
||||
target: ".lian-jing/context.json 等项目 MCP 文件".into(),
|
||||
Err(reason) => issues.push(BlockedItem {
|
||||
target: CODEX_PROJECT_CONFIG.into(),
|
||||
reason: BlockReason::NeedsHuman,
|
||||
detail: reason,
|
||||
}),
|
||||
@ -178,12 +225,47 @@ pub(crate) fn write_project_files(
|
||||
issues
|
||||
}
|
||||
|
||||
/// 只读:演算一键接入会对项目文件做的改动,叠加调用方给出的本机预计动作,生成计划与 digest。
|
||||
/// 本机步骤 → 阻断项(纯函数):手写同名条目、共享项目键与需人工处理的状态阻断确认;
|
||||
/// noop / apply / not_installed / not_applicable / unavailable 只列出。
|
||||
/// codex 步的只读判定完全来自项目 .codex/config.toml,阻断项指向该文件,与文件级同因阻断项合并。
|
||||
pub(crate) fn machine_step_blocker(step: &MachineStep) -> Option<BlockedItem> {
|
||||
let reason = match step.status.as_str() {
|
||||
"manual_kept" => BlockReason::ManualEntry,
|
||||
"other_project" => BlockReason::SharedKey,
|
||||
"needs_human" => BlockReason::NeedsHuman,
|
||||
_ => return None,
|
||||
};
|
||||
Some(BlockedItem {
|
||||
target: if step.step == "codex" {
|
||||
CODEX_PROJECT_CONFIG.into()
|
||||
} else {
|
||||
step.step.clone()
|
||||
},
|
||||
reason,
|
||||
detail: step.detail.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Codex 信任 → next_actions(纯函数):未信任或无法判定时提示人在 Codex 里信任该目录。
|
||||
fn codex_trust_next_action(trust: CodexTrust) -> Option<NextAction> {
|
||||
let detail = match trust {
|
||||
CodexTrust::Untrusted => "Codex 只在目录受信任后才加载项目 .codex/config.toml:请在 Codex 里信任该目录(炼境不改 Codex 信任表)",
|
||||
CodexTrust::Unknown => "读不出 Codex 用户配置,无法判断该目录是否受信任:请在 Codex 里确认已信任该目录(未信任时不加载项目 .codex/config.toml)",
|
||||
CodexTrust::Trusted | CodexTrust::NotInstalled => return None,
|
||||
};
|
||||
Some(NextAction {
|
||||
action: "codex_trust".into(),
|
||||
detail: detail.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 只读:演算一键接入会对项目文件做的改动,叠加调用方给出的本机预计动作与 Codex 信任,生成计划与 digest。
|
||||
/// 不写任何文件(目录快照前后字节一致)。
|
||||
pub(crate) fn plan_onboarding(
|
||||
root: &Path,
|
||||
project: &ProjectWrite,
|
||||
machine_changes: Vec<MachineStep>,
|
||||
codex_trust: CodexTrust,
|
||||
) -> OnboardingPlan {
|
||||
let sink = PlanSink::default();
|
||||
let mut blocked = identity_conflict(root, project.project_id)
|
||||
@ -196,18 +278,25 @@ pub(crate) fn plan_onboarding(
|
||||
for path in sink.touched.borrow().iter() {
|
||||
let relative = relative_path(root, path);
|
||||
let disk = read_disk(path).ok().flatten();
|
||||
let (action, content, diff) = match (pending.get(path), &disk) {
|
||||
let (action, content, diff, before_sha256) = match (pending.get(path), &disk) {
|
||||
(Some(new), None) => (
|
||||
FileAction::Create,
|
||||
new.clone(),
|
||||
Some(format!("新建,{} 行", new.lines().count())),
|
||||
None,
|
||||
),
|
||||
(Some(new), Some(old)) => (
|
||||
FileAction::Update,
|
||||
new.clone(),
|
||||
Some(bounded_diff(old, new, DIFF_LINE_LIMIT)),
|
||||
Some(sha256_hex(&old.replace("\r\n", "\n"))),
|
||||
),
|
||||
(None, current) => (
|
||||
FileAction::Noop,
|
||||
current.clone().unwrap_or_default(),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(None, current) => (FileAction::Noop, current.clone().unwrap_or_default(), None),
|
||||
};
|
||||
let mut warning = None;
|
||||
if action == FileAction::Update
|
||||
@ -232,28 +321,21 @@ pub(crate) fn plan_onboarding(
|
||||
diff,
|
||||
warning,
|
||||
content_sha256: sha256_hex(&content.replace("\r\n", "\n")),
|
||||
before_sha256,
|
||||
});
|
||||
}
|
||||
for step in &machine_changes {
|
||||
if let Some(reason) = match step.status.as_str() {
|
||||
"manual_kept" => Some(BlockReason::ManualEntry),
|
||||
"other_project" => Some(BlockReason::SharedKey),
|
||||
"needs_human" => Some(BlockReason::NeedsHuman),
|
||||
_ => None,
|
||||
} {
|
||||
blocked.push(BlockedItem {
|
||||
target: step.step.clone(),
|
||||
reason,
|
||||
detail: step.detail.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
blocked.extend(machine_changes.iter().filter_map(machine_step_blocker));
|
||||
// 同一文件同一原因只留一条(文件级在前,详情更具体):人在确认界面逐项选「跳过 / 中止」
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
blocked.retain(|item| seen.insert((item.target.clone(), item.reason)));
|
||||
let digest = plan_digest(project.project_id, &files, &machine_changes, &blocked);
|
||||
OnboardingPlan {
|
||||
project_id: project.project_id.to_string(),
|
||||
files,
|
||||
machine_changes,
|
||||
blocked,
|
||||
codex_trust,
|
||||
next_actions: codex_trust_next_action(codex_trust).into_iter().collect(),
|
||||
digest,
|
||||
}
|
||||
}
|
||||
@ -288,7 +370,8 @@ fn sha256_hex(text: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// digest 覆盖项目 ID、逐文件(路径、动作、内容哈希)、本机动作与阻断项。
|
||||
/// digest 覆盖项目 ID、逐文件(路径、动作、写入前与写入后内容哈希)、本机动作与阻断项;
|
||||
/// Codex 信任与 next_actions 不影响写入内容,不进 digest(用户去 Codex 信任目录后不必重新确认)。
|
||||
fn plan_digest(
|
||||
project_id: &str,
|
||||
files: &[PlannedFile],
|
||||
@ -297,7 +380,7 @@ fn plan_digest(
|
||||
) -> String {
|
||||
let canonical = serde_json::json!({
|
||||
"project_id": project_id,
|
||||
"files": files.iter().map(|f| (f.path.as_str(), f.action, f.content_sha256.as_str())).collect::<Vec<_>>(),
|
||||
"files": files.iter().map(|f| (f.path.as_str(), f.action, f.before_sha256.as_deref(), f.content_sha256.as_str())).collect::<Vec<_>>(),
|
||||
"machine": machine.iter().map(|m| (m.step.as_str(), m.status.as_str())).collect::<Vec<_>>(),
|
||||
"blocked": blocked.iter().map(|b| (b.target.as_str(), b.reason)).collect::<Vec<_>>(),
|
||||
});
|
||||
@ -399,6 +482,18 @@ mod tests {
|
||||
}]
|
||||
}
|
||||
|
||||
/// 本机只有 claude 一步、Codex 已信任时的计划。
|
||||
fn plan_for(root: &Path, status: &str) -> OnboardingPlan {
|
||||
plan_onboarding(root, &PROJECT, machine(status), CodexTrust::Trusted)
|
||||
}
|
||||
|
||||
fn blocked_pairs(plan: &OnboardingPlan) -> Vec<(String, BlockReason)> {
|
||||
plan.blocked
|
||||
.iter()
|
||||
.map(|item| (item.target.clone(), item.reason))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn temp_repo(name: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("lj_p8c_{name}_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
@ -414,7 +509,7 @@ mod tests {
|
||||
fn plan_on_empty_repo_creates_everything_and_writes_nothing() {
|
||||
let root = temp_repo("empty");
|
||||
let before = snapshot(&root);
|
||||
let plan = plan_onboarding(&root, &PROJECT, machine("apply"));
|
||||
let plan = plan_for(&root, "apply");
|
||||
assert_eq!(snapshot(&root), before, "plan 不得写任何文件");
|
||||
assert!(plan.files.len() >= 12, "实际 {} 个文件", plan.files.len());
|
||||
assert!(
|
||||
@ -448,7 +543,7 @@ mod tests {
|
||||
#[test]
|
||||
fn plan_predicts_exactly_what_apply_writes() {
|
||||
let root = temp_repo("predict");
|
||||
let plan = plan_onboarding(&root, &PROJECT, machine("apply"));
|
||||
let plan = plan_for(&root, "apply");
|
||||
assert!(write_project_files(&root, &PROJECT, &crate::DiskSink).is_empty());
|
||||
for file in &plan.files {
|
||||
let written = std::fs::read_to_string(root.join(&file.path)).unwrap();
|
||||
@ -483,8 +578,8 @@ mod tests {
|
||||
git(&root, &["add", "-A"]);
|
||||
git(&root, &["commit", "-q", "--no-verify", "-m", "onboard"]);
|
||||
|
||||
let first = plan_onboarding(&root, &PROJECT, machine("noop"));
|
||||
let second = plan_onboarding(&root, &PROJECT, machine("noop"));
|
||||
let first = plan_for(&root, "noop");
|
||||
let second = plan_for(&root, "noop");
|
||||
assert!(
|
||||
first.files.iter().all(|f| f.action == FileAction::Noop),
|
||||
"{:?}",
|
||||
@ -493,10 +588,7 @@ mod tests {
|
||||
assert_eq!(first.digest, second.digest, "同一状态两次计划 digest 相同");
|
||||
|
||||
// 本机预计动作变化 → digest 变化
|
||||
assert_ne!(
|
||||
plan_onboarding(&root, &PROJECT, machine("apply")).digest,
|
||||
first.digest
|
||||
);
|
||||
assert_ne!(plan_for(&root, "apply").digest, first.digest);
|
||||
|
||||
// CRLF 检出的同内容文件仍是 noop
|
||||
let packs = root.join(".lian-jing/capability-packs.json");
|
||||
@ -505,7 +597,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.replace("\r\n", "\n");
|
||||
std::fs::write(&packs, lf.replace('\n', "\r\n")).unwrap();
|
||||
let crlf = plan_onboarding(&root, &PROJECT, machine("noop"));
|
||||
let crlf = plan_for(&root, "noop");
|
||||
let not_noop: Vec<_> = crlf
|
||||
.files
|
||||
.iter()
|
||||
@ -514,17 +606,200 @@ mod tests {
|
||||
.collect();
|
||||
assert!(not_noop.is_empty(), "{not_noop:?}");
|
||||
|
||||
// 任一目标文件内容变化 → 该文件 update、digest 变化
|
||||
// 计划会写入或核对的文件内容变化 → 该文件 update、digest 变化
|
||||
std::fs::write(&packs, "{}\n").unwrap();
|
||||
let changed = plan_onboarding(&root, &PROJECT, machine("noop"));
|
||||
let entry = changed
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.path == ".lian-jing/capability-packs.json")
|
||||
.unwrap();
|
||||
let changed = plan_for(&root, "noop");
|
||||
let find_packs = |plan: &OnboardingPlan| {
|
||||
plan.files
|
||||
.iter()
|
||||
.find(|f| f.path == ".lian-jing/capability-packs.json")
|
||||
.cloned()
|
||||
.unwrap()
|
||||
};
|
||||
let entry = find_packs(&changed);
|
||||
assert_eq!(entry.action, FileAction::Update);
|
||||
assert!(entry.diff.as_deref().unwrap().contains("-{}"));
|
||||
assert_eq!(
|
||||
entry.before_sha256.as_deref(),
|
||||
Some(sha256_hex("{}\n").as_str())
|
||||
);
|
||||
assert_ne!(changed.digest, first.digest);
|
||||
|
||||
// 写入前内容进 digest:确认后文件又被改、写入后内容不变 → digest 仍变(apply 会拒绝)
|
||||
std::fs::write(&packs, "{\"edited\":true}\n").unwrap();
|
||||
let edited = plan_for(&root, "noop");
|
||||
let edited_entry = find_packs(&edited);
|
||||
assert_eq!(edited_entry.action, FileAction::Update);
|
||||
assert_eq!(
|
||||
edited_entry.content_sha256, entry.content_sha256,
|
||||
"写入后内容相同"
|
||||
);
|
||||
assert_ne!(
|
||||
edited.digest, changed.digest,
|
||||
"写入前内容不同 → digest 不同"
|
||||
);
|
||||
|
||||
// 计划不会触碰的目标(已存在的只建不改文件)不列出、不进 digest:怎么改 apply 都不写
|
||||
std::fs::write(root.join("lefthook.yml"), "# 项目自己的 hook\n").unwrap();
|
||||
let untouched = plan_for(&root, "noop");
|
||||
assert!(untouched.files.iter().all(|f| f.path != "lefthook.yml"));
|
||||
assert_eq!(untouched.digest, edited.digest);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Codex 未信任是 next_action,不是阻断项(设计 §4 blocked 只有四类,Q5 / C4):
|
||||
/// 新项目几乎都未信任,若阻断则一键接入对每个新项目都走不通;信任与否也不改变 digest。
|
||||
#[test]
|
||||
fn codex_trust_is_next_action_not_blocker_and_outside_digest() {
|
||||
let root = temp_repo("trust");
|
||||
let trusted = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Trusted);
|
||||
let untrusted = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Untrusted);
|
||||
assert!(untrusted.blocked.is_empty(), "{:?}", untrusted.blocked);
|
||||
assert_eq!(untrusted.codex_trust, CodexTrust::Untrusted);
|
||||
assert_eq!(
|
||||
untrusted
|
||||
.next_actions
|
||||
.iter()
|
||||
.map(|a| a.action.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["codex_trust"]
|
||||
);
|
||||
assert!(trusted.next_actions.is_empty());
|
||||
assert_eq!(
|
||||
trusted.digest, untrusted.digest,
|
||||
"用户去 Codex 信任目录后无需重新确认"
|
||||
);
|
||||
let unknown = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Unknown);
|
||||
assert!(unknown.blocked.is_empty() && unknown.next_actions.len() == 1);
|
||||
let absent = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::NotInstalled);
|
||||
assert!(absent.next_actions.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_step_blocker_maps_statuses() {
|
||||
let step = |name: &str, status: &str| MachineStep {
|
||||
step: name.into(),
|
||||
status: status.into(),
|
||||
detail: format!("{name}:{status}"),
|
||||
};
|
||||
for status in [
|
||||
"noop",
|
||||
"apply",
|
||||
"not_installed",
|
||||
"not_applicable",
|
||||
"unavailable",
|
||||
] {
|
||||
assert!(
|
||||
machine_step_blocker(&step("claude", status)).is_none(),
|
||||
"{status}"
|
||||
);
|
||||
}
|
||||
let pair = |name: &str, status: &str| {
|
||||
machine_step_blocker(&step(name, status)).map(|b| (b.target, b.reason))
|
||||
};
|
||||
assert_eq!(
|
||||
pair("claude", "manual_kept"),
|
||||
Some(("claude".into(), BlockReason::ManualEntry))
|
||||
);
|
||||
assert_eq!(
|
||||
pair("claude", "other_project"),
|
||||
Some(("claude".into(), BlockReason::SharedKey))
|
||||
);
|
||||
assert_eq!(
|
||||
pair("token", "needs_human"),
|
||||
Some(("token".into(), BlockReason::NeedsHuman))
|
||||
);
|
||||
assert_eq!(
|
||||
pair("codex", "manual_kept"),
|
||||
Some((CODEX_PROJECT_CONFIG.into(), BlockReason::ManualEntry)),
|
||||
"codex 步的判定来自项目 .codex/config.toml,阻断项指向该文件"
|
||||
);
|
||||
}
|
||||
|
||||
/// 回归(P8c 审查):「存在即跳过」只判存在。项目自有的非 UTF-8 文件(PowerShell 5 重定向生成的
|
||||
/// UTF-16LE lefthook.yml)照旧 skipped,不变成 failed、不阻断计划;全托管文件读不出则 failed(不盲目覆盖)。
|
||||
#[test]
|
||||
fn existing_non_utf8_files_skip_or_fail_by_ownership() {
|
||||
let root = temp_repo("utf16");
|
||||
let utf16: Vec<u8> = [0xFF, 0xFE]
|
||||
.into_iter()
|
||||
.chain(
|
||||
"pre-commit:\r\n commands: {}\r\n"
|
||||
.encode_utf16()
|
||||
.flat_map(u16::to_le_bytes),
|
||||
)
|
||||
.collect();
|
||||
std::fs::write(root.join("lefthook.yml"), &utf16).unwrap();
|
||||
let results =
|
||||
crate::commands::onboarding::dispatch_templates_with(&root, &PlanSink::default());
|
||||
let lefthook = results.iter().find(|r| r.name == "lefthook.yml").unwrap();
|
||||
assert_eq!(lefthook.action, "skipped", "{}", lefthook.detail);
|
||||
let plan = plan_for(&root, "apply");
|
||||
assert!(plan.blocked.is_empty(), "{:?}", plan.blocked);
|
||||
assert!(plan.files.iter().all(|f| f.path != "lefthook.yml"));
|
||||
|
||||
let envoy = root.join(".claude/commands/envoy.md");
|
||||
std::fs::create_dir_all(envoy.parent().unwrap()).unwrap();
|
||||
std::fs::write(&envoy, &utf16).unwrap();
|
||||
let plan = plan_for(&root, "apply");
|
||||
assert_eq!(
|
||||
blocked_pairs(&plan),
|
||||
[(
|
||||
".claude/commands/envoy.md".to_string(),
|
||||
BlockReason::NeedsHuman
|
||||
)]
|
||||
);
|
||||
|
||||
// 真实写入同样:lefthook.yml 原样保留,envoy.md 不被覆盖
|
||||
let results = crate::commands::onboarding::dispatch_templates_with(&root, &crate::DiskSink);
|
||||
let action = |name: &str| {
|
||||
results
|
||||
.iter()
|
||||
.find(|r| r.name == name)
|
||||
.map(|r| r.action.clone())
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(action("lefthook.yml"), "skipped");
|
||||
assert_eq!(action(".claude/commands/envoy.md"), "failed");
|
||||
assert_eq!(std::fs::read(root.join("lefthook.yml")).unwrap(), utf16);
|
||||
assert_eq!(std::fs::read(&envoy).unwrap(), utf16);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// 阻断项指向真正出错的文件,同一文件同一原因只留一条(P8c 审查)。
|
||||
#[test]
|
||||
fn blocked_targets_point_at_failing_files_without_duplicates() {
|
||||
let root = temp_repo("targets");
|
||||
// AGENTS.md 读不出:模板分发与蓝图规则同步各报一次,合并为一条
|
||||
std::fs::write(root.join("AGENTS.md"), [0xFF, 0xFE, 0x00, 0xD8]).unwrap();
|
||||
// settings.json 不是合法 JSON:身份与兼容投影在此失败
|
||||
std::fs::create_dir_all(root.join(".claude")).unwrap();
|
||||
std::fs::write(root.join(".claude/settings.json"), "{ not json").unwrap();
|
||||
// Codex 托管块标记错序:文件级与本机 codex 步各报一次,合并为一条
|
||||
std::fs::create_dir_all(root.join(".codex")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".codex/config.toml"),
|
||||
"# LIANJING_MCP_MANAGED_END\n# LIANJING_MCP_MANAGED_START\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut steps = machine("noop");
|
||||
steps.push(MachineStep {
|
||||
step: "codex".into(),
|
||||
status: "needs_human".into(),
|
||||
detail: "炼境 Codex 管理块不完整或顺序错误".into(),
|
||||
});
|
||||
let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted);
|
||||
let mut pairs = blocked_pairs(&plan);
|
||||
pairs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
assert_eq!(
|
||||
pairs,
|
||||
[
|
||||
(".claude/settings.json".to_string(), BlockReason::NeedsHuman),
|
||||
(CODEX_PROJECT_CONFIG.to_string(), BlockReason::NeedsHuman),
|
||||
("AGENTS.md".to_string(), BlockReason::NeedsHuman),
|
||||
]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@ -551,7 +826,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let plan = plan_onboarding(&root, &PROJECT, machine("noop"));
|
||||
let plan = plan_for(&root, "noop");
|
||||
let agents_entry = plan.files.iter().find(|f| f.path == "AGENTS.md").unwrap();
|
||||
assert_eq!(agents_entry.action, FileAction::Update);
|
||||
assert!(agents_entry.warning.is_some(), "混合文件须带预警");
|
||||
@ -574,11 +849,22 @@ mod tests {
|
||||
"[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let plan = plan_onboarding(&root, &PROJECT, machine("other_project"));
|
||||
assert!(plan
|
||||
.blocked
|
||||
.iter()
|
||||
.any(|b| b.reason == BlockReason::ManualEntry && b.target == ".codex/config.toml"));
|
||||
let mut steps = machine("other_project");
|
||||
// 本机 codex 步的只读判定同样来自这份文件:与文件级 manual_entry 同因,只留一条
|
||||
steps.push(MachineStep {
|
||||
step: "codex".into(),
|
||||
status: "manual_kept".into(),
|
||||
detail: "手写条目".into(),
|
||||
});
|
||||
let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted);
|
||||
assert_eq!(
|
||||
plan.blocked
|
||||
.iter()
|
||||
.filter(|b| b.target == ".codex/config.toml")
|
||||
.map(|b| b.reason)
|
||||
.collect::<Vec<_>>(),
|
||||
[BlockReason::ManualEntry]
|
||||
);
|
||||
assert!(plan
|
||||
.blocked
|
||||
.iter()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user