From 1dca8fd3eaa74db22f9148ec3cb19f2c1d67ab8c Mon Sep 17 00:00:00 2001 From: StarsailsClover <140955561+StarsailsClover@users.noreply.github.com> Date: Mon, 5 Oct 2026 03:17:44 +0800 Subject: [PATCH] feat: detect Codex update rewrites and add self-healing autostart Two root causes were found after the Codex 0.147 update: no persistent watch survived the update/reboot, and an app rewrite that preserved provider identity scored 3 against a threshold of 4, so watch would have captured the damaged config as a user edit. - classify a changed BROWSER_USE_CODEX_APP_VERSION plus protected removals as a rewrite - weight removal of 3+ complete mcp_servers/plugins/marketplaces/projects entries - add autostart install|status|uninstall (Windows): logon daemon plus a periodic one-shot repair task - report autostart health in doctor - regression tests for app-update rewrite, app update without losses, whole-entry loss, and the end-to-end repair path --- CHANGELOG.md | 36 +++ Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 10 +- README.zh-CN.md | 9 +- crates/keepmyconfig-cli/src/autostart.rs | 224 +++++++++++++++++++ crates/keepmyconfig-cli/src/main.rs | 70 +++++- crates/keepmyconfig-cli/src/render.rs | 38 ++++ crates/keepmyconfig-core/src/classify.rs | 247 ++++++++++++++++++++- crates/keepmyconfig-core/tests/workflow.rs | 70 ++++++ docs/USAGE.md | 18 ++ docs/USAGE.zh-CN.md | 17 ++ 12 files changed, 736 insertions(+), 9 deletions(-) create mode 100644 crates/keepmyconfig-cli/src/autostart.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d0ed34..2fea31c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,42 @@ and this project adheres to the BC version scheme (`v{Year}.{Major}-Alpha N`). ## [Unreleased] +## [v26.0-Alpha.4] — 2026-10-05 + +### Added + +- `autostart install|uninstall|status` (Windows): installs a logon daemon + (`KeepMyConfig-Watch`) plus a periodic one-shot repair task + (`KeepMyConfig-Check`, five-minute default) through `schtasks`, using small + wrapper scripts next to the executable. `doctor` now reports autostart state. + This closes the gap that let an update kill the manually started watch and + leave the configuration unprotected. +- Codex-update-aware rewrite detection: a change of + `mcp_servers.node_repl.env.BROWSER_USE_CODEX_APP_VERSION` combined with any + protected-path removal is treated as an app-rewrite clobber, and the removal + of three or more complete `mcp_servers` / `plugins` / `marketplaces` / + `projects` entries adds clobber weight. + +### Fixed + +- A Codex app update that rewrites `config.toml` while preserving provider + identity no longer scores below the clobber threshold. The observed 0.147 + rewrite (app build `26.930.31428` -> `26.930.31730`) dropped three MCP + servers, four curated plugin entries, and reset reasoning effort while + introducing `desktop.conversationDetailMode`; the old score was 3 against a + threshold of 4, so `watch` would have captured the damage as a user edit. + +### Verified + +- Real incident recovery on 2026-10-05: `status --check` exited 2 with 12 + missing protected paths and one changed value; `repair` restored all 12 and + rewrote `model_reasoning_effort` to the user value, leaving a pre-repair + backup. `status --check` returned 0 afterwards. +- New regression tests: an app-update rewrite is a clobber; an app update with + no losses is an edit; three complete entries removed is a clobber; a single + entry removal remains an edit; the end-to-end `process()` test repairs the + 0.147-style rewrite instead of capturing it. + ## [v26.0-Alpha.3] — 2026-10-03 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 8e7a904..b2a1563 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -554,7 +554,7 @@ dependencies = [ [[package]] name = "keepmyconfig-cli" -version = "0.1.0-alpha.3" +version = "0.1.0-alpha.4" dependencies = [ "anyhow", "assert_cmd", @@ -569,7 +569,7 @@ dependencies = [ [[package]] name = "keepmyconfig-core" -version = "0.1.0-alpha.3" +version = "0.1.0-alpha.4" dependencies = [ "chrono", "dirs", diff --git a/Cargo.toml b/Cargo.toml index ddf4921..7ad9676 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ ] [workspace.package] -version = "0.1.0-alpha.3" +version = "0.1.0-alpha.4" edition = "2021" authors = ["OxygenAILab"] license = "MIT" diff --git a/README.md b/README.md index 527b89a..fe726e6 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,10 @@ keepmyconfig init # 2) Watch in the background while you work (or register it at logon, see docs). keepmyconfig watch -# 3) After a CC Switch provider switch, verify and repair manually if needed. +# 3) Make protection survive reboots and Codex updates (Windows). +keepmyconfig autostart install + +# 4) After a CC Switch provider switch, verify and repair manually if needed. keepmyconfig status keepmyconfig repair ``` @@ -130,6 +133,7 @@ rolls the database back. | `repair [--dry-run] [--check]` | Merge the overlay into the live config now | | `watch [--once] [--dry-run]` | Detect and repair clobbers continuously, or once | +| `autostart install\|uninstall\|status` | Windows logon daemon + periodic one-shot repair task | | `backup --assets config,skills,plugins` | Versioned asset backup (hard links with `--link`) | | `restore-assets [--from DIR]` | Restore missing files; `--overwrite` to replace | | `ccswitch inspect\|adopt\|backups\|restore` | Opt-in CC Switch database integration | @@ -143,6 +147,10 @@ A write is treated as a provider-switch clobber only when the diff matches the fingerprint: protected paths disappeared **and** managed provider keys changed, while few or no new user-owned paths appeared. The score (balanced mode, threshold 4) also weighs bulk removal, file shrinkage, and added user paths. +Two additional signals cover app updates: a changed +`BROWSER_USE_CODEX_APP_VERSION` together with protected removals always counts +as a rewrite, and losing three or more complete MCP/plugin/marketplace/project +entries adds weight. User edits and Codex's own updates are captured as the new baseline instead of being reverted. Every decision is written to diff --git a/README.zh-CN.md b/README.zh-CN.md index 108ec71..3dc3d70 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -76,7 +76,10 @@ keepmyconfig init # 2) 后台守护(或配置开机自启,见文档) keepmyconfig watch -# 3) CC Switch 切换后检查;必要时手动修复 +# 3) 让保护在重启与 Codex 更新后继续生效(Windows) +keepmyconfig autostart install + +# 4) CC Switch 切换后检查;必要时手动修复 keepmyconfig status keepmyconfig repair ``` @@ -118,6 +121,7 @@ keepmyconfig ccswitch adopt --apply | `diff` | 路径级对比 live 与基线 | | `repair [--dry-run] [--check]` | 立即把覆盖层合并回 live | | `watch [--once] [--dry-run]` | 持续检测并修复(或只跑一轮) | +| `autostart install\|uninstall\|status` | Windows 登录守护 + 周期性单次修复任务 | | `backup --assets config,skills,plugins` | 版本化资产备份(`--link` 用硬链接) | | `restore-assets [--from DIR]` | 恢复缺失文件;`--overwrite` 覆盖 | | `ccswitch inspect\|adopt\|backups\|restore` | 可选的 CC Switch 数据库集成 | @@ -129,6 +133,9 @@ keepmyconfig ccswitch adopt --apply 只有同时满足“受保护路径消失”与“供应商托管键变化”,且几乎没有新增用户路径时,才会被 判定为供应商切换覆写。评分(balanced 模式阈值 4)还包括批量删除、文件缩小与新增用户 +路径。另有两条针对应用更新的信号:`BROWSER_USE_CODEX_APP_VERSION` 变化且受保护路径 +消失时必定视为整文件重写;一次丢掉 3 个及以上完整的 MCP/Plugin/Marketplace/Project +条目会显著加分。 路径。用户编辑与 Codex 自身更新会被采集为新基线,而不会被回滚。每次判定及证据都会写入 `~/.codex/.keepmyconfig/journal.jsonl`。 diff --git a/crates/keepmyconfig-cli/src/autostart.rs b/crates/keepmyconfig-cli/src/autostart.rs new file mode 100644 index 0000000..dbbc19f --- /dev/null +++ b/crates/keepmyconfig-cli/src/autostart.rs @@ -0,0 +1,224 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{bail, Context, Result}; +use keepmyconfig_core::store::Store; +use keepmyconfig_core::util; +use serde::Serialize; + +const TASK_WATCH: &str = "KeepMyConfig-Watch"; +const TASK_CHECK: &str = "KeepMyConfig-Check"; +const WRAPPER_WATCH: &str = "watch-daemon.cmd"; +const WRAPPER_CHECK: &str = "watch-once.cmd"; + +#[derive(Debug, Clone, Serialize)] +pub struct AutostartStatus { + pub supported: bool, + pub executable: PathBuf, + // GitHub@Oxy g enA IL ab | OxygenAILab @ StarsailsClove r + pub codex_home: PathBuf, + pub store_dir: PathBuf, + pub watch_task_installed: bool, + pub check_task_installed: bool, + pub watch_wrapper: PathBuf, + pub check_wrapper: PathBuf, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AutostartReport { + pub action: String, + pub details: Vec, + pub status: AutostartStatus, +} + +pub fn status(store: &Store) -> Result { + let executable = std::env::current_exe().context("cannot resolve the current executable")?; + let wrapper_dir = executable + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + Ok(AutostartStatus { + supported: cfg!(windows), + executable, + codex_home: store.paths().codex_home.clone(), + store_dir: store.paths().store_dir.clone(), + watch_task_installed: task_exists(TASK_WATCH), + check_task_installed: task_exists(TASK_CHECK), + watch_wrapper: wrapper_dir.join(WRAPPER_WATCH), + check_wrapper: wrapper_dir.join(WRAPPER_CHECK), + }) +} + +/// Install logon + periodic self-healing tasks for the current user. +pub fn install(store: &Store, interval_minutes: u64, no_daemon: bool) -> Result { + if !cfg!(windows) { + bail!( + "autostart is currently implemented for Windows only; on Linux use a systemd user unit running `keepmyconfig watch`" + ); + } + if interval_minutes == 0 || interval_minutes > 1440 { + // GitHub@OxygenAILab | Ox ygenAILab@Starsail sClov er + bail!("--interval-minutes must be between 1 and 1440"); + } + let current = status(store)?; + let mut details = Vec::new(); + + util::atomic_write( + ¤t.watch_wrapper, + &wrapper_script( + ¤t.executable, + ¤t.codex_home, + ¤t.store_dir, + false, + ), + ) + .with_context(|| format!("cannot write {}", current.watch_wrapper.display()))?; + util::atomic_write( + ¤t.check_wrapper, + &wrapper_script( + ¤t.executable, + ¤t.codex_home, + ¤t.store_dir, + true, + ), + ) + .with_context(|| format!("cannot write {}", current.check_wrapper.display()))?; + details.push(format!("wrappers: {}", current.watch_wrapper.display())); + + if !no_daemon { + run_schtasks(&[ + "/Create", + "/F", + "/TN", + TASK_WATCH, + "/SC", + "ONLOGON", + "/TR", + &format!("\"{}\"", current.watch_wrapper.display()), + ]) + .context("cannot create the logon task")?; + details.push(format!("task: {TASK_WATCH} (at logon, daemon)")); + } + run_schtasks(&[ + "/Create", + "/F", + "/TN", + TASK_CHECK, + "/SC", + "MINUTE", + "/MO", + &interval_minutes.to_string(), + "/TR", + &format!("\"{}\"", current.check_wrapper.display()), + ]) + .context("cannot create the periodic check task")?; + details.push(format!( + "task: {TASK_CHECK} (every {interval_minutes} minute(s), one-shot repair)" + )); + + if !no_daemon { + let _ = run_schtasks(&["/Run", "/TN", TASK_WATCH]); + details.push(format!("started {TASK_WATCH} now")); + } + let _ = run_schtasks(&["/Run", "/TN", TASK_CHECK]); + details.push(format!("started {TASK_CHECK} now")); + + Ok(AutostartReport { + action: "install".to_string(), + details, + status: status(store)?, + }) +} + +pub fn uninstall(store: &Store) -> Result { + if !cfg!(windows) { + bail!("autostart is currently implemented for Windows only"); + } + let current = status(store)?; + let mut details = Vec::new(); + for task in [TASK_WATCH, TASK_CHECK] { + if task_exists(task) { + // GitHub@Oxygen AILab | Oxygen AILab@ S tarsa ilsClove r + run_schtasks(&["/Delete", "/F", "/TN", task]) + .with_context(|| format!("cannot delete task {task}"))?; + details.push(format!("removed task: {task}")); + } + } + for wrapper in [¤t.watch_wrapper, ¤t.check_wrapper] { + if wrapper.is_file() { + std::fs::remove_file(wrapper) + .with_context(|| format!("cannot remove {}", wrapper.display()))?; + details.push(format!("removed wrapper: {}", wrapper.display())); + } + } + Ok(AutostartReport { + action: "uninstall".to_string(), + details, + status: status(store)?, + }) +} + +fn wrapper_script(executable: &Path, codex_home: &Path, store_dir: &Path, once: bool) -> String { + let mode = if once { + "watch --once --quiet" + } else { + "watch --quiet" + }; + format!( + "@echo off\r\n\"{}\" --codex-home \"{}\" --store \"{}\" {mode}\r\n", + executable.display(), + codex_home.display(), + store_dir.display() + ) +} + +fn task_exists(name: &str) -> bool { + if !cfg!(windows) { + return false; + } + Command::new("schtasks") + .args(["/Query", "/TN", name]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn run_schtasks(args: &[&str]) -> Result<()> { + let output = Command::new("schtasks") + .args(args) + .output() + .context("cannot run schtasks")?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + bail!( + "schtasks {} failed: {}{}", + args.join(" "), + stdout.trim(), + stderr.trim() + ) + } +} + +#[cfg(test)] +mod tests { + use super::wrapper_script; + use std::path::Path; + + #[test] + fn wrapper_contains_resolved_paths_and_mode() { + let script = wrapper_script( + Path::new(r"C:\Apps\KeepMyConfig\keepmyconfig.exe"), + Path::new(r"C:\Users\demo\.codex"), + Path::new(r"C:\Users\demo\.codex\.keepmyconfig"), + // GitHub @OxygenAIL ab | OxygenAILab@Star sailsClover + true, + ); + assert!(script.contains("--codex-home \"C:\\Users\\demo\\.codex\"")); + assert!(script.contains("watch --once --quiet")); + assert!(script.contains("keepmyconfig.exe")); + // Git Hub@Ox y genAILab | Oxy genAILab@St ars ails Clover + } +} diff --git a/crates/keepmyconfig-cli/src/main.rs b/crates/keepmyconfig-cli/src/main.rs index 1b148dd..dea4782 100644 --- a/crates/keepmyconfig-cli/src/main.rs +++ b/crates/keepmyconfig-cli/src/main.rs @@ -1,3 +1,4 @@ +mod autostart; mod render; use std::path::PathBuf; @@ -143,6 +144,12 @@ enum Command { /// Check the environment and report compatibility Doctor, + + /// Install or inspect logon/periodic self-healing tasks (Windows) + Autostart { + #[command(subcommand)] + command: AutostartCommand, + }, } #[derive(Subcommand, Debug)] @@ -174,6 +181,23 @@ enum CcSwitchCommand { }, } +#[derive(Subcommand, Debug)] +enum AutostartCommand { + /// Install a logon daemon plus a periodic one-shot repair task + Install { + /// Periodic repair interval in minutes + #[arg(long, default_value_t = 5)] + interval_minutes: u64, + /// Only install the periodic check (no long-running daemon) + #[arg(long)] + no_daemon: bool, + }, + /// Remove the scheduled tasks and wrapper scripts + Uninstall, + /// Show whether the tasks are installed + Status, +} + fn main() -> ExitCode { let cli = Cli::parse(); // Gi tHub@OxygenAILab | OxygenAILab @ StarsailsClover @@ -294,7 +318,9 @@ fn run(cli: Cli) -> Result { return; } if !printed_started { - eprintln!("Press Ctrl+C to stop."); + if !options.once { + eprintln!("Press Ctrl+C to stop."); + } printed_started = true; } println!("{}", render::watch_text(event)); @@ -338,9 +364,32 @@ fn run(cli: Cli) -> Result { } Command::Ccswitch { command } => run_ccswitch(&store, command, json), Command::Doctor => run_doctor(&store, json), + Command::Autostart { command } => run_autostart(&store, command, json), } } +fn run_autostart(store: &Store, command: AutostartCommand, json: bool) -> Result { + match command { + AutostartCommand::Install { + interval_minutes, + no_daemon, + } => { + let report = autostart::install(store, interval_minutes, no_daemon) + .context("cannot install autostart tasks")?; + print_value(json, &report, render::autostart_text(&report))?; + } + AutostartCommand::Uninstall => { + let report = autostart::uninstall(store).context("cannot remove autostart tasks")?; + print_value(json, &report, render::autostart_text(&report))?; + } + AutostartCommand::Status => { + let status = autostart::status(store).context("cannot read autostart status")?; + print_value(json, &status, render::autostart_status_text(&status))?; + } + } + Ok(ExitCode::SUCCESS) +} + fn run_ccswitch(store: &Store, command: CcSwitchCommand, json: bool) -> Result { match command { CcSwitchCommand::Inspect => { @@ -481,6 +530,25 @@ fn run_doctor(store: &Store, json: bool) -> Result { )), Err(error) => checks.push(check("cc_switch", false, error.to_string())), } + match autostart::status(store) { + Ok(status) if !status.supported => checks.push(check_status( + "autostart", + "warn", + "not supported on this platform (use a systemd user unit on Linux)".to_string(), + )), + Ok(status) => { + let installed = status.watch_task_installed || status.check_task_installed; + checks.push(check_status( + "autostart", + if installed { "ok" } else { "warn" }, + format!( + "watch task: {}, periodic check: {}", + status.watch_task_installed, status.check_task_installed + ), + )); + } + Err(error) => checks.push(check_status("autostart", "warn", error.to_string())), + } let failed = checks.iter().any(|entry| entry.status == "fail"); let report = DoctorReport { version: status.version.clone(), diff --git a/crates/keepmyconfig-cli/src/render.rs b/crates/keepmyconfig-cli/src/render.rs index 9baac65..269c554 100644 --- a/crates/keepmyconfig-cli/src/render.rs +++ b/crates/keepmyconfig-cli/src/render.rs @@ -1,3 +1,4 @@ +use crate::autostart::{AutostartReport, AutostartStatus}; use keepmyconfig_core::assets::{BackupReport, RestoreReport}; use keepmyconfig_core::ccswitch::{AdoptReport, CcSwitchSummary, RestoreDbReport}; use keepmyconfig_core::store::{CaptureReport, InitReport, RepairReport, StatusReport}; @@ -136,6 +137,12 @@ pub fn status_text(report: &StatusReport) -> String { summarize_paths(&diff.changed_protected, 6) )); } + if !diff.whole_entries_removed.is_empty() { + text.push_str(&format!( + " entries : {}\n", + summarize_paths(&diff.whole_entries_removed, 6) + )); + } } if let Some(ccswitch) = &report.ccswitch { text.push_str(&ccswitch_text(ccswitch)); @@ -312,6 +319,37 @@ pub fn watch_text(event: &WatchEvent) -> String { render_event(event) } +pub fn autostart_text(report: &AutostartReport) -> String { + let mut text = format!("autostart {}\n", report.action); + for detail in &report.details { + text.push_str(&format!(" {detail}\n")); + } + if !report.status.supported { + text.push_str(" (autostart management is Windows-only)\n"); + } + text.push_str(&autostart_status_text(&report.status)); + text +} + +pub fn autostart_status_text(status: &AutostartStatus) -> String { + format!( + " executable : {}\n watch task : {}\n check task : {}\n wrappers : {}\n {}\n", + status.executable.display(), + yes_no(status.watch_task_installed), + yes_no(status.check_task_installed), + status.watch_wrapper.display(), + status.check_wrapper.display() + ) +} + +fn yes_no(value: bool) -> &'static str { + if value { + "installed" + } else { + "not installed" + } +} + pub fn human_bytes(bytes: u64) -> String { const KIB: f64 = 1024.0; const MIB: f64 = KIB * 1024.0; diff --git a/crates/keepmyconfig-core/src/classify.rs b/crates/keepmyconfig-core/src/classify.rs index ea0b045..41cfd6f 100644 --- a/crates/keepmyconfig-core/src/classify.rs +++ b/crates/keepmyconfig-core/src/classify.rs @@ -1,8 +1,12 @@ +use std::collections::BTreeSet; + use serde::Serialize; -use toml_edit::DocumentMut; +use toml_edit::{DocumentMut, Item}; use crate::policy::{CompiledPolicy, DetectionMode}; -use crate::tomltree::{collect_all_leaves, collect_managed_leaves, collect_protected_leaves}; +use crate::tomltree::{ + collect_all_leaves, collect_managed_leaves, collect_protected_leaves, render_segments, +}; /// Path-level differences between the baseline and the live config. #[derive(Debug, Clone, Default, Serialize)] @@ -13,6 +17,11 @@ pub struct DiffReport { pub changed_protected: Vec, pub added_user_paths: Vec, pub changed_managed: Vec, + /// Multi-entry tables (mcp_servers, plugins, marketplaces, projects) whose + /// entry disappeared completely. + pub whole_entries_removed: Vec, + pub codex_version_before: Option, + pub codex_version_after: Option, pub size_before: usize, pub size_after: usize, } @@ -91,6 +100,25 @@ pub fn diff_docs( changed_managed.sort(); changed_managed.dedup(); + let mut whole_entries_removed = Vec::new(); + if !missing_protected.is_empty() { + let mut entries: BTreeSet = BTreeSet::new(); + for path in &missing_protected { + if let Some(entry) = entry_key(path) { + entries.insert(entry); + } + } + for entry in entries { + let prefix = format!("{entry}."); + let has_live_leaf = live_all + .keys() + .any(|path| path == &entry || path.starts_with(&prefix)); + if !has_live_leaf { + whole_entries_removed.push(entry); + } + } + } + DiffReport { protected_total: baseline_protected.len(), protected_in_live: live_protected.len(), @@ -98,6 +126,9 @@ pub fn diff_docs( changed_protected, added_user_paths, changed_managed, + whole_entries_removed, + codex_version_before: codex_app_version(baseline), + codex_version_after: codex_app_version(live), size_before, size_after, } @@ -105,10 +136,15 @@ pub fn diff_docs( /// Score a diff against the CC Switch clobber fingerprint. pub fn classify(diff: &DiffReport, mode: DetectionMode) -> Classification { + let app_updated = matches!( + (&diff.codex_version_before, &diff.codex_version_after), + (Some(before), Some(after)) if before != after + ); if diff.missing_protected.is_empty() && diff.changed_protected.is_empty() && diff.added_user_paths.is_empty() && diff.changed_managed.is_empty() + && !app_updated { return Classification { kind: ChangeKind::NoChange, @@ -142,6 +178,13 @@ pub fn classify(diff: &DiffReport, mode: DetectionMode) -> Classification { diff.changed_managed.len() )); } + if diff.whole_entries_removed.len() >= 3 { + score += 2; + evidence.push(format!( + "{} complete table entries removed", + diff.whole_entries_removed.len() + )); + } if diff.added_user_paths.is_empty() { score += 1; evidence.push("no new user-owned paths were introduced".to_string()); @@ -167,14 +210,27 @@ pub fn classify(diff: &DiffReport, mode: DetectionMode) -> Classification { diff.size_before, diff.size_after )); } + if app_updated { + evidence.push(format!( + "Codex app version changed {} -> {}", + diff.codex_version_before.as_deref().unwrap_or("-"), + diff.codex_version_after.as_deref().unwrap_or("-") + )); + } let threshold = threshold(mode); - let kind = match mode { + let mut kind = match mode { DetectionMode::Off => ChangeKind::Edit, DetectionMode::Strict if score >= 3 => ChangeKind::Clobber, DetectionMode::Balanced if score >= 4 => ChangeKind::Clobber, _ => ChangeKind::Edit, }; + // A Codex update rewrites config.toml from its own template and drops + // user-owned entries without touching provider identity. That is a + // clobber even when the generic score stays below the threshold. + if mode != DetectionMode::Off && app_updated && !diff.missing_protected.is_empty() { + kind = ChangeKind::Clobber; + } Classification { kind, score, @@ -183,6 +239,53 @@ pub fn classify(diff: &DiffReport, mode: DetectionMode) -> Classification { } } +const ENTRY_TABLES: [&str; 4] = ["mcp_servers", "plugins", "marketplaces", "projects"]; + +/// Split a rendered canonical path back into unescaped segments. +fn split_canonical_path(path: &str) -> Vec { + let mut segments = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + let mut escaped = false; + for character in path.chars() { + if escaped { + current.push(character); + escaped = false; + continue; + } + match character { + '\\' if in_quotes => escaped = true, + '"' => in_quotes = !in_quotes, + '.' if !in_quotes => { + segments.push(current.clone()); + current.clear(); + } + _ => current.push(character), + } + } + segments.push(current); + segments +} + +/// Entry key for tables where each child is an independent user-owned unit. +fn entry_key(path: &str) -> Option { + let segments = split_canonical_path(path); + if segments.len() >= 2 && ENTRY_TABLES.contains(&segments[0].as_str()) { + Some(render_segments(&segments[..2])) + } else { + None + } +} + +/// Codex desktop app build, written by the app into its own node_repl MCP env. +fn codex_app_version(doc: &DocumentMut) -> Option { + let mut item: &Item = doc.get("mcp_servers")?; + for key in ["node_repl", "env", "BROWSER_USE_CODEX_APP_VERSION"] { + item = item.as_table_like()?.get(key)?; + } + item.as_str().map(str::to_string) +} + fn threshold(mode: DetectionMode) -> i32 { match mode { DetectionMode::Off => i32::MAX, @@ -325,4 +428,142 @@ followUpQueueMode = "queue" let classification = classify(&diff, DetectionMode::Balanced); assert_eq!(classification.kind, ChangeKind::NoChange); } + + #[test] + fn codex_app_update_rewrite_is_a_clobber() { + let policy = Policy::default().compile().unwrap(); + let baseline: DocumentMut = r#" +model = "deepseek-v4.1-flash" +model_provider = "SailsAPI" + +[mcp_servers.node_repl] +command = "node_repl.exe" + +[mcp_servers.node_repl.env] +BROWSER_USE_CODEX_APP_VERSION = "26.930.31428" + +[mcp_servers.cu_bridge] +command = "node.exe" +args = ["server.mjs"] + +[mcp_servers.prima-mock-api] +command = "pma.exe" +args = ["serve", "--mcp"] + +[mcp_servers.wsl-cu] +command = "node" +args = ["server.mjs"] + +[plugins."figma@openai-api-curated"] +enabled = true + +[plugins."linear@openai-api-curated"] +enabled = true + +[desktop] +followUpQueueMode = "queue" +"# + .parse() + .unwrap(); + let live: DocumentMut = r#" +model = "deepseek-v4.1-flash" +model_provider = "SailsAPI" + +[mcp_servers.node_repl] +command = "node_repl.exe" + +[mcp_servers.node_repl.env] +BROWSER_USE_CODEX_APP_VERSION = "26.930.31730" + +[desktop] +followUpQueueMode = "queue" +conversationDetailMode = "STEPS_COMMANDS" +"# + .parse() + .unwrap(); + let diff = diff_docs(&baseline, &live, &policy, 1200, 700); + assert!(!diff.whole_entries_removed.is_empty()); + let classification = classify(&diff, DetectionMode::Balanced); + assert_eq!(classification.kind, ChangeKind::Clobber); + assert!(classification + .evidence + .iter() + .any(|line| line.contains("Codex app version changed"))); + } + + #[test] + fn app_update_without_losses_is_an_edit() { + let policy = Policy::default().compile().unwrap(); + let baseline: DocumentMut = r#" +model = "deepseek-v4.1-flash" + +[mcp_servers.node_repl.env] +BROWSER_USE_CODEX_APP_VERSION = "26.930.31428" + +[plugins."pdf@openai-primary-runtime"] +enabled = true +"# + .parse() + .unwrap(); + let live: DocumentMut = r#" +model = "deepseek-v4.1-flash" + +[mcp_servers.node_repl.env] +BROWSER_USE_CODEX_APP_VERSION = "26.930.31730" + +[plugins."pdf@openai-primary-runtime"] +enabled = true +"# + .parse() + .unwrap(); + let diff = diff_docs(&baseline, &live, &policy, 200, 200); + let classification = classify(&diff, DetectionMode::Balanced); + assert_eq!(classification.kind, ChangeKind::Edit); + } + + #[test] + fn three_complete_entries_removed_is_a_clobber() { + let policy = Policy::default().compile().unwrap(); + let baseline: DocumentMut = r#" +model = "deepseek-v4.1-flash" + +[mcp_servers.alpha] +command = "alpha.exe" +args = ["serve"] + +[mcp_servers.beta] +command = "beta.exe" +args = ["serve"] + +[mcp_servers.gamma] +command = "gamma.exe" +args = ["serve"] +"# + .parse() + .unwrap(); + let live: DocumentMut = "model = \"deepseek-v4.1-flash\"\n".parse().unwrap(); + let diff = diff_docs(&baseline, &live, &policy, 400, 40); + assert_eq!(diff.whole_entries_removed.len(), 3); + let classification = classify(&diff, DetectionMode::Balanced); + assert_eq!(classification.kind, ChangeKind::Clobber); + } + + #[test] + fn removing_one_entry_is_still_an_edit() { + let policy = Policy::default().compile().unwrap(); + let baseline: DocumentMut = r#" +model = "deepseek-v4.1-flash" + +[mcp_servers.alpha] +command = "alpha.exe" +args = ["serve"] +"# + .parse() + .unwrap(); + let live: DocumentMut = "model = \"deepseek-v4.1-flash\"\n".parse().unwrap(); + let diff = diff_docs(&baseline, &live, &policy, 100, 40); + assert_eq!(diff.whole_entries_removed.len(), 1); + let classification = classify(&diff, DetectionMode::Balanced); + assert_eq!(classification.kind, ChangeKind::Edit); + } } diff --git a/crates/keepmyconfig-core/tests/workflow.rs b/crates/keepmyconfig-core/tests/workflow.rs index 6ed07f5..1329e22 100644 --- a/crates/keepmyconfig-core/tests/workflow.rs +++ b/crates/keepmyconfig-core/tests/workflow.rs @@ -329,6 +329,76 @@ fn restore_skips_manifest_entries_that_escape_the_codex_home() { assert!(!escape_target.exists(), "path traversal must be refused"); } +#[test] +fn codex_update_rewrite_is_repaired_not_captured() { + let baseline = r#"model = "deepseek-v4.1-flash" +model_provider = "SailsAPI" +model_reasoning_effort = "max" + +[mcp_servers.node_repl] +command = "node_repl.exe" + +[mcp_servers.node_repl.env] +BROWSER_USE_CODEX_APP_VERSION = "26.930.31428" +CODEX_CLI_PATH = 'C:\\.codex\\bin\\old\\codex.exe' + +[mcp_servers.cu_bridge] +command = "node.exe" +args = ["server.mjs"] + +[mcp_servers.prima-mock-api] +command = "pma.exe" +args = ["serve", "--mcp"] + +[mcp_servers.wsl-cu] +command = "node" +args = ["server.mjs"] + +[plugins."figma@openai-api-curated"] +enabled = true + +[plugins."linear@openai-api-curated"] +enabled = true + +[desktop] +followUpQueueMode = "queue" +"#; + let upgraded = r#"model = "deepseek-v4.1-flash" +model_provider = "SailsAPI" +model_reasoning_effort = "high" + +[mcp_servers.node_repl] +command = "node_repl.exe" + +[mcp_servers.node_repl.env] +BROWSER_USE_CODEX_APP_VERSION = "26.930.31730" +CODEX_CLI_PATH = 'C:\\.codex\\bin\\new\\codex.exe' + +[desktop] +followUpQueueMode = "queue" +conversationDetailMode = "STEPS_COMMANDS" +"#; + let (_temp, paths, store) = setup(baseline); + store.init(None, false).unwrap(); + fs::write(&paths.config_file, upgraded).unwrap(); + + let outcome = store.process(false).unwrap(); + assert!( + matches!(outcome, ProcessOutcome::Repaired(_)), + "a Codex update rewrite must be repaired, not captured: {outcome:?}" + ); + + let repaired = fs::read_to_string(&paths.config_file).unwrap(); + assert!(repaired.contains("cu_bridge")); + assert!(repaired.contains("pma.exe")); + assert!(repaired.contains("wsl-cu")); + assert!(repaired.contains("figma@openai-api-curated")); + assert!(repaired.contains("model_reasoning_effort = \"max\"")); + // The new app build keeps its own updated runtime paths and new keys. + assert!(repaired.contains("26.930.31730")); + assert!(repaired.contains("conversationDetailMode")); +} + #[test] // GitHu b@O xygenAIL ab | O xygen AILab@StarsailsC lover fn cc_switch_adopt_publishes_common_config_providers_and_mcp() { diff --git a/docs/USAGE.md b/docs/USAGE.md index ea87b93..64f38cd 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -114,6 +114,22 @@ keepmyconfig repair --dry-run # show what would be restored keepmyconfig repair ``` +### Surviving reboots and Codex updates (Windows) + +```powershell +keepmyconfig autostart install # logon daemon + check every 5 minutes +keepmyconfig autostart install --interval-minutes 10 +keepmyconfig autostart install --no-daemon +keepmyconfig autostart status +keepmyconfig autostart uninstall +``` + +`install` writes two wrapper scripts next to `keepmyconfig.exe`, registers +`KeepMyConfig-Watch` (runs `watch` at logon) and `KeepMyConfig-Check` (runs +`watch --once --quiet` on a schedule) with the current-user Task Scheduler, and +starts both once immediately. The periodic task keeps repairing even if the +daemon is killed by an app update or a reboot. + ### Recovering from an existing loss ```powershell @@ -175,6 +191,8 @@ everything in one transaction after taking a consistent SQLite backup. | managed provider keys changed | +2 | | no new user-owned paths | +1 (1–5 new: 0, more: −1) | | file shrank by ≥30% | +1 | +| three or more complete MCP/plugin/marketplace/project entries removed | +2 | +| `BROWSER_USE_CODEX_APP_VERSION` changed **and** protected paths were removed | forced clobber | `balanced` repairs at ≥4, `strict` at ≥3, `off` never auto-repairs. diff --git a/docs/USAGE.zh-CN.md b/docs/USAGE.zh-CN.md index 6d37e30..2c07ffd 100644 --- a/docs/USAGE.zh-CN.md +++ b/docs/USAGE.zh-CN.md @@ -98,6 +98,21 @@ keepmyconfig repair --dry-run keepmyconfig repair ``` +### 在重启与 Codex 更新后继续生效(Windows) + +```powershell +keepmyconfig autostart install # 登录守护 + 每 5 分钟检查 +keepmyconfig autostart install --interval-minutes 10 +keepmyconfig autostart install --no-daemon +keepmyconfig autostart status +keepmyconfig autostart uninstall +``` + +`install` 会在 `keepmyconfig.exe` 旁写两个包装脚本,注册 +`KeepMyConfig-Watch`(登录时运行 `watch`)与 `KeepMyConfig-Check`(按计划运行 +`watch --once --quiet`)两个当前用户计划任务,并立即各启动一次。即使守护进程被应用 +更新或重启杀掉,周期任务也会持续修复。 + ### 已经丢失配置的抢救 ```powershell @@ -156,6 +171,8 @@ adopt 按表结构(`settings`、`providers`、`mcp_servers`)校验数据库 | 供应商托管键发生变化 | +2 | | 无新增用户路径 | +1(1–5 个:0;更多:−1) | | 文件缩小 ≥30% | +1 | +| 一次删除 3 个及以上完整的 MCP/Plugin/Marketplace/Project 条目 | +2 | +| `BROWSER_USE_CODEX_APP_VERSION` 变化且受保护路径消失 | 必定判定为覆写 | `balanced` 阈值 4,`strict` 阈值 3,`off` 从不自动修复。