Compare commits

..

No commits in common. "019d81c60e1589862cf123c8e8a0548abcce827b" and "c4241f075d09fb8184ca15d8f53ecda3d76fcd1c" have entirely different histories.

7 changed files with 29 additions and 187 deletions

View File

@ -156,21 +156,3 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
- acceptance: "agent_verify: [cargo test] 托管块同时含 mcp-lian-jing 与 lianjing-session-mcp 两行、插件路径为绝对路径、roots 与 normalize_dsh_root 一致;[node --test] 假 ctx 下按会话 cwd 命中项目才挂载、null 哨兵与未命中不挂、shim 失败/超时不抛错且无未处理 rejection;[DSH 0.2 与 0.1 运行时] 临时 DSH_HOME + stub MCP 服务,ACP session/new 两个不同 cwd 的会话只有项目会话产生 /mcp/projects/<id> 请求。human_verify: DSH GUI 在 enterprise 工作区新建会话能看到开发变量工具,在其他工作区看不到。" - acceptance: "agent_verify: [cargo test] 托管块同时含 mcp-lian-jing 与 lianjing-session-mcp 两行、插件路径为绝对路径、roots 与 normalize_dsh_root 一致;[node --test] 假 ctx 下按会话 cwd 命中项目才挂载、null 哨兵与未命中不挂、shim 失败/超时不抛错且无未处理 rejection;[DSH 0.2 与 0.1 运行时] 临时 DSH_HOME + stub MCP 服务,ACP session/new 两个不同 cwd 的会话只有项目会话产生 /mcp/projects/<id> 请求。human_verify: DSH GUI 在 enterprise 工作区新建会话能看到开发变量工具,在其他工作区看不到。"
- 为什么:2026-10-01 人终验确认 DSH 桌面版会话只连全局地址。只读调研(两路独立 + 对抗复核)结论:DSH 桌面版只有一个 Host 进程,启动目录固定为 profile 目录,!!js 只在启动时求值、拿不到会话信息,mcp-client 的地址在实例化时固定,也不读任何项目级 MCP 配置,所以只改托管块做不到。可行路径是 DSH 自带的 Agent 作用域:每个 GUI 会话的 session.header.cwd 即工作区路径,会话创建时串行发出 agent/created;在 agent.ctx 上挂 dsh-mcp-client,工具只对该会话可见并覆盖同名全局工具(DSH 内置的 ACP 就这样按会话挂 MCP)。 - 为什么:2026-10-01 人终验确认 DSH 桌面版会话只连全局地址。只读调研(两路独立 + 对抗复核)结论:DSH 桌面版只有一个 Host 进程,启动目录固定为 profile 目录,!!js 只在启动时求值、拿不到会话信息,mcp-client 的地址在实例化时固定,也不读任何项目级 MCP 配置,所以只改托管块做不到。可行路径是 DSH 自带的 Agent 作用域:每个 GUI 会话的 session.header.cwd 即工作区路径,会话创建时串行发出 agent/created;在 agent.ctx 上挂 dsh-mcp-client,工具只对该会话可见并覆盖同名全局工具(DSH 内置的 ACP 就这样按会话挂 MCP)。
- 风险(实施前需人确认是否值得):依赖 DSH 内部接口(agent/created、agent.ctx.plugin、ctx.loader.import、按 scope 分账),DSH 升级可能失效,须特性探测并降级为全局;Host 有 installFailLoud,插件任何未处理异常都会打崩桌面 Host;agent/created 是串行事件,挂载须加短超时,炼境离线时静默降级;Node 按 URL 缓存 ESM,插件升级须换文件名;子代理看不到父会话作用域的工具,是否给子代理单独挂载需人定;属于往 DSH 进程注入代码,信任边界与现有 !!js + execFileSync 相同,但需按「管家准入」论证。 - 风险(实施前需人确认是否值得):依赖 DSH 内部接口(agent/created、agent.ctx.plugin、ctx.loader.import、按 scope 分账),DSH 升级可能失效,须特性探测并降级为全局;Host 有 installFailLoud,插件任何未处理异常都会打崩桌面 Host;agent/created 是串行事件,挂载须加短超时,炼境离线时静默降级;Node 按 URL 缓存 ESM,插件升级须换文件名;子代理看不到父会话作用域的工具,是否给子代理单独挂载需人定;属于往 DSH 进程注入代码,信任边界与现有 !!js + execFileSync 相同,但需按「管家准入」论证。
- 可行性实测(2026-10-02,用户确认「先实测再实现」):临时 DSH_HOME + 记录请求的桩 MCP 服务 + 原型插件(%TEMP%\lj-p9-spike),经 ACP 在同一进程内新建两个不同 cwd 的会话。DSH 0.2.0-rc.2(桌面版内置)与 0.1.5-rc.2(enterprise 本地)结果一致:插件按绝对路径从 home patch 加载;ctx.loader.import('@deepseek-ai/dsh-mcp-client') 成功;每个会话触发 agent/created 且带 session.header.cwd;项目内会话挂载后桩服务收到 /mcp/projects/<id> 的 initialize 与 tools/list(带认证头),另一会话无任何请求。0.1.x 没有 profileContext,事件里也没有 source 字段。未实测:挂载工具对该会话模型的可见性、桌面 GUI 热加载(只有代码阅读结论)。实现需在 PR #24 合并后从 master 开分支。
### ✅ P3b — 接入写入幂等:同内容不重写、Codex 托管块不累积空行
- status: done
- complexity: S
- files: src-tauri/src/lib.rs, src-tauri/src/mcp_inject.rs, src-tauri/src/commands/blueprint.rs, src-tauri/src/commands/cicd.rs, src-tauri/src/commands/agent_infra.rs, src-tauri/src/mcp_tools.rs
- acceptance: "agent_verify: [cargo test] 连续 upsert Codex 托管块两次字节不变,已累积的空行被吸收,块外后续内容保留;CRLF 检出的同内容 AGENTS/CLAUDE 托管块返回 unchanged 且不被 LF 重写;语义相同但键序/行尾/末尾换行不同的 settings.json 不被重写;CRLF 检出的同内容 CI workflow 的 plan 为 noop;[MCP inject_mcp ×2] enterprise 的 .codex/config.toml、.claude/settings.json、.lian-jing/context.json、.claude/liangjing.json 第二次注入修改时间不变。"
- 为什么:2026-10-01 enterprise 未提交改动归属梳理发现两处炼境侧缺陷。① Codex 托管块自带结尾换行,又原样保留 END 之后的换行,每注入一次多一个空行(enterprise 已累积 16 行)。② sync_blueprint_rules 与注入在内容不变时也用 LF 重写文件:子项目开 core.autocrlf,CRLF 检出的同内容文件因大小变化被 git status 误报为改动(CONVENTIONS.md、context.json),修改时间也被刷新,干扰了「这次改动是谁做的」的判断。
- verification: "2026-10-02:新增 lib.rs write_if_content_changed(忽略行尾比较,内容不变不写);sync_blueprint_rules 的 CONVENTIONS 与托管块、context.json、.claude/settings.json、Codex 托管块统一改用它,托管块未变时返回 unchanged。cargo test 246 passed。实测 enterprise:注入后 .codex/config.toml 末尾空行 16→0;再次注入四个文件修改时间均不变。已存在的伪改动(context.json、CONVENTIONS.md)需在子项目里由人刷新一次索引。
对抗审查(9 个 agent,2 条成立、5 条驳回)后补:JSON 写入(settings.json、context.json)改为按语义比较——Claude Code 改写后键序与末尾换行不同,按文本比较仍会整文件重排;CI workflow 的 plan 改为忽略行尾判 noop(原按字节比较,CRLF 检出的同内容文件会被判 update 并请用户确认一次无变化写入),apply 与 generate_workflow 落盘同样只在内容变化时写;同类遗漏 agent_infra 生成文件与 notes-archive 回填也统一改用 write_if_content_changed。cargo test 245 passed(新增 JSON 语义与 CI CRLF 回归断言)。"
### 📋 P10 — 接入包升级前检测目标文件的块外未提交改动
- status: todo
- complexity: S
- files: src-tauri/src/commands/onboarding.rs(apply 预览 / 结果)
- depends: P8
- acceptance: "agent_verify: [cargo test] 目标文件在 git 中有托管块之外的未提交改动时,apply_onboarding_pack 的预览与结果对该文件给出 warning(含文件名与「块外有未提交改动,升级后将成为混合文件」提示),默认仍只改托管块、不改块外内容;无未提交改动或非 git 仓库时无此提示。"
- 为什么:2026-10-01 enterprise 的 docs/ai-context/git-workflow.md 在有项目未提交改写的情况下被升级了托管块(1.6.0→1.7.0),产生「同一文件混有炼境与项目两种来源改动」的混合文件。按「禁止字符串手术」铁律它无法拆分提交,只能交人定案,verify_onboarding 也因此持续报版本不符。管家应在制造混合文件之前提示,而不是事后由人收拾。

View File

@ -489,17 +489,12 @@ fn write_file(path: &std::path::PathBuf, content: &str, rel: &str) -> FileResult
}; };
} }
} }
match crate::write_if_content_changed(path, content) { match std::fs::write(path, content) {
Ok(true) => FileResult { Ok(_) => FileResult {
path: rel.into(), path: rel.into(),
action: "written".into(), action: "written".into(),
detail: "已写入".into(), detail: "已写入".into(),
}, },
Ok(false) => FileResult {
path: rel.into(),
action: "skipped".into(),
detail: "内容未变,未重写".into(),
},
Err(e) => FileResult { Err(e) => FileResult {
path: rel.into(), path: rel.into(),
action: "failed".into(), action: "failed".into(),

View File

@ -1256,14 +1256,14 @@ pub fn get_blueprint_status(project_path: String) -> Result<BlueprintStatus, Str
pub struct SyncResult { pub struct SyncResult {
pub success: bool, pub success: bool,
pub conventions_written: bool, pub conventions_written: bool,
pub claude_md_action: String, // created | appended | updated | unchanged pub claude_md_action: String, // created | appended | updated | skipped
pub agents_md_action: String, // created | appended | updated | unchanged pub agents_md_action: String, // created | appended | updated
pub message: String, pub message: String,
} }
/// 在指定文件中写入/替换 BLUEPRINT_MANAGED 块: /// 在指定文件中写入/替换 BLUEPRINT_MANAGED 块:
/// 文件不存在 → 用 header + 模板创建;已有 MANAGED 区 → 原位替换(内容不变则不写);否则追加。 /// 文件不存在 → 用 header + 模板创建;已有 MANAGED 区 → 原位替换;否则追加。
/// 返回动作:created | updated | unchanged | appended /// 返回动作:created | updated | appended
fn upsert_managed_block( fn upsert_managed_block(
path: &Path, path: &Path,
template: &str, template: &str,
@ -1278,9 +1278,8 @@ fn upsert_managed_block(
let content = std::fs::read_to_string(path).map_err(|e| format!("读取 {label} 失败: {e}"))?; let content = std::fs::read_to_string(path).map_err(|e| format!("读取 {label} 失败: {e}"))?;
if content.contains("<!-- BLUEPRINT_MANAGED_START") { if content.contains("<!-- BLUEPRINT_MANAGED_START") {
let new_content = replace_managed_section(&content, template); let new_content = replace_managed_section(&content, template);
let written = crate::write_if_content_changed(path, &new_content) std::fs::write(path, new_content).map_err(|e| format!("更新 {label} 失败: {e}"))?;
.map_err(|e| format!("更新 {label} 失败: {e}"))?; Ok("updated".to_string())
Ok(if written { "updated" } else { "unchanged" }.to_string())
} else { } else {
let new_content = format!("{}\n\n{template}\n", content.trim_end()); let new_content = format!("{}\n\n{template}\n", content.trim_end());
std::fs::write(path, new_content).map_err(|e| format!("追加 {label} 失败: {e}"))?; std::fs::write(path, new_content).map_err(|e| format!("追加 {label} 失败: {e}"))?;
@ -1301,10 +1300,10 @@ pub fn sync_blueprint_rules(project_path: String) -> Result<SyncResult, String>
// 确保 .blueprint/ 存在 // 确保 .blueprint/ 存在
std::fs::create_dir_all(&bp_dir).map_err(|e| format!("创建 .blueprint/ 失败: {e}"))?; std::fs::create_dir_all(&bp_dir).map_err(|e| format!("创建 .blueprint/ 失败: {e}"))?;
// 1. 写入 CONVENTIONS.md(使用当前生效版本:用户版优先,fallback 内置;内容不变则不写) // 1. 写入 CONVENTIONS.md(使用当前生效版本:用户版优先,fallback 内置)
let conventions_written = crate::write_if_content_changed( std::fs::write(
&bp_dir.join("CONVENTIONS.md"), bp_dir.join("CONVENTIONS.md"),
&effective_conventions_content(), effective_conventions_content(),
) )
.map_err(|e| format!("写入 CONVENTIONS.md 失败: {e}"))?; .map_err(|e| format!("写入 CONVENTIONS.md 失败: {e}"))?;
@ -1324,7 +1323,7 @@ pub fn sync_blueprint_rules(project_path: String) -> Result<SyncResult, String>
Ok(SyncResult { Ok(SyncResult {
success: true, success: true,
conventions_written, conventions_written: true,
claude_md_action: claude_action.clone(), claude_md_action: claude_action.clone(),
agents_md_action: agents_action.clone(), agents_md_action: agents_action.clone(),
message: format!( message: format!(
@ -3052,14 +3051,6 @@ mod tests {
assert!(c.contains("内容B") && !c.contains("内容A")); assert!(c.contains("内容B") && !c.contains("内容A"));
assert_eq!(c.matches("BLUEPRINT_MANAGED_START").count(), 1); assert_eq!(c.matches("BLUEPRINT_MANAGED_START").count(), 1);
// 2b. 回归:内容不变(含 autocrlf 检出的 CRLF 副本)→ unchanged,不重写文件
//(2026-10-01 enterprise:无条件 LF 重写让 git status 误报改动、修改时间失真)
std::fs::write(&f, c.replace('\n', "\r\n")).unwrap();
let before = std::fs::read(&f).unwrap();
let a = upsert_managed_block(&f, tpl_v2, "# 头部").unwrap();
assert_eq!(a, "unchanged");
assert_eq!(std::fs::read(&f).unwrap(), before, "CRLF 原样保留,未被 LF 重写");
// 3. 存在但无 MANAGED 区 → appended(既有内容保留) // 3. 存在但无 MANAGED 区 → appended(既有内容保留)
let g = dir.join("CLAUDE.md"); let g = dir.join("CLAUDE.md");
std::fs::write( std::fs::write(

View File

@ -309,13 +309,8 @@ pub fn plan_cicd_workflow_for(
let (content, filename, required_secrets, toolchain) = let (content, filename, required_secrets, toolchain) =
build_workflow_for_type(&root, &config, workflow_type)?; build_workflow_for_type(&root, &config, workflow_type)?;
let target = workflow_target(&root, &filename)?; let target = workflow_target(&root, &filename)?;
// 忽略行尾比较:autocrlf 检出的同内容 workflow 不应被判为 update、再让用户确认一次无变化的写入
let change = match std::fs::read(&target) { let change = match std::fs::read(&target) {
Ok(existing) Ok(existing) if existing == content.as_bytes() => "noop",
if crate::same_text_ignoring_eol(&String::from_utf8_lossy(&existing), &content) =>
{
"noop"
}
Ok(_) => "update", Ok(_) => "update",
Err(error) if error.kind() == std::io::ErrorKind::NotFound => "create", Err(error) if error.kind() == std::io::ErrorKind::NotFound => "create",
Err(error) => return Err(format!("读取现有 workflow 失败:{error}")), Err(error) => return Err(format!("读取现有 workflow 失败:{error}")),
@ -376,9 +371,11 @@ pub fn apply_cicd_workflow_for(
)); ));
} }
let written = plan.change != "noop" let written = plan.change != "noop";
&& crate::write_if_content_changed(&target, &plan.content) if written {
std::fs::write(&target, plan.content.as_bytes())
.map_err(|error| format!("写入 workflow 失败:{error}"))?; .map_err(|error| format!("写入 workflow 失败:{error}"))?;
}
plan.path = target.to_string_lossy().to_string(); plan.path = target.to_string_lossy().to_string();
Ok(WorkflowApplyResult { plan, written }) Ok(WorkflowApplyResult { plan, written })
@ -489,7 +486,7 @@ pub fn generate_workflow(
let workflows_dir = project_root.join(".gitea").join("workflows"); let workflows_dir = project_root.join(".gitea").join("workflows");
std::fs::create_dir_all(&workflows_dir).map_err(|e| e.to_string())?; std::fs::create_dir_all(&workflows_dir).map_err(|e| e.to_string())?;
let file_path = workflows_dir.join(&filename); let file_path = workflows_dir.join(&filename);
crate::write_if_content_changed(&file_path, &content).map_err(|e| e.to_string())?; std::fs::write(&file_path, &content).map_err(|e| e.to_string())?;
Ok(WorkflowResult { Ok(WorkflowResult {
content, content,
@ -1122,11 +1119,6 @@ mod tests {
assert!(!second_apply.written); assert!(!second_apply.written);
assert_eq!(second_apply.plan.change, "noop"); assert_eq!(second_apply.plan.change, "noop");
// 回归:autocrlf 检出的同内容 workflow(CRLF)仍判 noop,不请求确认一次无变化的写入
std::fs::write(&target, plan.content.replace('\n', "\r\n")).unwrap();
let crlf_plan = plan_cicd_workflow_for(&conn, "p1", "pr_ci").unwrap();
assert_eq!(crlf_plan.change, "noop");
std::fs::remove_dir_all(root).unwrap(); std::fs::remove_dir_all(root).unwrap();
} }

View File

@ -18,27 +18,6 @@ pub fn mcp_client_auth_headers_json(project_digest: &str) -> Result<String, Stri
.map_err(|_| "MCP 认证头生成失败".to_string()) .map_err(|_| "MCP 认证头生成失败".to_string())
} }
/// 两段文本忽略行尾差异(CRLF/LF)后是否相同。
pub(crate) fn same_text_ignoring_eol(left: &str, right: &str) -> bool {
left.replace("\r\n", "\n") == right.replace("\r\n", "\n")
}
/// 内容(忽略行尾差异)未变时不重写文件,返回是否真的写入。
/// 子项目常开 core.autocrlf:用 LF 重写 CRLF 检出的同内容文件会改变大小与修改时间,
/// git status 因此误报改动,修改时间也不再能说明「最后是谁改的」(2026-10-01 enterprise 归属梳理实测)。
/// 凡向子项目受跟踪文件写入,都应经此函数(或在其之前做更强的语义比较,如 JSON)。
pub(crate) fn write_if_content_changed(
path: &std::path::Path,
content: &str,
) -> std::io::Result<bool> {
if let Ok(existing) = std::fs::read_to_string(path) {
if same_text_ignoring_eol(&existing, content) {
return Ok(false);
}
}
std::fs::write(path, content).map(|()| true)
}
/// 创建不弹 CMD 窗口的子进程(Windows 专用 CREATE_NO_WINDOW 标志)。 /// 创建不弹 CMD 窗口的子进程(Windows 专用 CREATE_NO_WINDOW 标志)。
/// 所有后台静默命令都应通过此函数创建,避免在 Windows 上频繁闪出黑框。 /// 所有后台静默命令都应通过此函数创建,避免在 Windows 上频繁闪出黑框。
pub(crate) fn silent_cmd(program: &str) -> std::process::Command { pub(crate) fn silent_cmd(program: &str) -> std::process::Command {

View File

@ -194,27 +194,13 @@ fn read_context_object(path: &PathBuf, label: &str) -> Result<Value, String> {
} }
} }
/// 现有 JSON 文件与待写值语义相同(与键序、缩进、行尾、末尾换行无关)时不重写:
/// Claude Code 等客户端改写这些文件时用自己的键序与格式,按文本比较会让炼境每次注入都整文件重排。
fn json_file_equals(path: &std::path::Path, value: &Value) -> bool {
std::fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str::<Value>(text.trim_start_matches('\u{feff}')).ok())
.is_some_and(|existing| &existing == value)
}
fn write_context_object(path: &PathBuf, value: &Value, label: &str) -> Result<(), String> { fn write_context_object(path: &PathBuf, value: &Value, label: &str) -> Result<(), String> {
if json_file_equals(path, value) {
return Ok(());
}
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
.map_err(|error| format!("创建 {label} 目录失败: {error}"))?; .map_err(|error| format!("创建 {label} 目录失败: {error}"))?;
} }
let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?; let content = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?;
crate::write_if_content_changed(path, &content) std::fs::write(path, content).map_err(|error| format!("写入 {label} 失败: {error}"))
.map(|_| ())
.map_err(|error| format!("写入 {label} 失败: {error}"))
} }
/// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。 /// 写工具无关的项目身份锚点。项目根目录是边界,客户端项目 UI 或用户级配置均不是身份来源。
@ -1052,16 +1038,11 @@ fn read_json(path: &PathBuf) -> Result<Value, String> {
} }
fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> { fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> {
if json_file_equals(path, value) {
return Ok(());
}
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("创建 .claude 目录失败: {}", e))?; std::fs::create_dir_all(parent).map_err(|e| format!("创建 .claude 目录失败: {}", e))?;
} }
let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?; let content = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
crate::write_if_content_changed(path, &content) std::fs::write(path, content).map_err(|e| format!("写入 settings.json 失败: {}", e))
.map(|_| ())
.map_err(|e| format!("写入 settings.json 失败: {}", e))
} }
fn upsert_codex_mcp_config( fn upsert_codex_mcp_config(
@ -1084,13 +1065,12 @@ fn upsert_codex_mcp_config(
current.find(CODEX_MCP_MANAGED_END), current.find(CODEX_MCP_MANAGED_END),
); );
let next = match (start, end) { let next = match (start, end) {
(Some(start), Some(end)) if end >= start => { (Some(start), Some(end)) if end >= start => format!(
// 块自带结尾换行:吸收 END 之后原有的空行再接上块外内容,否则每次注入都多一个空行 "{}{}{}",
let tail = &current[..start],
current[end + CODEX_MCP_MANAGED_END.len()..].trim_start_matches(['\r', '\n']); block,
let separator = if tail.is_empty() { "" } else { "\n" }; &current[end + CODEX_MCP_MANAGED_END.len()..]
format!("{}{block}{separator}{tail}", &current[..start]) ),
}
(Some(_), _) | (_, Some(_)) => { (Some(_), _) | (_, Some(_)) => {
return Err(format!( return Err(format!(
"{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试", "{} 的炼境 Codex 管理块不完整或顺序错误,请修复标记后重试",
@ -1115,9 +1095,7 @@ fn upsert_codex_mcp_config(
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
.map_err(|error| format!("创建 .codex 目录失败: {error}"))?; .map_err(|error| format!("创建 .codex 目录失败: {error}"))?;
} }
crate::write_if_content_changed(path, &next) std::fs::write(path, next).map_err(|error| format!("写入 Codex config.toml 失败: {error}"))
.map(|_| ())
.map_err(|error| format!("写入 Codex config.toml 失败: {error}"))
} }
fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> { fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> {
@ -1429,80 +1407,6 @@ mod tests {
); );
} }
/// 回归:托管块原先每次注入都在 END 之后多留一个空行(enterprise 已累积 15 行),
/// 且内容不变也会用 LF 重写 CRLF 检出的文件。
/// 回归:Claude Code 改写 settings.json 后键序与末尾换行不同,语义未变时注入不应整文件重排。
#[test]
fn json_writes_skip_semantically_equal_files() {
let base = std::env::temp_dir().join(format!("lianjing_json_idem_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let path = base.join("settings.json");
let client_written = "{\r\n \"permissions\": {\"allow\": []},\r\n \"mcpServers\": {\"lian-jing\": {\"type\": \"sse\", \"url\": \"u\"}}\r\n}\r\n";
std::fs::write(&path, client_written).unwrap();
let value: Value = serde_json::from_str(client_written).unwrap();
write_json(&path, &value).unwrap();
write_context_object(&path, &value, "settings.json").unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
client_written,
"语义相同,保留客户端的键序、行尾与末尾换行"
);
let mut changed = value.clone();
changed["mcpServers"]["lian-jing"]["url"] = json!("u2");
write_json(&path, &changed).unwrap();
let written: Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(written, changed, "语义变化时照常写入");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn codex_mcp_upsert_is_idempotent_and_absorbs_accumulated_blank_lines() {
let base = std::env::temp_dir().join(format!("lianjing_codex_idem_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join(".codex")).unwrap();
let path = base.join(".codex/config.toml");
let url = "http://127.0.0.1:27190/mcp/projects/p1";
let helper = shim_helper_command("d1");
// 块在末尾、END 之后堆了 15 个空行(CRLF 检出)
let block = format!(
"{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{url}\"\nhttp_headers_helper = {}\n{CODEX_MCP_MANAGED_END}",
serde_json::to_string(&helper).unwrap()
);
let accumulated =
format!("model = \"gpt-6\"\n{block}{}", "\n".repeat(16)).replace('\n', "\r\n");
std::fs::write(&path, &accumulated).unwrap();
upsert_codex_mcp_config(&path, url, &helper).unwrap();
let cleaned = std::fs::read_to_string(&path).unwrap();
assert!(
cleaned.ends_with(&format!("{CODEX_MCP_MANAGED_END}\n")),
"END 之后的累积空行被吸收"
);
// 再注入一次:内容与字节都不变
let before = std::fs::read(&path).unwrap();
upsert_codex_mcp_config(&path, url, &helper).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), before);
// 块在中间:块外后续内容保留,并且重复注入稳定
std::fs::write(
&path,
format!("{cleaned}\n\n\n[profiles.x]\nmodel = \"y\"\n"),
)
.unwrap();
upsert_codex_mcp_config(&path, url, &helper).unwrap();
let middle = std::fs::read_to_string(&path).unwrap();
assert!(middle.contains(&format!(
"{CODEX_MCP_MANAGED_END}\n\n[profiles.x]\nmodel = \"y\"\n"
)));
upsert_codex_mcp_config(&path, url, &helper).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), middle);
let _ = std::fs::remove_dir_all(&base);
}
#[test] #[test]
fn codex_mcp_config_preserves_user_content_and_rejects_handwritten_conflict() { fn codex_mcp_config_preserves_user_content_and_rejects_handwritten_conflict() {
let base = let base =

View File

@ -1744,8 +1744,7 @@ fn backfill_notes_archive(conn: &rusqlite::Connection) -> Result<String, String>
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
} }
let lines: Vec<&str> = entries.iter().map(|(_, l)| l.as_str()).collect(); let lines: Vec<&str> = entries.iter().map(|(_, l)| l.as_str()).collect();
crate::write_if_content_changed(&path, &(lines.join("\n") + "\n")) std::fs::write(&path, lines.join("\n") + "\n").map_err(|e| e.to_string())?;
.map_err(|e| e.to_string())?;
archived_notes += entries.len(); archived_notes += entries.len();
report.push_str(&format!( report.push_str(&format!(
"- ✅ {}:{} 条 → {}\n", "- ✅ {}:{} 条 → {}\n",