Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -130,6 +133,7 @@ rolls the database back.
| `repair [--dry-run] [--check]` | Merge the overlay into the live config now |
<!-- GitHub@Oxyge nA I Lab | OxygenAILab @Starsails Clover -->
| `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 |
Expand All @@ -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
<!-- GitH ub@O xygenAILab | OxygenAILab@StarsailsCl over -->
Expand Down
9 changes: 8 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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 数据库集成 |
Expand All @@ -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`。

Expand Down
224 changes: 224 additions & 0 deletions crates/keepmyconfig-cli/src/autostart.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub status: AutostartStatus,
}

pub fn status(store: &Store) -> Result<AutostartStatus> {
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<AutostartReport> {
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(
&current.watch_wrapper,
&wrapper_script(
&current.executable,
&current.codex_home,
&current.store_dir,
false,
),
)
.with_context(|| format!("cannot write {}", current.watch_wrapper.display()))?;
util::atomic_write(
&current.check_wrapper,
&wrapper_script(
&current.executable,
&current.codex_home,
&current.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<AutostartReport> {
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 [&current.watch_wrapper, &current.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
}
}
Loading
Loading