supercode_harness/checkpoint.rs
1//! §2 module 20 `checkpoint` (COMPOSABLE-HARNESS-DESIGN.md line 470): file
2//! checkpointing / shadow-git; D4-adjacent revert; D3 turn-diff tracking.
3//! Line 1504: "restores FILES, not context — pairs with, never replaces,
4//! the reduction sidecar."
5//!
6//! **Not to be confused with** [`crate::Agent::checkpoint`]/[`crate::Agent::rewind_to`]
7//! (an in-memory CONVERSATION-position marker, an unrelated pre-existing
8//! facility) or [`supercode_interchange::session_tree`] (module 21, tree-addressable
9//! sessions). This module only ever touches files on disk.
10//!
11//! # The write-path interception seam (D-5, shared with `formatters`/P5-11)
12//! [`crate::tools::WriteObserver`] (defined in `crate::tools`, the seam's
13//! natural owner since [`crate::tools::ToolContext`] carries it) is called
14//! by `write_file`/`edit_file`/`apply_patch` around their mutation.
15//! [`CheckpointObserver`] is this module's implementation: `before_write`
16//! captures a pre-image; `after_write` is a no-op (reserved for
17//! `formatters`).
18//!
19//! # Storage — never the user's real git
20//! [`CheckpointStore`] is a content-addressed, per-project shadow store at
21//! a caller-supplied `root` (the same dependency-injection shape as
22//! [`crate::store::SessionStore::open`]) — blobs keyed by
23//! [`crate::reduce::content_hash`] under `<root>/objects/`, one JSON
24//! manifest per checkpoint under `<root>/checkpoints/<id>.json`. This
25//! module never shells out to `git` and never touches the project's own
26//! `.git` — there is no `GIT_DIR` to get wrong because none is ever used.
27//!
28//! # C8 (design line 1819) compliance
29//! A [`CheckpointId`] is an opaque `String` — the ONLY thing a caller
30//! (session record, CLI) ever threads around. It is never the file
31//! contents, and it never reaches the model's context window: restoring
32//! files is a disk operation, never a conversation-history mutation.
33
34use std::collections::HashSet;
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::Mutex;
38
39use serde::{Deserialize, Serialize};
40
41use crate::error::{Error, Result};
42use crate::tools::WriteObserver;
43
44/// Default number of checkpoints retained per project before the oldest are
45/// pruned (bounded-disk requirement) — overridable via
46/// `[capabilities.checkpoint].retain`.
47pub const DEFAULT_RETAIN: usize = 50;
48
49/// An opaque, lexically-sortable (ascending = chronological, since it's
50/// zero-padded-millis-prefixed) checkpoint identifier. This is ALL a
51/// session record ever carries for C8 — never file contents.
52pub type CheckpointId = String;
53
54fn now_ms() -> u128 {
55 std::time::SystemTime::now()
56 .duration_since(std::time::UNIX_EPOCH)
57 .map(|d| d.as_millis())
58 .unwrap_or(0)
59}
60
61/// Mint a fresh id: zero-padded millis + an 8-hex-digit salt derived from
62/// (pid, millis, a monotonic instant) — collision-free in practice without
63/// pulling in a UUID/random dependency (matches this crate's existing
64/// `shell_sentinel` precedent in `tools/builtins.rs`).
65fn mint_id() -> CheckpointId {
66 use std::hash::BuildHasher;
67 let millis = now_ms();
68 let salt = std::collections::hash_map::RandomState::new().hash_one((
69 std::process::id(),
70 millis,
71 std::time::Instant::now(),
72 ));
73 format!("{millis:020}-{:08x}", salt as u32)
74}
75
76/// One file's entry in a [`CheckpointManifest`] — the turn-diff (D3) is
77/// simply this manifest's `files` list.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct CheckpointFileEntry {
80 /// Path relative to the project root, `/`-separated, never absolute and
81 /// never containing a `..` component in what [`CheckpointObserver`]
82 /// itself writes (restore independently re-validates this — see
83 /// [`CheckpointStore::restore`] — rather than trusting it).
84 pub path: String,
85 /// blake3 hex digest ([`crate::reduce::content_hash`]) of the pre-image
86 /// content, or `None` if the file did not exist before this
87 /// checkpoint's turn began (i.e. the write that follows is a create —
88 /// restoring deletes it again).
89 pub blob: Option<String>,
90}
91
92/// A checkpoint's full manifest — one JSON file per checkpoint.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct CheckpointManifest {
95 /// This checkpoint's id.
96 pub id: CheckpointId,
97 /// Unix-epoch milliseconds when this checkpoint was minted.
98 pub created_at_ms: u128,
99 /// A short, human-readable label (e.g. the turn's prompt excerpt) —
100 /// display only, never re-parsed.
101 pub label: String,
102 /// Every file this turn captured a pre-image for — the D3 turn-diff.
103 pub files: Vec<CheckpointFileEntry>,
104}
105
106/// Lightweight listing entry ([`CheckpointStore::list`]) — the manifest
107/// without the (potentially long) file list.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct CheckpointMeta {
110 /// This checkpoint's id.
111 pub id: CheckpointId,
112 /// Unix-epoch milliseconds when this checkpoint was minted.
113 pub created_at_ms: u128,
114 /// A short, human-readable label — display only.
115 pub label: String,
116 /// How many files this checkpoint's manifest lists.
117 pub file_count: usize,
118}
119
120/// The outcome of a [`CheckpointStore::restore`] call.
121#[derive(Debug, Clone, Default, Serialize, Deserialize)]
122pub struct RestoreReport {
123 /// Files successfully restored (project-relative paths).
124 pub restored: Vec<String>,
125 /// Files refused, with the reason — e.g. an out-of-root or
126 /// protected-path target. Non-empty means the restore was PARTIAL; a
127 /// caller must surface this, never silently swallow it.
128 pub refused: Vec<(String, String)>,
129}
130
131/// A directory-backed, content-addressed shadow store rooted at `root` —
132/// see the module doc comment. Never touches anything outside `root` except
133/// (during [`Self::restore`]) the project files a manifest names, which are
134/// re-validated against `project_root` independently of how the manifest
135/// was produced.
136#[derive(Debug)]
137pub struct CheckpointStore {
138 root: PathBuf,
139}
140
141impl CheckpointStore {
142 /// Open (creating if needed) a store at `root`. `Err` if `root` can't
143 /// be created (e.g. an unwritable state dir) — callers (see
144 /// [`observer_for_config`]) treat that as "disable checkpoint, warn
145 /// once", never a crash.
146 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
147 let root = root.into();
148 std::fs::create_dir_all(root.join("objects"))?;
149 std::fs::create_dir_all(root.join("checkpoints"))?;
150 Ok(CheckpointStore { root })
151 }
152
153 fn objects_dir(&self) -> PathBuf {
154 self.root.join("objects")
155 }
156 fn checkpoints_dir(&self) -> PathBuf {
157 self.root.join("checkpoints")
158 }
159 fn manifest_path(&self, id: &str) -> PathBuf {
160 self.checkpoints_dir().join(format!("{id}.json"))
161 }
162 fn blob_path(&self, hash: &str) -> PathBuf {
163 let prefix = &hash[..hash.len().min(2)];
164 self.objects_dir().join(prefix).join(hash)
165 }
166
167 /// Reject an `id` that could escape the store root when joined into a
168 /// path — the exact same defensive shape as
169 /// `crate::store::SessionStore::validate_name`, since a `checkpoint
170 /// restore <id>`/`checkpoint diff <id>` CLI argument is untrusted user
171 /// input by the time it reaches here.
172 fn validate_id(id: &str) -> Result<()> {
173 let bad = id.is_empty()
174 || id.contains('/')
175 || id.contains('\\')
176 || id.contains('\0')
177 || id.split(['/', '\\']).any(|c| c == ".." || c == ".")
178 || Path::new(id).is_absolute()
179 || id.trim() != id;
180 if bad {
181 return Err(Error::Other(format!("invalid checkpoint id: `{id}`")));
182 }
183 Ok(())
184 }
185
186 /// Content-address `content`, writing it only if not already present
187 /// (dedup — the same content written by two different checkpoints costs
188 /// one blob). Atomic (write-to-tmp, rename) so a concurrent reader
189 /// never observes a partial blob; a lost race against another writer
190 /// racing the SAME hash is harmless (identical content either way).
191 fn write_blob(&self, content: &[u8]) -> Result<String> {
192 let hash = crate::reduce::content_hash(content);
193 let dest = self.blob_path(&hash);
194 if dest.exists() {
195 return Ok(hash);
196 }
197 let Some(parent) = dest.parent() else {
198 return Err(Error::Other("blob path has no parent".to_string()));
199 };
200 std::fs::create_dir_all(parent)?;
201 let tmp = parent.join(format!(".tmp-{}-{}", std::process::id(), mint_id()));
202 std::fs::write(&tmp, content)?;
203 match std::fs::rename(&tmp, &dest) {
204 Ok(()) => {}
205 Err(e) if dest.exists() => {
206 // Another writer won the race with identical content.
207 let _ = std::fs::remove_file(&tmp);
208 let _ = e;
209 }
210 Err(e) => return Err(e.into()),
211 }
212 Ok(hash)
213 }
214
215 fn read_blob(&self, hash: &str) -> Result<Vec<u8>> {
216 std::fs::read(self.blob_path(hash)).map_err(Into::into)
217 }
218
219 fn write_manifest(&self, m: &CheckpointManifest) -> Result<()> {
220 Self::validate_id(&m.id)?;
221 let dest = self.manifest_path(&m.id);
222 let json = serde_json::to_vec_pretty(m).map_err(|e| Error::Other(e.to_string()))?;
223 let dir = self.checkpoints_dir();
224 std::fs::create_dir_all(&dir)?;
225 let tmp = dir.join(format!(".tmp-{}-{}", std::process::id(), mint_id()));
226 std::fs::write(&tmp, &json)?;
227 std::fs::rename(&tmp, &dest)?;
228 Ok(())
229 }
230
231 /// Mint a fresh checkpoint (a new turn) with an empty file list. `label`
232 /// is display-only.
233 pub fn create_checkpoint(&self, label: &str) -> Result<CheckpointId> {
234 let id = mint_id();
235 let manifest = CheckpointManifest {
236 id: id.clone(),
237 created_at_ms: now_ms(),
238 label: label.to_string(),
239 files: Vec::new(),
240 };
241 self.write_manifest(&manifest)?;
242 Ok(id)
243 }
244
245 /// Idempotently record `rel`'s pre-image under checkpoint `id` — a
246 /// SECOND call for the same `(id, rel)` pair is a no-op (the manifest
247 /// always keeps the EARLIEST pre-image seen this turn, which is the one
248 /// a revert needs). `content: None` means the file did not exist yet.
249 pub fn record_pre_image(&self, id: &str, rel: &str, content: Option<Vec<u8>>) -> Result<()> {
250 let mut manifest = self.manifest(id)?;
251 if manifest.files.iter().any(|f| f.path == rel) {
252 return Ok(());
253 }
254 let blob = match content {
255 Some(bytes) => Some(self.write_blob(&bytes)?),
256 None => None,
257 };
258 manifest.files.push(CheckpointFileEntry {
259 path: rel.to_string(),
260 blob,
261 });
262 self.write_manifest(&manifest)
263 }
264
265 /// Read one checkpoint's full manifest.
266 pub fn manifest(&self, id: &str) -> Result<CheckpointManifest> {
267 Self::validate_id(id)?;
268 let text = std::fs::read_to_string(self.manifest_path(id))
269 .map_err(|e| Error::Other(format!("checkpoint `{id}` not found: {e}")))?;
270 serde_json::from_str(&text)
271 .map_err(|e| Error::Other(format!("checkpoint `{id}` manifest is corrupt: {e}")))
272 }
273
274 /// Every checkpoint in the store, newest first (ids are millis-prefixed
275 /// so lexical descending order IS chronological descending order). A
276 /// corrupt individual manifest is skipped (resilience — one bad file
277 /// never hides every other checkpoint), not a hard error.
278 pub fn list(&self) -> Result<Vec<CheckpointMeta>> {
279 let dir = self.checkpoints_dir();
280 let mut metas = Vec::new();
281 if !dir.exists() {
282 return Ok(metas);
283 }
284 for entry in std::fs::read_dir(&dir)? {
285 let entry = entry?;
286 let path = entry.path();
287 if path.extension().and_then(|e| e.to_str()) != Some("json") {
288 continue;
289 }
290 let Ok(text) = std::fs::read_to_string(&path) else {
291 continue;
292 };
293 if let Ok(m) = serde_json::from_str::<CheckpointManifest>(&text) {
294 metas.push(CheckpointMeta {
295 id: m.id,
296 created_at_ms: m.created_at_ms,
297 label: m.label,
298 file_count: m.files.len(),
299 });
300 }
301 }
302 metas.sort_by(|a, b| b.id.cmp(&a.id));
303 Ok(metas)
304 }
305
306 /// D3 turn-diff: the set of project-relative paths this checkpoint's
307 /// turn touched — exactly the manifest's file list (every entry exists
308 /// BECAUSE a write-tool call captured a pre-image for it this turn).
309 pub fn turn_diff(&self, id: &str) -> Result<Vec<String>> {
310 Ok(self
311 .manifest(id)?
312 .files
313 .into_iter()
314 .map(|f| f.path)
315 .collect())
316 }
317
318 /// BP-7 (catalog §4a "Turn diff tracking": "cumulative file-diff of
319 /// the turn"): the turn's cumulative UNIFIED DIFF, not just the file
320 /// list [`Self::turn_diff`] returns.
321 ///
322 /// Each manifest entry is a pre-image (`None` = the file did not exist
323 /// before the turn); the post-image is whatever is on disk under
324 /// `project_root` NOW (missing = the turn deleted it). So this is the
325 /// net effect of the whole turn — a file written three times in one
326 /// turn shows one diff from where it started to where it ended, which
327 /// is exactly what "cumulative" means and what cc's `/diff` and cx's
328 /// `turn_diff_tracker` show.
329 ///
330 /// Non-UTF-8 content on either side is reported as
331 /// `Binary files a/<path> and b/<path> differ` rather than being
332 /// diffed byte-wise into garbage; a file whose content is unchanged
333 /// contributes nothing. Entries are emitted in sorted path order so
334 /// the output is stable across runs.
335 pub fn turn_patch(&self, id: &str, project_root: &Path) -> Result<String> {
336 let mut entries = self.manifest(id)?.files;
337 entries.sort_by(|a, b| a.path.cmp(&b.path));
338 let mut out = String::new();
339 for entry in entries {
340 let before: Option<Vec<u8>> = match &entry.blob {
341 Some(blob) => Some(self.read_blob(blob)?),
342 None => None,
343 };
344 let after = std::fs::read(project_root.join(&entry.path)).ok();
345 if before == after {
346 continue;
347 }
348 let (Some(before_text), Some(after_text)) = (
349 decode_side(before.as_deref()),
350 decode_side(after.as_deref()),
351 ) else {
352 out.push_str(&format!(
353 "Binary files a/{0} and b/{0} differ
354",
355 entry.path
356 ));
357 continue;
358 };
359 let patch = diffy::create_patch(&before_text, &after_text);
360 out.push_str(&format!(
361 "--- a/{0}
362+++ b/{0}
363",
364 entry.path
365 ));
366 // `diffy` emits its own `---`/`+++` header lines with no file
367 // names; the project-relative pair above replaces them, so the
368 // output reads like an ordinary `git diff`.
369 for line in patch.to_string().lines().skip(2) {
370 out.push_str(line);
371 out.push('\n');
372 }
373 }
374 Ok(out)
375 }
376
377 /// D4-adjacent revert: restore `project_root`'s working files to
378 /// checkpoint `id`. For each manifest entry: `Some(blob)` rewrites the
379 /// file to that pre-image; `None` (didn't exist before the turn)
380 /// deletes it if present now (undoing a create). SECURITY: every
381 /// target is independently re-validated (never trusts the manifest was
382 /// produced honestly) against `project_root` containment (no symlink
383 /// escape, no `..` traversal) AND `protected_globs` (plus an
384 /// unconditional `.git/**` floor) checked against BOTH the LEXICAL
385 /// normalized path and the symlink-RESOLVED path — a lexical-only
386 /// check would miss a manifest entry like `foo/config` where `foo` is
387 /// a pre-existing symlink into `.git`: lexically it's clean, but it
388 /// resolves inside `root` (so containment alone accepts it too) and
389 /// lands on the real `.git/config`. A refused entry is recorded in
390 /// [`RestoreReport::refused`], never silently applied AND never aborts
391 /// the rest of the restore (partial-success, fully reported).
392 pub fn restore(
393 &self,
394 id: &str,
395 project_root: &Path,
396 protected_globs: &[String],
397 ) -> Result<RestoreReport> {
398 let manifest = self.manifest(id)?;
399 let mut report = RestoreReport::default();
400 for entry in &manifest.files {
401 // (1) Up-front rejection: a legitimately-captured entry (see
402 // `CheckpointFileEntry::path`'s doc comment) is always a clean
403 // relative path — `record_pre_image` never produces an absolute
404 // path or a `..` component. An entry that has one is by
405 // definition hostile or corrupt (traversal-injected, or a
406 // corrupted/copied/shared manifest — exactly this module's
407 // stated threat model) and must never reach a raw-string
408 // pattern match at all.
409 if let Some(reason) = reject_unsafe_manifest_path(&entry.path) {
410 report.refused.push((entry.path.clone(), reason));
411 continue;
412 }
413 let target = project_root.join(&entry.path);
414 // (2) Normalize-then-protect: compute the SAME lexically-
415 // normalized project-relative path `contained()` uses
416 // internally, ONCE, and check `is_protected` against THAT
417 // (not the raw manifest string) — so a `.git/**`-floor or
418 // `protected_globs` bypass via `x/../.git/config` can no
419 // longer disagree between the two checks (the root cause of
420 // the bug this replaces: `is_protected` matched the raw
421 // string while `contained` matched the normalized path).
422 let Some(normalized_rel) = normalized_project_rel(project_root, &target) else {
423 report.refused.push((
424 entry.path.clone(),
425 "refused: escapes the project root".to_string(),
426 ));
427 continue;
428 };
429 if is_protected(&normalized_rel, protected_globs) {
430 report
431 .refused
432 .push((entry.path.clone(), "refused: protected path".to_string()));
433 continue;
434 }
435 // Belt-and-suspenders: the existing symlink-safe containment
436 // check (canonicalizes the longest existing ancestor, refusing
437 // any symlink escape) still runs unconditionally.
438 if !contained(project_root, &target) {
439 report.refused.push((
440 entry.path.clone(),
441 "refused: escapes the project root".to_string(),
442 ));
443 continue;
444 }
445 // (3) Resolved-then-protect: `normalized_rel` above is a purely
446 // LEXICAL collapse — it never resolves symlinks — while
447 // `contained` (just above) DOES resolve symlinks when it
448 // canonicalizes the longest existing ancestor. Those two views
449 // of the path can disagree exactly when a component of `target`
450 // is a symlink: e.g. a pre-existing `foo -> .git` inside
451 // `project_root` plus a manifest entry `foo/config` lexically
452 // normalizes to `foo/config` (not protected — no literal
453 // `.git/` prefix) yet resolves to `<root>/.git/config` (still
454 // "contained" under `project_root`, so the escape check above
455 // doesn't catch it either — it never leaves the root, it just
456 // lands somewhere the lexical path didn't say). Re-run
457 // `is_protected` against the RESOLVED, symlink-followed
458 // project-relative path too, so this can't slip through: the
459 // `.git`/`protected_globs` floor now sees BOTH the lexical and
460 // the resolved view, and refuses if EITHER is protected.
461 if let Some(resolved_rel) = resolved_project_rel(project_root, &target) {
462 if is_protected(&resolved_rel, protected_globs) {
463 report
464 .refused
465 .push((entry.path.clone(), "refused: protected path".to_string()));
466 continue;
467 }
468 }
469 match &entry.blob {
470 Some(hash) => {
471 let bytes = match self.read_blob(hash) {
472 Ok(b) => b,
473 Err(e) => {
474 report
475 .refused
476 .push((entry.path.clone(), format!("blob unreadable: {e}")));
477 continue;
478 }
479 };
480 if let Some(parent) = target.parent() {
481 let _ = std::fs::create_dir_all(parent);
482 }
483 if let Err(e) = std::fs::write(&target, &bytes) {
484 report
485 .refused
486 .push((entry.path.clone(), format!("write failed: {e}")));
487 continue;
488 }
489 }
490 None if target.exists() => {
491 if let Err(e) = std::fs::remove_file(&target) {
492 report
493 .refused
494 .push((entry.path.clone(), format!("delete failed: {e}")));
495 continue;
496 }
497 }
498 None => {}
499 }
500 report.restored.push(entry.path.clone());
501 }
502 Ok(report)
503 }
504
505 /// Bounded-disk requirement: keep only the `keep` newest checkpoints,
506 /// deleting the rest, then GC any blob no longer referenced by a
507 /// surviving manifest. Returns the number of checkpoints removed.
508 pub fn prune(&self, keep: usize) -> Result<usize> {
509 let mut metas = self.list()?; // newest first
510 if metas.len() <= keep {
511 return Ok(0);
512 }
513 let stale = metas.split_off(keep);
514 let removed = stale.len();
515 for m in stale {
516 let _ = std::fs::remove_file(self.manifest_path(&m.id));
517 }
518 self.gc_unreferenced_blobs()?;
519 Ok(removed)
520 }
521
522 /// Delete every blob under `objects/` not referenced by ANY surviving
523 /// manifest — a full scan, not a refcount (simplest correct form; the
524 /// retention bound keeps this cheap in practice — see [`Self::prune`]'s
525 /// doc comment).
526 fn gc_unreferenced_blobs(&self) -> Result<()> {
527 let mut referenced: HashSet<String> = HashSet::new();
528 for meta in self.list()? {
529 if let Ok(m) = self.manifest(&meta.id) {
530 for f in m.files {
531 if let Some(b) = f.blob {
532 referenced.insert(b);
533 }
534 }
535 }
536 }
537 let objects = self.objects_dir();
538 if !objects.exists() {
539 return Ok(());
540 }
541 for entry in std::fs::read_dir(&objects)? {
542 let entry = entry?;
543 if !entry.file_type()?.is_dir() {
544 continue;
545 }
546 for inner in std::fs::read_dir(entry.path())? {
547 let inner = inner?;
548 let name = inner.file_name();
549 let Some(name) = name.to_str() else {
550 continue;
551 };
552 if name.starts_with(".tmp-") {
553 continue; // an in-flight write, not ours to reap
554 }
555 if !referenced.contains(name) {
556 let _ = std::fs::remove_file(inner.path());
557 }
558 }
559 }
560 Ok(())
561 }
562}
563
564// SECURITY (safe-path consolidation, CRITICAL fix): every primitive below
565// used to be defined HERE, locally — this module's own P5-9 fix for the
566// `.git`-clobber bug class. It is now `crate::safe_path`'s canonical
567// implementation instead, with checkpoint DELEGATING to it (thin wrappers,
568// same names, same signatures, same behavior) so `crate::permissions`'s
569// gate and `crate::tools`'s sandbox containment reuse this exact proven
570// logic rather than each re-implementing (and, in the permissions gate's
571// case, getting wrong) their own. See `crate::safe_path`'s module doc
572// comment for the full story and the two-views-of-a-path explanation every
573// doc comment below used to carry inline.
574//
575// Every one of this module's 23 tests (see `tests` below) still exercises
576// these names directly and is UNCHANGED — that is the proof this extraction
577// is behavior-preserving.
578
579/// Is `rel` (a project-relative, `/`-separated path) a hard floor this
580/// module refuses to snapshot INTO or restore OVER, regardless of config?
581/// `.git` (and everything under it) is unconditional — checkpoint must
582/// never touch the user's real git repo even if `permissions.protected_paths`
583/// (module 13) is off. `extra_globs` layers `Config::permissions_protected_paths`
584/// on top when the caller has one (restore only — capture-time protection
585/// is already covered because [`CheckpointObserver::before_write`] only
586/// ever fires for a write the P5-1 permission gate already approved, which
587/// already folds `protected_paths` in — see that method's own doc comment).
588/// See `crate::safe_path::is_protected` for the implementation.
589fn is_protected(rel: &str, extra_globs: &[String]) -> bool {
590 crate::safe_path::is_protected(rel, extra_globs)
591}
592
593/// Up-front rejection for a manifest-declared path that could never have
594/// come from a legitimate capture: `record_pre_image` only ever stores a
595/// clean, `/`-separated, project-relative path (see
596/// [`CheckpointFileEntry::path`]'s doc comment), so an absolute path or one
597/// containing a `..` (`ParentDir`), root, or Windows-prefix component is by
598/// definition hostile or corrupt. Returns the refusal reason, or `None` if
599/// `rel` is clean. Called BEFORE any raw-string pattern match (e.g.
600/// [`is_protected`]) so a traversal entry like `x/../.git/config` — which
601/// does not literally string-match the `.git/` floor — is refused before
602/// it can ever be compared against anything. See
603/// `crate::safe_path::reject_unsafe_rel_path` for the implementation.
604fn reject_unsafe_manifest_path(rel: &str) -> Option<String> {
605 crate::safe_path::reject_unsafe_rel_path(rel)
606}
607
608/// Lexically normalize `path` (expected to be `root.join(rel)` for some
609/// manifest-declared `rel`) via the SAME collapse [`contained`] uses, then —
610/// if the normalized form is still under `root` — return its
611/// project-relative, `/`-separated tail. Callers (currently only
612/// [`CheckpointStore::restore`]) compute this ONCE per entry and feed the
613/// single result to [`is_protected`], so the protected-path floor sees
614/// EXACTLY the same normalized path [`contained`]'s containment check
615/// computes. See `crate::safe_path::normalized_project_rel` for the
616/// implementation.
617fn normalized_project_rel(root: &Path, path: &Path) -> Option<String> {
618 crate::safe_path::normalized_project_rel(root, path)
619}
620
621/// Symlink- and traversal-safe containment check: is `path` (absolute,
622/// possibly not-yet-existing) confined under `root`? Fails closed (`false`)
623/// on any resolution error. See `crate::safe_path::contained` for the
624/// implementation.
625fn contained(root: &Path, path: &Path) -> bool {
626 crate::safe_path::contained(root, path)
627}
628
629/// Resolved (symlink-following) counterpart to [`normalized_project_rel`]:
630/// canonicalizes `path`'s longest existing ancestor (resolving any symlink
631/// along the way — see `crate::safe_path::resolve_real`) and returns the
632/// resulting absolute path's tail relative to `root`'s own canonical form, as a
633/// `/`-separated string — or `None` if resolution fails, or the resolved
634/// path lands outside `root` entirely (that case is already refused by
635/// [`contained`]; this function only needs to report a rel path when
636/// there IS one). [`CheckpointStore::restore`] feeds this to
637/// [`is_protected`] IN ADDITION TO the lexical [`normalized_project_rel`]
638/// result, closing the gap where a symlink resolves into `.git` (or a
639/// `protected_globs` match) even though its LEXICAL path never mentions
640/// `.git` at all and it never leaves `root` (so `contained` alone would
641/// accept it) — see the call site's doc comment for the exact repro this
642/// closes. See `crate::safe_path::resolved_project_rel` for the
643/// implementation.
644fn resolved_project_rel(root: &Path, path: &Path) -> Option<String> {
645 crate::safe_path::resolved_project_rel(root, path)
646}
647
648#[derive(Debug, Default)]
649struct ObserverState {
650 /// The checkpoint currently open for the in-flight turn.
651 current: Option<CheckpointId>,
652 /// Project-relative paths already captured under `current` — first
653 /// write per turn wins the pre-image; every write after the first is a
654 /// no-op capture (the manifest already holds the earliest state).
655 captured: HashSet<String>,
656}
657
658/// [`WriteObserver`] implementation backing `[capabilities.checkpoint]`.
659/// One instance per [`crate::Agent`] (installed on its [`crate::tools::ToolContext`]
660/// AND held directly so `crate::Agent::run_loop` can call
661/// [`Self::begin_turn`] once per user turn — see that method's call site's
662/// doc comment).
663#[derive(Debug)]
664pub struct CheckpointObserver {
665 store: CheckpointStore,
666 project_root: PathBuf,
667 retain: usize,
668 protected: Vec<String>,
669 /// BP-7 (§3.1 `capabilities.checkpoint.restore`): see
670 /// [`Self::restore_enabled`].
671 restore_enabled: bool,
672 state: Mutex<ObserverState>,
673 /// Set once on any I/O failure — graceful degrade (§ "git-absent /
674 /// unwritable state dir ⇒ warn + disable, never crash"), not a panic
675 /// and not a blocked tool call. `Relaxed` throughout: this is a
676 /// best-effort circuit breaker, not a correctness-critical ordering.
677 disabled: AtomicBool,
678}
679
680impl CheckpointObserver {
681 /// `protected` layers extra glob patterns (typically
682 /// `Config::permissions_protected_paths`, when module 13 is active) on
683 /// top of the unconditional `.git/**` floor — consulted by
684 /// [`Self::restore`].
685 pub fn new(
686 store: CheckpointStore,
687 project_root: PathBuf,
688 retain: usize,
689 protected: Vec<String>,
690 ) -> Self {
691 Self::with_restore(store, project_root, retain, protected, true)
692 }
693
694 /// BP-7: [`Self::new`] with the restore half explicitly gated — see
695 /// [`Self::restore_enabled`].
696 pub fn with_restore(
697 store: CheckpointStore,
698 project_root: PathBuf,
699 retain: usize,
700 protected: Vec<String>,
701 restore_enabled: bool,
702 ) -> Self {
703 CheckpointObserver {
704 store,
705 project_root,
706 retain: retain.max(1),
707 protected,
708 restore_enabled,
709 state: Mutex::new(ObserverState::default()),
710 disabled: AtomicBool::new(false),
711 }
712 }
713
714 /// Read-only access to the underlying store (e.g. so a caller can
715 /// `list`/`turn_diff`/`restore` without re-deriving the root path).
716 pub fn store(&self) -> &CheckpointStore {
717 &self.store
718 }
719
720 /// List every checkpoint, newest first — see [`CheckpointStore::list`].
721 pub fn list(&self) -> Result<Vec<CheckpointMeta>> {
722 self.store.list()
723 }
724
725 /// D3 turn-diff for one checkpoint — see [`CheckpointStore::turn_diff`].
726 pub fn turn_diff(&self, id: &str) -> Result<Vec<String>> {
727 self.store.turn_diff(id)
728 }
729
730 /// BP-7: the turn's cumulative unified diff against THIS observer's own
731 /// project root — see [`CheckpointStore::turn_patch`].
732 pub fn turn_patch(&self, id: &str) -> Result<String> {
733 self.store.turn_patch(id, &self.project_root)
734 }
735
736 /// BP-7 (§3.1 `capabilities.checkpoint.restore`): whether this
737 /// observer may put files back. `false` is the turn-diff-only posture
738 /// cx-parity takes — Codex tracks each turn's diff but has no code
739 /// restore at all (its `ghost_snapshot` is a stripped legacy no-op), so
740 /// a preset that transcribes Codex must be able to arm the tracking
741 /// half without the restoring half.
742 pub fn restore_enabled(&self) -> bool {
743 self.restore_enabled
744 }
745
746 /// D4-adjacent revert: restore this project's working files to
747 /// checkpoint `id`, honoring THIS observer's own `project_root` and
748 /// `protected` globs (the fields set at construction) — see
749 /// [`CheckpointStore::restore`] for the full security contract.
750 pub fn restore(&self, id: &str) -> Result<RestoreReport> {
751 if !self.restore_enabled {
752 return Err(Error::Other(
753 "checkpoint restore is off for this harness (`[capabilities.checkpoint] restore = false`) — this preset tracks each turn's diff but has no code-restore surface"
754 .to_string(),
755 ));
756 }
757 self.store.restore(id, &self.project_root, &self.protected)
758 }
759
760 /// Whether this observer has disabled itself after an I/O failure.
761 pub fn is_disabled(&self) -> bool {
762 self.disabled.load(Ordering::Relaxed)
763 }
764
765 /// Open a fresh checkpoint for a new turn — called once at the top of
766 /// `crate::Agent::run_loop` (i.e. once per `Agent::send`/
767 /// `send_with_files`/`send_with_images` call, cc's "per-prompt
768 /// file-history-snapshot"). `label` is a short excerpt of the turn's
769 /// prompt, display-only. Also prunes past the retention bound here
770 /// (once per turn, not once per write) — see [`CheckpointStore::prune`].
771 /// Returns `None` when disabled (config-off is never routed here at
772 /// all — see [`observer_for_config`] — so `None` here specifically
773 /// means an I/O failure already tripped the breaker).
774 pub fn begin_turn(&self, label: &str) -> Option<CheckpointId> {
775 if self.disabled.load(Ordering::Relaxed) {
776 return None;
777 }
778 let short: String = label.chars().take(120).collect();
779 match self.store.create_checkpoint(&short) {
780 Ok(id) => {
781 if let Ok(mut st) = self.state.lock() {
782 st.current = Some(id.clone());
783 st.captured.clear();
784 }
785 if let Err(e) = self.store.prune(self.retain) {
786 eprintln!("warning: checkpoint: prune failed: {e}");
787 }
788 Some(id)
789 }
790 Err(e) => {
791 eprintln!(
792 "warning: checkpoint disabled for the rest of this session — \
793 failed to open a new checkpoint: {e}"
794 );
795 self.disabled.store(true, Ordering::Relaxed);
796 None
797 }
798 }
799 }
800
801 /// The checkpoint currently open for the in-flight turn, if any.
802 pub fn current(&self) -> Option<CheckpointId> {
803 self.state.lock().ok().and_then(|s| s.current.clone())
804 }
805}
806
807#[async_trait::async_trait]
808impl WriteObserver for CheckpointObserver {
809 async fn before_write(&self, path: &Path) {
810 if self.disabled.load(Ordering::Relaxed) {
811 return;
812 }
813 if !contained(&self.project_root, path) {
814 // Outside the project root — out of this module's scope (§ "an
815 // honest gap", never a crash or a wrong snapshot).
816 return;
817 }
818 let Some(normalized) = crate::tools::normalize(path) else {
819 return;
820 };
821 let Some(root_normalized) = crate::tools::normalize(&self.project_root) else {
822 return;
823 };
824 let Ok(rel_path) = normalized.strip_prefix(&root_normalized) else {
825 return;
826 };
827 let rel = rel_path.to_string_lossy().replace('\\', "/");
828 if rel.is_empty() || rel == ".git" || rel.starts_with(".git/") {
829 return;
830 }
831
832 // The ENTIRE check-read-record sequence runs under one lock, so two
833 // concurrent writes to the SAME path (e.g. a `run_tools_concurrently`
834 // batch, or a P5-6 background job racing a foreground write) can
835 // never both read-and-lose a torn pre-image: the second one to
836 // arrive here always sees `captured` already contains `rel` and
837 // skips entirely, never re-reading a post-first-write state.
838 let mut st = match self.state.lock() {
839 Ok(g) => g,
840 Err(poisoned) => poisoned.into_inner(),
841 };
842 if st.current.is_none() {
843 // A write reached this seam with no open turn (e.g. a caller
844 // driving the tool registry directly, outside `Agent::send`) —
845 // self-heal with an ad-hoc checkpoint rather than silently
846 // dropping the capture. `begin_turn` takes this same lock, so
847 // it must be called with `st` released first.
848 drop(st);
849 self.begin_turn("untracked");
850 st = match self.state.lock() {
851 Ok(g) => g,
852 Err(poisoned) => poisoned.into_inner(),
853 };
854 }
855 if st.captured.contains(&rel) {
856 return;
857 }
858 let Some(id) = st.current.clone() else {
859 return; // begin_turn's own failure already warned + disabled
860 };
861 let content = std::fs::read(path).ok(); // None => doesn't exist yet (a create)
862 match self.store.record_pre_image(&id, &rel, content) {
863 Ok(()) => {
864 st.captured.insert(rel);
865 }
866 Err(e) => {
867 eprintln!(
868 "warning: checkpoint disabled for the rest of this session — \
869 failed to record a snapshot: {e}"
870 );
871 self.disabled.store(true, Ordering::Relaxed);
872 }
873 }
874 }
875
876 async fn after_write(&self, _path: &Path) -> Option<String> {
877 // P5-11: `formatters`/`lsp` now occupy this hook (via
878 // `crate::tools::WriteObserverChain`, installed AFTER this observer
879 // in `crate::agent::build_tool_context`'s chain) — checkpoint itself
880 // still has nothing to do after a write completes, and returning
881 // `None` keeps the tool-result text this hook contributes
882 // byte-identical to before P5-11 whenever checkpoint is the only
883 // observer installed.
884 None
885 }
886}
887
888/// A stable per-project-directory tag — the same hash-of-canonicalized-cwd
889/// idea `crates/cli/src/main.rs::cwd_tag` uses for session naming, kept
890/// separately here (a `core`-crate concern, and `cli` depends on `core` not
891/// the reverse) so two different projects never share one shadow store even
892/// though they'd otherwise both resolve to the same `$SUPERCODE_HOME`-
893/// derived parent directory.
894///
895/// BP-10: `pub(crate)` because the persisted approval cache
896/// (`crate::permissions::approval`) keys its own per-project store the same
897/// way — the SAME tag function, not a second hash with the same idea, so
898/// one project's checkpoints and its remembered approvals can never
899/// disagree about which project they belong to.
900pub(crate) fn project_tag(cwd: &Path) -> String {
901 use std::hash::{Hash, Hasher};
902 let canon = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
903 let mut h = std::collections::hash_map::DefaultHasher::new();
904 canon.hash(&mut h);
905 format!("{:016x}", h.finish())
906}
907
908/// The DEFAULT shadow-store root for `config.cwd` when
909/// [`crate::Config::checkpoint_dir`] is unset: `$SUPERCODE_HOME/checkpoints/<project_tag>`
910/// (`crate::agent::global_instructions_dir` is the same `$SUPERCODE_HOME`
911/// resolver the global instruction tier and `checkpoint` both use).
912fn default_shadow_root(cwd: &Path) -> PathBuf {
913 crate::agent::global_instructions_dir()
914 .join("checkpoints")
915 .join(project_tag(cwd))
916}
917
918/// Build the [`CheckpointObserver`] a fresh [`crate::Agent`] should install,
919/// given a resolved [`crate::Config`] — called once, from
920/// `crate::agent::build_tool_context`. `Config::checkpoint_enabled` is the
921/// ONE gate: `false` (the default) returns `None` WITHOUT touching the
922/// filesystem at all (no `CheckpointStore::open`, no directory created) —
923/// the default-off byte-identity guarantee. `true` opens (creating if
924/// needed) the shadow store at `Config::checkpoint_dir`, or
925/// `default_shadow_root` when that's unset; an I/O failure (unwritable
926/// state dir) is reported via a one-time `eprintln!` warning and returns
927/// `None` — graceful degrade, never a crash, never a blocked `Agent::new`.
928/// BP-7: one side of a [`CheckpointStore::turn_patch`] entry as text.
929/// `None` (absent file) decodes to the empty string — a create diffs
930/// against nothing and a delete diffs to nothing, which is what a unified
931/// diff of those cases should show. Non-UTF-8 bytes return `None`, which
932/// the caller renders as a "binary files differ" line rather than diffing
933/// them into garbage.
934fn decode_side(bytes: Option<&[u8]>) -> Option<String> {
935 match bytes {
936 None => Some(String::new()),
937 Some(b) => String::from_utf8(b.to_vec()).ok(),
938 }
939}
940
941pub fn observer_for_config(config: &crate::Config) -> Option<std::sync::Arc<CheckpointObserver>> {
942 if !config.checkpoint_enabled {
943 return None;
944 }
945 let root = config
946 .checkpoint_dir
947 .clone()
948 .unwrap_or_else(|| default_shadow_root(&config.cwd));
949 match CheckpointStore::open(&root) {
950 Ok(store) => Some(std::sync::Arc::new(CheckpointObserver::with_restore(
951 store,
952 config.cwd.clone(),
953 config.checkpoint_retain,
954 config.permissions_protected_paths.clone(),
955 config.checkpoint_restore,
956 ))),
957 Err(e) => {
958 eprintln!(
959 "warning: [capabilities.checkpoint] is enabled but the shadow store at \
960 {} could not be opened — checkpoint is disabled for this session: {e}",
961 root.display()
962 );
963 None
964 }
965 }
966}