From c83e19fcadad7d74818d326ed7d3f19feeba4938 Mon Sep 17 00:00:00 2001 From: integ Date: Fri, 11 Sep 2026 17:14:23 -0500 Subject: [PATCH] fix(patch): keep two Claude Code installs of one version from clobbering each other on restore Each pristine backup now records which installs its bytes are the pristine content of -- one small file per install beside the backup -- so `clodex patch --restore` no longer falls back to matching on the claude version in a file name when no patch manifest survives. The manifest could not carry this on its own: it records ONE install, and a successful restore deletes it. So patch install A, restore A, then have the target resolve to a different same-version install B, and the next restore published A's pristine bytes over B with nothing but a warning -- no lost files anywhere in that sequence. The same gap let a user who answered the "conflicting pristine backups" refusal by deleting a file launder the wrong bytes into a manifest that every later restore then trusted on sight. - A machine with two same-version installs now restores each from its own backup instead of refusing. That case previously had no evidence to decide on. - A record naming this install outranks a manifest that records a different one; a manifest that DISAGREES with a record about the same install refuses, because nothing dates either one. - Both `--restore` and `clodex patch` migrate what a manifest proved before replacing or clearing it, so an upgrading user's older backup is not left unattributed by the one command that had the evidence in hand. The manifest is dropped only once an established record stands in its place. - A backup matched on its version tag alone -- the remaining compatibility fallback -- is recorded AS a guess, and that guess is never re-derived as proof: not through the live bytes matching the backup the guess copied them from, not through the manifest the guessing run wrote, and not through the content address a legacy backup is adopted into. It is written before the binary changes, and refused outright if it cannot be. Running the same fallback for a SECOND install is refused. - Nothing promotes a guess, including a fresh snapshot of the very bytes the guess installed: the install holding them is a fact the guess created, and establishing on it would hand their true owner a refusal. An install whose record stays a guess is not stranded -- its own restores work. Confidence is resolved for the BYTES, not one filename, or a third name could still carry it. - A manifest that disagrees with a record about one install refuses only when it does NOT describe the live bytes. One install legitimately has two pristine snapshots when something rewrites its binary in place, and refusing there failed every later patch and restore permanently. - A record that exists but cannot be read refuses rather than falling back; damaged evidence that those bytes were claimed is not the same as no evidence. - No backup is published before its record, so an interrupted patch cannot leave an unattributed one behind. One file per install rather than a shared list: the patch lock lives under CLODEX_HOME while the backup directory is shared, so a merged list could lose an entry. Closes #204 --- .claude/docs/patcher.md | 163 +++++++++-- README.md | 2 +- src/patch-backup.ts | 376 +++++++++++++++++++++++-- src/patcher.ts | 188 ++++++++++++- tests/patch-backup.test.ts | 376 ++++++++++++++++++++++++- tests/patcher-command.test.ts | 500 +++++++++++++++++++++++++++++++++- 6 files changed, 1540 insertions(+), 65 deletions(-) diff --git a/.claude/docs/patcher.md b/.claude/docs/patcher.md index e8658f6b..5b6bc5b4 100644 --- a/.claude/docs/patcher.md +++ b/.claude/docs/patcher.md @@ -617,14 +617,15 @@ tweakcc's own repack reads back as an ordinary module name. unverifiable backups produce a loud error, never a copy. **A matching version tag is not install provenance**: the npm platform package and the native installer ship different files under the same Claude Code version, and both are supported, so one machine can hold two same-version installs whose - bytes differ. The manifest is the only record of which install a backup was made for, and it holds - **one** install — so it can confirm a backup but can never rule one in by elimination. Two states - therefore refuse outright rather than fall through to version-tag selection (issue #199, reproduced + bytes differ. The manifest holds **one** install — so it can confirm a backup but can never rule one + in by elimination (the per-backup sidecar below is what does that). Two states therefore refuse + outright rather than fall through to version-tag selection (issue #199, reproduced on real 2.1.266 binaries in issue #199, and again on real 2.1.263 bytes with a byte-differing second copy standing in for the second install; the published 2.1.263 npm and native artifacts are independently known to differ in both size and hash): - the manifest records a **different** `binaryPath` than the resolved target — it vouches for - nothing in the backup directory, and the error names the install it does belong to; + nothing in the backup directory, and the error names the install it does belong to (unless a + per-backup sidecar records this one; see below); - the manifest records **this** target but the pristine bytes it named are gone or corrupt — its own testimony says the same-version backups still on disk were made for some other install. @@ -635,18 +636,7 @@ tweakcc's own repack reads back as an ordinary module name. Disqualifying only the backup the manifest names is NOT sufficient and was rejected during review: the manifest holds one install, so every earlier install's backup is an unrecorded orphan carrying the same version tag, and "restore the one it did not name" hands a third install's bytes to the - target — turning a `conflicting pristine backups` refusal into a destructive copy. **What remains - unprotected is the no-manifest case**, and it is reachable by an ordinary sequence: a successful - `--restore` DELETES the manifest, so patch A → restore A → have the target resolve to install B → - restore again publishes A's pristine bytes over B, with only a warning. (`~/.clodex` wiped or a - different `CLODEX_HOME` get there too.) Selection there still rests on the version tag alone. - Worse, a user who answers the `conflicting pristine backups` refusal by deleting one file can - launder the wrong bytes into a manifest that the rules above then trust on sight — which is why - that message no longer says "remove the wrong one". Closing this needs provenance recorded per - backup rather than a single-slot manifest, or requiring `--restore` to see a `/*ccpatch:` marker in - the live binary before it copies anything (`clodex patch`'s implicit restore already inspects the - source; `--restore` never does). Tracked separately — do not read the rules above as a full - guarantee. + target — turning a `conflicting pristine backups` refusal into a destructive copy. Path identity is plain string equality, so a manifest written under a different spelling of the same path reads as another install and refuses — safe, but a false refusal, and it blocks @@ -668,6 +658,140 @@ tweakcc's own repack reads back as an ordinary module name. --restore`. That mirror is a SINGLE slot holding whichever install clodex patched last, so on a machine with two same-version installs `tweakcc --restore` performs exactly the copy the rules above refuse. clodex writes the file but does not control that command. +- **Each backup records which installs it was made for, in one file per install beside it** — + `claude--.orig.for-.json`, holding + `{"install": "", "assumed": }` (issue #204). The manifest could not carry this: it holds + **one** install and a successful `--restore` **deletes** it, so a backup routinely outlives the only + record of what it belonged to. patch A → restore A → have the target resolve to a different + same-version install B → restore again published A's pristine bytes over B with nothing but a + warning, and **no files were lost anywhere in that sequence**. (`~/.clodex` wiped or a different + `CLODEX_HOME` reach the same state.) A user who answered the `conflicting pristine backups` refusal + by deleting one file could also launder the wrong bytes into a manifest every later restore then + trusted on sight. + + **One file per install, never a shared list.** A single list would have to be read, merged and + rewritten, and the patch lock lives under `CLODEX_HOME` while the backup directory is shared — two + concurrent patches under different `CLODEX_HOME`s would lose an entry. A create-once file per + install has nothing to merge. Multiple installs is the normal case, not an edge: backups are + content-addressed, so two installs whose pristine bytes are identical legitimately share one backup + and it is correct for either. The install path is hashed only to keep the file name safe and + fixed-length; selection reads the path from inside the file, and a record whose name does not agree + with the install it holds is treated as damaged rather than trusted. + + Consequences: + - a backup recorded for some OTHER install is refused, exactly like a manifest that records one; + - a record naming THIS install outranks a manifest that records a different one — it is direct + evidence, where the manifest was only evidence about the directory; + - a two-install machine now **works** instead of refusing: each install's own backup names it, so + `Found conflicting pristine backups` is no longer the outcome of the ordinary two-install case; + - restoring one install therefore must NOT clear the manifest belonging to another — `--restore` + clears the manifest only when it records the install being restored; + - a manifest that disagrees with an established record for the same install refuses — **but only + when the manifest does not describe the live bytes**. That state is reachable both ways round: + one install path rewritten in place with a different build of one claude version (the manifest is + then stale), or one install legitimately snapshotted twice, which `tweakcc` theming produces and + the snapshot path keeps both files for on purpose. What separates them is + `manifest.patchedSha256 === liveSha256`: when it holds, clodex provably wrote those bytes last, so + the manifest is current and its backup is the right source. Refusing there instead made every + later patch AND restore of an ordinary single-install machine fail permanently, with a message + telling the user to reinstall — which does not clear a record. Where neither claim can be dated, + the refusal stands, and both it and the two-records-disagree refusal now **name the record files**, + because deleting the stale one is the way out; + - a record that exists but **cannot be read** refuses rather than falling back to the version tag. + That includes a record that is unopenable rather than malformed (a directory, a dangling link, + mode 000): its name came out of the same directory listing, so "absent" is not an available + reading. Damaged positive evidence is not the same as no evidence — something claimed those + bytes. The message names the file, and deleting it opts back into the fallback; + - the version-tag fallback is refused for an install these bytes were **already guessed onto**. The + first guess proves nothing about ownership, which is why it neither selects nor establishes, but + running the same fallback again is how one install's bytes reach two. A guess recorded for THIS + install does not refuse — that is the same decision being repeated, and the single-install machine + depends on it. + + **ESTABLISHED vs ASSUMED, and what may promote.** A record's `assumed` flag says whether the + association was proven or matched on a version tag, and the distinction has to survive every path + that could re-derive it: + - a guess is recorded AS a guess rather than skipped. Skipping was not enough: after a warned + version-tag restore the live bytes match the backup *because the guess put them there*, so the + next patch took the `reuse` path and recorded it as established; + - the manifest carries `pristineProvenance: 'assumed'` when the run that wrote it guessed, so the + next run cannot read a guessing run's own manifest as independent proof. Absent is read as + established, which is not a proof — a manifest written before the field existed may record a run + that guessed and is indistinguishable from one that did not. It is accepted because those + manifests are the migration path for every existing install; + - **every** plan **inherits** the confidence already recorded for those bytes. Confidence belongs to + the CONTENT, not to a filename: asking only about the name a plan chose left a third name carrying + the guess — restore B from a legacy backup by version tag, patch A so that backup is adopted under + its content address, then patch B, which picks the canonical name, finds no record of B beside it, + and established what the legacy name still called a guess. So every alias holding these exact bytes + is consulted, and the content address and the legacy name are read directly, because the scan finds + records only beside an existing `.orig` and a record outlives its backup. `reuse` matches bytes a guess may have put there, and + canonicalizing a legacy backup would otherwise launder a guess through a filename change. A + `snapshot` is no exception, even though it inspected the live bytes itself: if a guess restored + those very bytes onto this install, "the install holds them" is a fact the guess created, and + establishing on it would hand the bytes' true owner a refusal. **Nothing promotes a guess.** The + protection a promotion looked like it was buying is already provided by refusing the fallback for + an install these bytes were guessed onto, and an install whose record stays a guess is not + stranded — its own restores still work, with the note; + - an established record is never downgraded. Callers ask for what their run can prove, and a run + that can prove less must not erase what an earlier one knew. + + **Both migration sites cover a manifest for another VERSION at this same path**, not only one for + another install: its backup is still on disk and clearing the manifest would leave it unattributed. + + **`--restore` records before it writes, and before it clears.** Before the copy, because the guess + changes the live bytes to the backup's — after that, "the live bytes match this backup" is no longer + independent evidence, so the record saying it was a guess must already be on disk. If it cannot be + written, the guess is **refused** and the binary is untouched: it is the optional compatibility + path. Before the manifest is cleared, because the manifest may be the only thing attributing that + backup, and the manifest is dropped **only once an established record stands in its place** — a + manifest is stronger than a guess, so trading it for one destroys testimony instead of migrating it. + When the write fails on an established restore, the rescue still happens and the manifest is kept. + + **`clodex patch` migrates the manifest it is about to replace**, when that manifest records a + different install or a different claude version. The manifest holds one install, so patching a + second one used to strip the first's only attribution. + + **No pristine backup is published before its record** — not the fresh snapshot, and not the + canonical name a legacy backup is adopted into. A published `.orig` with no record beside it is + precisely the unattributed same-version file this exists to prevent, and a crash or a full disk + between the two writes would leave one for good. The reverse order is safe: a record whose backup + never appeared is inert, because scanning starts from the `.orig` files, and it stays true if those + bytes ever land at that address again. + + **What remains is a backup written before records existed**: nothing attributes it, so selection + falls back to the version tag and the plan carries a loud note saying exactly that. Refusing would + strand every backup an earlier clodex wrote, including on the single-install machine where the guess + is always right. It self-heals — `--restore` and `clodex patch` both migrate a manifest's evidence, + and any patch of that install writes a record. Three narrower gaps stay open and are not closed + here: + - **Path identity is still plain string equality** (see below), so a case-only respelling of an + install path on APFS reads as another install: the restore is refused rather than falling back — + safe, and the message names the recorded spelling to use — and a manifest for the same install + under the other spelling survives a restore rather than being cleared. The fix is one + path-equivalence helper over `realpathSync.native`/inode, which invalidates the `binaryPath` in + every existing manifest and so needs a migration. + - **Replacing the executable at the SAME path with a different same-version artifact** leaves a + record pointing at a path whose bytes are no longer the ones it was written for. Two established + records now disagree and refuse, and a manifest disagreeing with a record refuses too, so the + destructive form is narrowed to the case where neither exists yet. Closing it entirely needs + `--restore` to read the live bundle for a `/*ccpatch:` marker first — issue #204's fix 1 — which + is Mach-O/ELF/PE-sensitive and needs the per-format probe. + - **A record's confidence is written with temp + rename, not compare-and-swap.** Two processes + recording the SAME install under different `CLODEX_HOME`s can race, and the last rename wins, so + an established record can be replaced by a guess. It needs a lock keyed by the backup directory + rather than by `CLODEX_HOME`; the same missing lock already lets `--restore` (which takes no lock + at all) interleave with a patch on `main`. + + Two smaller consequences are accepted rather than fixed, both safe directions: + - an install whose only claim on a backup is a GUESS is refused when another install holds an + established record for those bytes, even though repeating its own guess used to work. The evidence + genuinely favours the other install; the message names it, and reinstalling clears it. + - the migration before a manifest is replaced checks `existsSync(manifest.backupPath)`, so a backup + that was manually MOVED to another same-version alias is not migrated to its new name. Finding the + bytes by hash instead would mean re-hashing every same-version backup (~250 MB each) on the + ordinary two-install patch, which is not worth it for a state only manual file movement reaches. + - **`clodex patch --restore` must work on a binary that no longer runs** — that is what a pristine backup is *for*. It resolves the version from `claude --version` when it can, and otherwise falls back to the manifest's `claudeVersion` when `manifest.binaryPath` matches the resolved install and @@ -675,9 +799,10 @@ tweakcc's own repack reads back as an ordinary module name. anything. An npm placeholder is the exception: it proves a package manager replaced the target, so the old manifest is no longer authoritative for that path even though the wrapper's `package.json` still reveals its version. The placeholder refusal preserves both manifest and backups. Generic - broken binaries retain manifest recovery. A successful restore drops the manifest, which is sound - only because a manifest recorded against a different install can no longer reach the copy — it is - refused during selection, so the other install's rescue record is never deleted with it. The patch + broken binaries retain manifest recovery. A successful restore drops the manifest **only when it + records the install that was restored**: selection can now succeed on a per-backup sidecar while the + manifest still holds a different install, and clearing it there would delete that install's only + rescue record. The patch path keeps the hard `version-unknown` failure (patching is elective; restoring is the way out), and its error message names `--restore` as the recovery. - **`CLODEX_CLAUDE_PATH` does not choose the patch target, and that is deliberate.** It selects the diff --git a/README.md b/README.md index 3671a5c7..0100c871 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ Patch the installed Claude Code binary so clodex favorites and aliases are first On Windows, when Claude Code was installed with `npm install -g`, the `claude` on your PATH is a small launcher script (`claude.cmd`, `claude.ps1`, and an extensionless one) rather than the program itself; `clodex patch` follows it to the program and patches that. (On macOS and Linux npm makes a symlink instead, which always worked.) If the launcher cannot be followed — its program moved or removed, or it names two different programs — `clodex patch` stops without touching anything and tells you to set `TWEAKCC_CC_INSTALLATION_PATH` to the program directly. -The patch map is built from your favorites and aliases; context windows come from provider metadata. A pristine per-version backup is kept, and a manifest (`~/.clodex/patch-state.json`) makes re-runs no-ops until your config or Claude Code version changes — then the binary is restored first and re-patched fresh. `clodex claude` checks patch freshness at launch and offers to re-patch (a non-blocking notice when not interactive). Re-run `clodex patch` after every `claude` update. +The patch map is built from your favorites and aliases; context windows come from provider metadata. A pristine per-version backup is kept — recorded against the install it was made for, so a machine with two installs of one Claude Code version restores each from its own bytes — and a manifest (`~/.clodex/patch-state.json`) makes re-runs no-ops until your config or Claude Code version changes — then the binary is restored first and re-patched fresh. `clodex claude` checks patch freshness at launch and offers to re-patch (a non-blocking notice when not interactive). Re-run `clodex patch` after every `claude` update. #### Local patches (trusted code) diff --git a/src/patch-backup.ts b/src/patch-backup.ts index 59432825..55c39aad 100644 --- a/src/patch-backup.ts +++ b/src/patch-backup.ts @@ -29,15 +29,28 @@ // made for, and it holds ONE install, so it can confirm a backup but never // rule one in by elimination: when it records a different install, or names // pristine bytes for this one that are no longer on disk, restoring is -// refused rather than guessed (issue #199). What remains is the case of no -// manifest at all, where selection still rests on the version tag; the plan -// carries a note saying so. +// refused rather than guessed (issue #199). A manifest is not enough on its +// own, though: it holds one install and a successful `--restore` DELETES it, +// so a backup routinely outlives the only record of what it belonged to. Each +// backup therefore also carries a PROVENANCE RECORD PER INSTALL beside it, +// naming an install whose pristine content it is and whether that association +// was established or merely assumed. Those records survive the manifest, and +// they are what lets two same-version installs coexist instead of one of them +// being handed the other's bytes (issue #204). A record that exists but cannot +// be read refuses rather than falling back — something claimed those bytes — and +// so does a manifest that disagrees with a record about the same install, unless +// the manifest describes the live bytes and is therefore provably the current one. Backups written before the records existed carry none +// at all; for those, and only those, selection still rests on the version tag, +// and then the plan says so and the guess itself is recorded as a guess so no +// later run can re-derive it as proof. // -// Everything here is deterministic given its inputs so the decisions can be -// tested directly; the caller performs the file copies. +// Everything that decides anything here is deterministic given its inputs so the +// decisions can be tested directly; the caller performs the file copies. The one +// write this module owns is a provenance record, which belongs beside the naming +// and scanning rules it is read by. import { createHash } from 'node:crypto'; -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -78,12 +91,144 @@ export function tweakccMirrorBackupPath(dir = backupDir()): string { return join(dir, 'native-binary.backup'); } +/** + * On-disk shape of one provenance record: which install the backup beside it was + * made for, and whether that association was ESTABLISHED or merely assumed. + */ +interface InstallProvenance { + install: string; + /** + * True when the only thing that tied this backup to that install was the claude + * version in its file name (issue #204's remaining compatibility fallback). Such + * a record never selects and never refuses anything — it exists to stop the guess + * from being promoted to an established one later, which is how a warned fallback + * used to turn into a permanent fact every run then trusted on sight. + */ + assumed: boolean; +} + +/** + * ONE FILE PER INSTALL, beside the backup: `.for-.json`. + * + * A single shared list would have to be read, merged and rewritten, and the patch + * lock lives under `CLODEX_HOME` while the backup directory is shared — two + * concurrent patches under different `CLODEX_HOME`s would lose one install's entry. + * A file per install is created once and never merged, so there is nothing to lose. + * The install path is hashed to keep the name filesystem-safe and fixed-length; the + * path itself is inside the file, which is what selection reads. + * + * The suffix keeps these out of the `claude-*.orig` pattern the scanner matches, and + * `tweakcc` only ever names `native-binary.backup` explicitly, so neither mistakes a + * record for a backup. + */ +export function installProvenancePath(backupPath: string, binaryPath: string): string { + const tag = createHash('sha256').update(binaryPath).digest('hex').slice(0, BACKUP_SHA_PREFIX_LENGTH); + return `${backupPath}.for-${tag}.json`; +} + +/** Matches any install's provenance record for the backup file `name`. */ +function installProvenancePattern(name: string): RegExp { + return new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.for-[0-9a-f]{${BACKUP_SHA_PREFIX_LENGTH}}\\.json$`); +} + +/** What one provenance record says, or `damaged` when it cannot be read. */ +export type ProvenanceRecord = InstallProvenance | 'damaged'; + +export function readInstallProvenance(path: string): ProvenanceRecord | null { + let raw: string; + try { + raw = readFileSync(path, 'utf8'); + } catch { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return 'damaged'; + } + if (typeof parsed !== 'object' || parsed === null) return 'damaged'; + const { install, assumed } = parsed as { install?: unknown; assumed?: unknown }; + // Both fields are required, and `assumed` must be a real boolean: anything else + // would read as `false` — the ESTABLISHED value — so a damaged former guess would + // silently become the strongest evidence in the directory. + if (typeof install !== 'string' || install === '') return 'damaged'; + if (typeof assumed !== 'boolean') return 'damaged'; + return { install, assumed }; +} + +/** + * Record `binaryPath` as an install whose pristine content `backupPath` holds, and + * report the confidence that now stands on disk. + * + * Idempotent. An ESTABLISHED record is never downgraded to a guess: callers ask for + * `assumed` from what THIS run can prove, and a run that can prove less than an + * earlier one must not erase what the earlier one knew. Promotion the other way is + * allowed and necessary — a fresh snapshot inspects the install's own bytes, which is + * evidence no guess produced — so callers must pass `assumed: true` whenever their + * evidence is derived from an earlier guess rather than independent of it. + * + * The return value is what the caller must act on: `--restore` may only drop a patch + * manifest once an ESTABLISHED record has replaced it, or the manifest's testimony is + * destroyed and nothing takes its place. + */ +export function recordBackupProvenance( + backupPath: string, + binaryPath: string, + opts: { assumed: boolean }, +): 'established' | 'assumed' { + const target = installProvenancePath(backupPath, binaryPath); + const existing = readInstallProvenance(target); + if (existing && existing !== 'damaged' && existing.install === binaryPath) { + // Nothing to write: already at this confidence, or already stronger. + if (existing.assumed === opts.assumed) return existing.assumed ? 'assumed' : 'established'; + if (!existing.assumed) return 'established'; + } + const record: InstallProvenance = { install: binaryPath, assumed: opts.assumed }; + // Temp + rename for the same reason the backups use it: a half-written record + // reads as damaged, and a damaged record refuses rather than falling back. + const temp = `${target}.tmp-${process.pid}-${Date.now().toString(36)}`; + try { + writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`); + renameSync(temp, target); + return opts.assumed ? 'assumed' : 'established'; + } catch (err) { + try { + rmSync(temp, { force: true }); + } catch { + // best effort — the temp name matches neither the backup pattern nor the + // record one, so a leftover is inert. + } + throw err; + } +} + export interface BackupCandidate { path: string; /** `content-addressed` names carry a sha256 prefix; `legacy` ones do not. */ kind: 'content-addressed' | 'legacy'; /** sha256 of the file's CURRENT bytes. */ sha256: string; + /** + * Installs whose pristine content these bytes are ESTABLISHED to be. Empty for a + * backup written before records existed — the one remaining case where a version + * tag is all that ties a backup to an install. Assumed records are deliberately + * absent: they are not evidence, and only the writer consults them. + */ + installs: string[]; + /** + * Installs recorded beside this backup as a GUESS. Not evidence: these never + * select and never promote. They do block the version-tag fallback for a + * DIFFERENT install, because "these bytes were once handed to that install" is + * still a reason not to hand them to this one. + */ + assumedInstalls: string[]; + /** + * Provenance records beside this backup that could not be read. Something was + * recorded and we cannot tell what, so the version-tag fallback is refused rather + * than treating damaged positive evidence as if it had never existed. + */ + damagedProvenance: string[]; } export interface BackupScan { @@ -108,6 +253,35 @@ export function scanPristineBackups(version: string, dir = backupDir()): BackupS } catch { return { valid: [], corrupt: [] }; } + // One readdir for the backups AND their provenance records: the records are named + // after the backup they belong to, so they are already in this listing. + const provenanceOf = ( + name: string, + ): Pick => { + const recordPattern = installProvenancePattern(name); + const installs: string[] = []; + const assumedInstalls: string[] = []; + const damagedProvenance: string[] = []; + for (const entry of entries) { + if (!recordPattern.test(entry)) continue; + const path = join(dir, entry); + const record = readInstallProvenance(path); + // The name came out of this same listing, so `null` here is not "no record": + // it is one that exists and cannot be opened — a directory, a dangling link, + // mode 000, an I/O error. Reading that as absent is what let the version-tag + // fallback run on a backup that had already been claimed. + if (record === null || record === 'damaged' || installProvenancePath(join(dir, name), record.install) !== path) { + damagedProvenance.push(path); + continue; + } + (record.assumed ? assumedInstalls : installs).push(record.install); + } + return { + installs: installs.sort(), + assumedInstalls: assumedInstalls.sort(), + damagedProvenance: damagedProvenance.sort(), + }; + }; const valid: BackupCandidate[] = []; const corrupt: string[] = []; for (const entry of entries.sort()) { @@ -129,9 +303,9 @@ export function scanPristineBackups(version: string, dir = backupDir()): BackupS corrupt.push(path); continue; } - valid.push({ path, kind: 'content-addressed', sha256 }); + valid.push({ path, kind: 'content-addressed', sha256, ...provenanceOf(entry) }); } else { - valid.push({ path, kind: 'legacy', sha256 }); + valid.push({ path, kind: 'legacy', sha256, ...provenanceOf(entry) }); } } return { valid, corrupt }; @@ -209,6 +383,13 @@ export interface PatchManifestFacts { backupPath?: string; patchedSha256?: string; pristineSha256?: string; + /** + * `assumed` when the run that wrote this manifest tied its backup to its install + * by the claude version in a file name alone. Absent means established — which is + * the right reading for every manifest written before this field existed, since a + * guess back then was reported but not recorded anywhere else either. + */ + pristineProvenance?: 'assumed'; } export interface PristineFacts { @@ -226,7 +407,20 @@ export type PristinePlan = /** The live binary IS the pristine bytes — patch it, no restore. */ | { action: 'reuse'; backupPath: string; pristineSha256: string; notes: string[] } /** Restore these pristine bytes over the live binary, then patch. */ - | { action: 'restore'; backupPath: string; pristineSha256: string; probeVersion: boolean; notes: string[] } + | { + action: 'restore'; + backupPath: string; + pristineSha256: string; + probeVersion: boolean; + /** + * True when nothing but the version tag tied these bytes to this install — + * no manifest for it, and no provenance sidecar anywhere. The restore still + * happens (refusing would strand every backup written before sidecars + * existed), but the caller must not record the guess as provenance. + */ + assumedForThisInstall: boolean; + notes: string[]; + } /** Undecidable from bytes alone — extract the source and re-plan. */ | { action: 'inspect' } /** Bootstrap: store the live binary as this version's pristine backup. */ @@ -244,8 +438,10 @@ function noBackupMessage(facts: PristineFacts): string { const corrupt = facts.corruptBackups?.length ? ` (${facts.corruptBackups.length} backup file(s) for this version failed integrity checks and were ignored)` : ''; - return `claude ${facts.version} is already patched and no trustworthy pristine backup for that version exists in ${backupDir()}${corrupt}. ` - + 'Reinstall Claude Code to get a pristine binary, then run `clodex patch`.'; + return `clodex holds no pristine backup of claude ${facts.version} it can attribute to ` + + `${facts.binaryPath}: none was found in ${backupDir()}${corrupt}. If that binary is patched, ` + + 'reinstall Claude Code to get a pristine one and run `clodex patch` again. If it was never ' + + 'patched, there is nothing to restore.'; } /** @@ -285,6 +481,64 @@ function otherInstallMessage(facts: PristineFacts, otherBinaryPath: string): str + 'Claude Code to make this one pristine.'; } +/** Every backup on disk is recorded as belonging to some OTHER install. */ +function otherInstallProvenanceMessage(facts: PristineFacts, recorded: BackupCandidate[]): string { + const owners = [...new Set(recorded.flatMap(backup => backup.installs))]; + return `The pristine backup(s) of claude ${facts.version} in ${backupDir()} are recorded as the ` + + `pristine content of ${owners.join(', ')}, not of ${facts.binaryPath}. Two installs of one ` + + 'Claude Code version are different files, so restoring one of those would overwrite this ' + + `install with bytes that were never its own. Set TWEAKCC_CC_INSTALLATION_PATH=${owners[0]} to ` + + 'restore that install instead, or reinstall Claude Code to make this one pristine.'; +} + +/** A provenance record exists beside a backup but cannot be read. */ +function damagedProvenanceMessage(facts: PristineFacts, damaged: string[]): string { + return `A pristine backup of claude ${facts.version} in ${backupDir()} carries a provenance record ` + + `that clodex cannot read (${damaged.join(', ')}). Something recorded which install those bytes ` + + 'belong to and that record is damaged, so clodex will not fall back to matching on the claude ' + + `version in the file name — on a machine with more than one install that would hand ` + + `${facts.binaryPath} bytes that were never its own. Delete the unreadable record to accept that ` + + 'fallback, or reinstall Claude Code to make this install pristine.'; +} + +/** Two backups holding different bytes both claim to be this install's pristine content. */ +function contradictoryProvenanceMessage(facts: PristineFacts, mine: BackupCandidate[]): string { + return `More than one pristine backup of claude ${facts.version} is recorded as the pristine ` + + `content of ${facts.binaryPath} (${mine.map(backup => backup.path).join(', ')}), and they do ` + + 'not hold the same bytes, so clodex cannot tell which one that install holds now — being ' + + 'snapshotted twice, at two different sets of unpatched bytes, reaches this state without either ' + + 'record being wrong. Delete whichever is stale to decide it: ' + + `${mine.map(backup => installProvenancePath(backup.path, facts.binaryPath)).join(', ')}. ` + + `Or reinstall the Claude Code at ${facts.binaryPath} and delete both.`; +} + +/** The manifest and an established record disagree about this install's pristine bytes. */ +function manifestContradictsRecordMessage( + facts: PristineFacts, + manifestChoice: string, + recorded: BackupCandidate[], +): string { + return `The patch manifest names ${manifestChoice} as the pristine content of ${facts.binaryPath}, ` + + `but ${recorded.map(backup => backup.path).join(', ')} is recorded as that install's pristine ` + + 'content and holds different bytes. Nothing establishes which of them the install at that path ' + + 'is now — an executable replaced in place with a different build of the same claude version ' + + 'reaches exactly this state — so clodex will not pick one. Delete whichever of these records is ' + + `stale to decide it: ${recorded.map(backup => installProvenancePath(backup.path, facts.binaryPath)).join(', ')}. ` + + `Or reinstall the Claude Code at ${facts.binaryPath} and delete both: a pristine install needs ` + + 'no backup, and `clodex patch` will record its own.'; +} + +/** Bytes this machine has already handed to a DIFFERENT install, as a guess. */ +function assumedElsewhereMessage(facts: PristineFacts, backups: BackupCandidate[]): string { + const others = [...new Set(backups.flatMap(backup => backup.assumedInstalls))]; + return `The pristine backup(s) of claude ${facts.version} in ${backupDir()} have already been ` + + `restored onto ${others.join(', ')} — not onto ${facts.binaryPath}. That was itself matched on ` + + 'the claude version in a file name, so it is not proof of ownership, but it is a reason not to ' + + `hand the same bytes to a second install: two installs of one version are different files. Set ` + + `TWEAKCC_CC_INSTALLATION_PATH=${others[0]} to restore that install, or reinstall Claude Code to ` + + 'make this one pristine.'; +} + /** * Pick the pristine bytes to restore over an already-patched binary. * Every branch requires bytes whose provenance is established; when none are, @@ -292,6 +546,7 @@ function otherInstallMessage(facts: PristineFacts, otherBinaryPath: string): str */ function selectRestoreSource(facts: PristineFacts): RestorePlan { const notes: string[] = []; + let assumedForThisInstall = false; const recorded = facts.manifest; // A manifest speaks only for the version it was written for. After an upgrade // it records a backup of the OLD version, which is not among this version's @@ -338,9 +593,58 @@ function selectRestoreSource(facts: PristineFacts): RestorePlan { return { action: 'error', message: recordedBackupGoneMessage(facts, manifest) }; } + // 2c. The manifest chose, but a record for this same install names bytes that are + // not those. That state is reachable BOTH ways round: one install path can be + // rewritten in place with a different build of the same claude version (the + // manifest is then stale), and one install can legitimately be snapshotted + // twice — tweakcc theming rewrites the binary, and the snapshot path keeps both + // files on purpose — leaving two true records of what it held at two times. + // + // What separates them is whether the manifest describes the binary in front of + // us. When its `patchedSha256` IS the live bytes, clodex provably wrote them + // last, so it is the current record and its backup is the right source. Only + // when it does not is there nothing to date either claim by, and then refusing + // is the only safe answer: refusing in the other case made every later patch + // AND restore of an ordinary single-install machine fail permanently, with a + // message telling the user to reinstall, which does not clear a record. + const manifestDescribesLiveBytes = !!manifest?.patchedSha256 + && manifest.patchedSha256 === facts.liveSha256; + if (chosen && !manifestDescribesLiveBytes) { + const manifestChoice = chosen; + const contradicting = facts.backups.filter( + backup => backup.installs.includes(facts.binaryPath) && backup.sha256 !== manifestChoice.sha256, + ); + if (contradicting.length) { + return { + action: 'error', + message: manifestContradictsRecordMessage(facts, manifestChoice.path, contradicting), + }; + } + } + // 3. No manifest help: fall back to the version's backups, but only when they // agree on the content. Two different "pristine" snapshots of one version // mean at least one is wrong; guessing is exactly the destructive move. + // 3. The provenance records: which backups were made FOR this install. This is + // the record that outlives the manifest, so it is the evidence available on + // exactly the paths #199's manifest fix could not reach — after a successful + // `--restore` deleted the manifest, or with a wiped `~/.clodex`. It also + // settles a two-install machine positively rather than by refusal: each + // install's own backup names it, so both can be restored. + if (!chosen) { + const mine = facts.backups.filter(backup => backup.installs.includes(facts.binaryPath)); + if (mine.length) { + if (new Set(mine.map(backup => backup.sha256)).size > 1) { + return { action: 'error', message: contradictoryProvenanceMessage(facts, mine) }; + } + // Prefer a self-validating name when both spellings hold the same bytes. + chosen = mine.find(backup => backup.kind === 'content-addressed') ?? mine[0]; + } + } + + // 4. Nothing establishes a backup for this install. Refuse on any positive + // evidence that the bytes on disk belong elsewhere, and fall back to the + // version tag only for backups that carry no record at all. if (!chosen) { // A manifest recorded against a DIFFERENT install vouches for nothing here. // Disqualifying only the backup IT names is not enough: the manifest holds one @@ -352,6 +656,33 @@ function selectRestoreSource(facts: PristineFacts): RestorePlan { ? { action: 'error', message: otherInstallMessage(facts, other.binaryPath) } : { action: 'error', message: noBackupMessage(facts) }; } + // A sidecar naming some other install is the same kind of positive evidence, + // and it is the one that survives the manifest being deleted. Without it, + // restoring here published one install's pristine bytes over another and then + // reported success — issue #204. + const recordedElsewhere = facts.backups.filter(backup => backup.installs.length > 0); + if (recordedElsewhere.length) { + return { action: 'error', message: otherInstallProvenanceMessage(facts, recordedElsewhere) }; + } + // A record that cannot be read is not the same as no record: something was + // written, and reading it as "unattributed" is what would let the version-tag + // fallback below run on a backup that had already been claimed. + const damaged = facts.backups.flatMap(backup => backup.damagedProvenance); + if (damaged.length) { + return { action: 'error', message: damagedProvenanceMessage(facts, damaged) }; + } + // These bytes have already been restored onto some OTHER install by this same + // fallback. The guess proves nothing about ownership, so it neither selects nor + // establishes — but running the fallback AGAIN for a second install is how one + // install's bytes reach two, and the first guess is warning enough not to. + // A guess recorded for THIS install is not a reason to refuse: it is the same + // decision, being repeated. + const assumedElsewhere = facts.backups.filter( + backup => backup.assumedInstalls.some(install => install !== facts.binaryPath), + ); + if (assumedElsewhere.length) { + return { action: 'error', message: assumedElsewhereMessage(facts, assumedElsewhere) }; + } const distinct = [...new Set(facts.backups.map(backup => backup.sha256))]; if (distinct.length > 1) { return { @@ -368,19 +699,27 @@ function selectRestoreSource(facts: PristineFacts): RestorePlan { // Prefer a self-validating name when both spellings hold the same bytes. chosen = facts.backups.find(backup => backup.kind === 'content-addressed') ?? facts.backups[0]; if (chosen) { - // Say so out loud: no manifest records this install, so the ONLY thing tying - // these bytes to it is the version tag in the file name. That is the last - // place a same-version backup from another install can still be selected. + // Say so out loud: nothing records which install this backup was made for, + // so the ONLY thing tying these bytes to this one is the version tag in the + // file name. That is the last place a same-version backup from another + // install can still be selected, and it is reachable only for a backup + // written before clodex recorded provenance beside it. + assumedForThisInstall = true; notes.push( - `No patch manifest records ${facts.binaryPath}, so ${chosen.path} is being used as its ` - + `pristine content on the strength of its claude ${facts.version} version tag alone. On a ` - + 'machine with more than one Claude Code install those bytes may belong to the other one — ' - + 'a clodex that had recorded this install would have refused rather than guess.', + `Neither a patch manifest nor a provenance record ties ${chosen.path} to ${facts.binaryPath}, ` + + `so it is being used as that install's pristine content on the strength of its claude ` + + `${facts.version} version tag alone — it predates the records clodex now writes. On a ` + + 'machine with more than one Claude Code install those bytes may belong to the other one, ' + + 'and clodex will not record this guess as provenance.', ); } } if (!chosen) return { action: 'error', message: noBackupMessage(facts) }; + // A manifest written by a run that guessed is not independent evidence — it is the + // guess, written down. Carrying its own confidence forward is what stops the next + // run from reading it as proof and promoting the record beside the backup. + if (manifest?.pristineProvenance === 'assumed') assumedForThisInstall = true; return { action: 'restore', backupPath: chosen.path, @@ -389,6 +728,7 @@ function selectRestoreSource(facts: PristineFacts): RestorePlan { // another version's binary, stored under a mislabeled name by an older // clodex. Executing it is the only evidence available; require it. probeVersion: chosen.kind === 'legacy', + assumedForThisInstall, notes, }; } diff --git a/src/patcher.ts b/src/patcher.ts index f328e9cf..178d3b14 100644 --- a/src/patcher.ts +++ b/src/patcher.ts @@ -90,11 +90,15 @@ import { backupDir, collectPristineFacts, contentAddressedBackupPath, + installProvenancePath, isPatchedClaudeSource, + legacyBackupPath, looksLikeLegacyClodexPatch, planInspectedPristineSource, planPristineSource, planRestoreOnly, + readInstallProvenance, + recordBackupProvenance, sha256File, tweakccMirrorBackupPath, type PristineFacts, @@ -142,6 +146,13 @@ export interface PatchManifest { * treat it as optional. */ pristineSha256?: string; + /** + * `assumed` when this run tied `backupPath` to `binaryPath` by the claude version + * in a file name alone, rather than by a record or an earlier manifest. Absent + * means established. Without it, the next run reads a guessing run's manifest as + * independent proof and promotes the guess (issue #204). + */ + pristineProvenance?: 'assumed'; patchedAt: string; } @@ -887,6 +898,8 @@ export async function applyPatch( let patchedSha256: string; let backup: string; let pristineSha256: string; + /** What tied `backup` to this install, carried into the manifest. */ + const pristine: { provenance: 'established' | 'assumed' } = { provenance: 'established' }; try { mkdirSync(backupDir(), { recursive: true }); @@ -990,6 +1003,52 @@ export async function applyPatch( // poisoned backup must not be laundered into a content-addressed name (which // later runs then trust without a probe), and must not clobber the tweakcc // mirror on its way to failing. + // A guessed association is recorded AS a guess rather than skipped: the record + // is what stops a later run from promoting it (the live bytes now match the + // backup because the guess put them there, and the manifest this run writes was + // itself derived from it). It never selects and never refuses. + // + // Every plan INHERITS the confidence already recorded for these bytes. `reuse` + // matches bytes a guess may have put there; canonicalizing a legacy backup would + // otherwise launder a guess through a filename change; and a `snapshot` is no + // exception either, even though it inspected the live bytes itself — if a guess + // restored those very bytes onto this install, "the install holds them" is a fact + // the guess created, so establishing on it would hand these bytes' true owner a + // refusal. Nothing promotes a guess. The protection a promotion was supposed to + // buy is already provided by refusing the fallback for an install these bytes were + // guessed onto. + // + // Confidence belongs to the CONTENT, not to a filename. Asking only about the name + // this plan chose left a third name carrying the guess: restore B from a legacy + // backup by version tag, patch A so the legacy file is adopted under its content + // address, then patch B — which picks the canonical name, finds no record of B + // beside it, and established what the legacy name still called a guess. So every + // alias of these exact bytes is consulted, and the two names a record can sit + // beside while its backup is gone (the content address and the legacy name) are + // read directly, because the scan only finds records next to an existing `.orig`. + const inheritsAGuess = (path: string): boolean => { + const existing = readInstallProvenance(installProvenancePath(path, binaryPath)); + return existing === 'damaged' || (existing !== null && existing.assumed); + }; + const provenanceAssumed = (plan.action === 'restore' && plan.assumedForThisInstall) + || inheritsAGuess(plan.backupPath) + || inheritsAGuess(contentAddressedBackupPath(version, plan.pristineSha256)) + || inheritsAGuess(legacyBackupPath(version)) + || facts.backups.some( + candidate => candidate.sha256 === plan.pristineSha256 + && candidate.assumedInstalls.includes(binaryPath), + ); + // From what the write LEFT on disk, not from what it asked for. The two agree + // today — an established request promotes, so the writer never answers `assumed` + // to one — but the manifest is the next run's evidence, and reading it from the + // result rather than the intent means that stays true without depending on the + // writer's promotion rule. + const recordProvenance = (path: string) => { + if (recordBackupProvenance(path, binaryPath, { assumed: provenanceAssumed }) === 'assumed') { + pristine.provenance = 'assumed'; + } + }; + if (!loaded) { loaded = await seedCandidate(backup); if (isPatchedClaudeSource(loaded.source)) { @@ -1015,6 +1074,12 @@ export async function applyPatch( + `to publish it as ${plan.backupPath}`, ); } + // Record BEFORE the backup becomes visible. A published `.orig` with no record + // beside it is exactly the unattributed same-version file this exists to + // prevent, and a crash or a full disk between the two writes would leave one + // for good. The reverse order is safe: a record whose backup never appeared is + // inert, because scanning starts from the `.orig` files. + recordProvenance(plan.backupPath); publishFileByRename(candidatePath, plan.backupPath); } @@ -1028,6 +1093,10 @@ export async function applyPatch( // is absent from it and gets replaced rather than adopted and published. const canonical = contentAddressedBackupPath(version, pristineSha256); if (canonical !== backup) { + // Both names first, for the same reason the snapshot path records before it + // publishes: whichever file exists must already say whose bytes it holds. + recordProvenance(backup); + recordProvenance(canonical); const alreadyStored = facts.backups.some( candidate => candidate.path === canonical && candidate.sha256 === pristineSha256, ); @@ -1039,6 +1108,18 @@ export async function applyPatch( // backup, never the live binary — so it stays pristine after patching). publishFileByRename(backup, tweakccMirrorBackupPath()); + // Record which install these bytes are the pristine content of, beside the + // backup itself. The manifest written at the end of this function says the same + // thing, but it holds one install and `--restore` deletes it, so this is what + // still answers "whose bytes are these?" on a later run — and refuses to hand + // them to a different install (issue #204). Both names are recorded when a + // legacy backup was adopted under its content address: the legacy file is left + // on disk deliberately, for `tweakcc --restore` and older clodex, so leaving it + // unattributed would leave the one file this change cannot speak for. + // Idempotent, so repeating the snapshot path's write costs nothing. + recordProvenance(backup); + if (plan.backupPath !== backup) recordProvenance(plan.backupPath); + const builtIn = applyClodexPatches(loaded.source, desired.config); results = builtIn.results; const failedEffortPatches = requiredEffortPatchFailures(results); @@ -1163,6 +1244,7 @@ export async function applyPatch( patchedSha256, backupPath: backup, pristineSha256, + ...(pristine.provenance === 'assumed' ? { pristineProvenance: 'assumed' as const } : {}), patchedAt: new Date().toISOString(), }; writePatchManifest(manifest); @@ -1254,6 +1336,63 @@ function runRestoreCommand(target: ClaudePatchTarget): number { p.log.error(verified.message); return 1; } + // Record what this restore knows BEFORE the binary changes and before the manifest + // is cleared. + // + // Before the copy, because a version-tag guess changes the live bytes to the + // backup's: once that has happened, "the live bytes match this backup" is no + // longer independent evidence, so the record saying it was a guess has to already + // be on disk. If it cannot be, the guess is refused — it is the optional + // compatibility path, and skipping it costs the user nothing but a message. + // + // Before the manifest is cleared, because the manifest may be the ONLY thing tying + // that backup to this install — an upgrading user's backup predates these records + // — and clearing it without writing one leaves the backup unattributed for good, + // which is the state issue #204 turns destructive on a second install. + // A manifest for this same path but a DIFFERENT claude version is about an older + // install of Claude Code whose backup is still on disk, and clearing it below would + // leave that backup unattributed. `clodex patch` migrates the same case. + if (manifest && manifest.backupPath && manifest.binaryPath === binaryPath + && manifest.claudeVersion !== version && manifest.backupPath !== plan.backupPath) { + try { + if (existsSync(manifest.backupPath)) { + recordBackupProvenance(manifest.backupPath, manifest.binaryPath, { + assumed: manifest.pristineProvenance === 'assumed', + }); + } + } catch (err) { + p.log.warn( + `Could not record in ${backupDir()} that ${manifest.backupPath} holds the pristine bytes of ` + + `claude ${manifest.claudeVersion} at ${manifest.binaryPath} ` + + `(${err instanceof Error ? err.message : String(err)}). That version may need to be ` + + 'reinstalled rather than restored.', + ); + } + } + + let recorded: 'established' | 'assumed' | 'failed'; + try { + recorded = recordBackupProvenance(plan.backupPath, binaryPath, { assumed: plan.assumedForThisInstall }); + } catch (err) { + recorded = 'failed'; + const detail = err instanceof Error ? err.message : String(err); + if (plan.assumedForThisInstall) { + p.log.error( + `Refusing to restore ${binaryPath} from ${plan.backupPath}: nothing but the claude ` + + `${version} version tag ties those bytes to this install, and clodex cannot record that in ` + + `${backupDir()} (${detail}). Writing them without that record would leave the machine unable ` + + 'to tell afterwards that the match was a guess. Fix the backup directory, or reinstall ' + + 'Claude Code to make this install pristine.', + ); + return 1; + } + p.log.warn( + `Could not record in ${backupDir()} that ${plan.backupPath} holds the pristine bytes of ` + + `${binaryPath} (${detail}). Restoring anyway and keeping the patch manifest, so that record ` + + 'is not lost — a later restore would otherwise have nothing tying that backup to this install.', + ); + } + // Keep the live binary's own permissions: `copyFileSync` would hand it the // backup file's instead, and a non-executable claude is a worse outcome than // the one being fixed. @@ -1279,16 +1418,22 @@ function runRestoreCommand(target: ClaudePatchTarget): number { ); copyFileSync(plan.backupPath, binaryPath); } - // Reaching here means the plan established these bytes as THIS install's - // pristine content, so the manifest being cleared is this install's own record - // that it was patched. A manifest recorded against a different install cannot - // get here: `selectRestoreSource` refuses that case outright rather than - // selecting a backup by version tag, which is what used to delete another - // install's only rescue record along with clobbering it (issue #199). - try { - unlinkSync(getPatchManifestPath()); - } catch { - // no manifest to remove + + // The manifest records ONE install, and clearing it is meant to say "this install + // is no longer patched". Two things must hold first. Only this install's own + // record may be cleared: a provenance record lets a restore succeed while the + // manifest still holds a DIFFERENT install (both are recorded, so each can be + // restored), and deleting the manifest there would throw away the other install's + // only rescue record — the damage issue #199 named, arrived at from the other + // direction. And an ESTABLISHED record must now stand in its place: a manifest is + // stronger evidence than a guess, so dropping it in exchange for one destroys + // testimony rather than migrating it. + if (recorded === 'established' && (!manifest || manifest.binaryPath === binaryPath)) { + try { + unlinkSync(getPatchManifestPath()); + } catch { + // no manifest to remove + } } p.log.success(`Restored pristine claude ${version} from ${plan.backupPath}.`); return 0; @@ -1360,6 +1505,29 @@ export async function runPatchCommand(opts: { return 1; } + // This run is about to REPLACE the manifest, which holds one install. When the one + // it holds is a different install (or this install at a different claude version), + // that manifest is the only thing attributing its backup — an upgrading user's + // backup predates the per-install records — so migrate its testimony first or + // patching one install silently strips the other's rescue record (issue #204). + if (manifest && manifest.backupPath + && (manifest.binaryPath !== binaryPath || manifest.claudeVersion !== version)) { + try { + if (existsSync(manifest.backupPath)) { + recordBackupProvenance(manifest.backupPath, manifest.binaryPath, { + assumed: manifest.pristineProvenance === 'assumed', + }); + } + } catch (err) { + p.log.warn( + `Could not record in ${backupDir()} that ${manifest.backupPath} holds the pristine bytes of ` + + `${manifest.binaryPath} before replacing the patch manifest ` + + `(${err instanceof Error ? err.message : String(err)}). That install may need to be ` + + 'reinstalled rather than restored.', + ); + } + } + try { // Never patch on top of a patch: applyPatch decides from the pristine // backups for THIS version (and the manifest) whether the live binary is diff --git a/tests/patch-backup.test.ts b/tests/patch-backup.test.ts index a6803e9e..14b741a3 100644 --- a/tests/patch-backup.test.ts +++ b/tests/patch-backup.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createHash } from 'node:crypto'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -10,6 +10,9 @@ import { isPatchedClaudeSource, looksLikeLegacyClodexPatch, legacyBackupPath, + installProvenancePath, + readInstallProvenance, + recordBackupProvenance, planInspectedPristineSource, planPristineSource, planRestoreOnly, @@ -84,6 +87,196 @@ describe('scanPristineBackups', () => { it('returns nothing when the backup directory does not exist', () => { expect(scanPristineBackups('2.1.220', join(dir, 'missing'))).toEqual({ valid: [], corrupt: [] }); }); + + it('reports the installs each backup is recorded for', () => { + const bytes = 'pristine 2.1.220'; + const path = store(`claude-2.1.220-${sha(bytes).slice(0, BACKUP_SHA_PREFIX_LENGTH)}.orig`, bytes); + const unrecorded = store('claude-2.1.220.orig', bytes); + recordBackupProvenance(path, '/install/native', { assumed: false }); + recordBackupProvenance(path, '/install/npm', { assumed: false }); + + const scan = scanPristineBackups('2.1.220', dir); + expect(scan.valid.find(candidate => candidate.path === path)?.installs) + .toEqual(['/install/native', '/install/npm']); + expect(scan.valid.find(candidate => candidate.path === unrecorded)?.installs).toEqual([]); + }); + + it('keeps an assumed record out of the established installs', () => { + const bytes = 'pristine 2.1.220'; + const path = store(`claude-2.1.220-${sha(bytes).slice(0, BACKUP_SHA_PREFIX_LENGTH)}.orig`, bytes); + recordBackupProvenance(path, '/install/guessed', { assumed: true }); + + const scan = scanPristineBackups('2.1.220', dir); + expect(scan.valid[0]?.installs).toEqual([]); + expect(scan.valid[0]?.damagedProvenance).toEqual([]); + }); + + it('reports a provenance record it cannot read instead of ignoring it', () => { + const bytes = 'pristine 2.1.220'; + const path = store(`claude-2.1.220-${sha(bytes).slice(0, BACKUP_SHA_PREFIX_LENGTH)}.orig`, bytes); + const record = installProvenancePath(path, '/install/claude'); + writeFileSync(record, '{"install": "/install/cla'); + + const scan = scanPristineBackups('2.1.220', dir); + expect(scan.valid[0]?.installs).toEqual([]); + expect(scan.valid[0]?.damagedProvenance).toEqual([record]); + }); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'treats a record it cannot open as damaged, not as absent', + () => { + // The name is right there in the directory listing, so this is not "no record": + // it is one that exists and cannot be read. Reading it as absent is what let the + // version-tag fallback run on a backup another install had already claimed. + const bytes = 'pristine 2.1.220'; + const path = store(`claude-2.1.220-${sha(bytes).slice(0, BACKUP_SHA_PREFIX_LENGTH)}.orig`, bytes); + const record = installProvenancePath(path, '/install/claude'); + writeFileSync(record, JSON.stringify({ install: '/install/claude', assumed: false })); + chmodSync(record, 0o000); + try { + const scan = scanPristineBackups('2.1.220', dir); + expect(scan.valid[0]?.installs).toEqual([]); + expect(scan.valid[0]?.damagedProvenance).toEqual([record]); + } finally { + chmodSync(record, 0o600); + } + }, + ); + + it('treats a record whose name does not match the install it holds as damaged', () => { + // The name is derived from the install path, so a mismatch means one of the two + // is wrong — including a hash-prefix collision that would otherwise let a record + // be selected for an install it was not written for. + const bytes = 'pristine 2.1.220'; + const path = store(`claude-2.1.220-${sha(bytes).slice(0, BACKUP_SHA_PREFIX_LENGTH)}.orig`, bytes); + const record = installProvenancePath(path, '/install/claude'); + writeFileSync(record, JSON.stringify({ install: '/somewhere/else/claude', assumed: false })); + + const scan = scanPristineBackups('2.1.220', dir); + expect(scan.valid[0]?.installs).toEqual([]); + expect(scan.valid[0]?.damagedProvenance).toEqual([record]); + }); + + it('does not mistake a provenance record for a backup', () => { + const bytes = 'pristine 2.1.220'; + const path = store(`claude-2.1.220-${sha(bytes).slice(0, BACKUP_SHA_PREFIX_LENGTH)}.orig`, bytes); + recordBackupProvenance(path, '/install/claude', { assumed: false }); + + const scan = scanPristineBackups('2.1.220', dir); + expect(scan.valid.map(candidate => candidate.path)).toEqual([path]); + expect(scan.corrupt).toEqual([]); + }); + + it('reads only the records belonging to its own backup', () => { + const bytes = 'pristine 2.1.220'; + const mine = store(`claude-2.1.220-${sha(bytes).slice(0, BACKUP_SHA_PREFIX_LENGTH)}.orig`, bytes); + const other = store('claude-2.1.220.orig', bytes); + recordBackupProvenance(other, '/install/other', { assumed: false }); + + const scan = scanPristineBackups('2.1.220', dir); + expect(scan.valid.find(candidate => candidate.path === mine)?.installs).toEqual([]); + expect(scan.valid.find(candidate => candidate.path === other)?.installs).toEqual(['/install/other']); + }); +}); + +describe('backup provenance records', () => { + let dir: string; + let backup: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'clodex-backup-prov-')); + backup = join(dir, 'claude-2.1.220-abcdef0123456789.orig'); + writeFileSync(backup, 'pristine'); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads nothing for a backup that has no record', () => { + expect(readInstallProvenance(installProvenancePath(backup, '/install/native'))).toBeNull(); + }); + + it('gives each install its own file, so no merge can lose one', () => { + recordBackupProvenance(backup, '/install/native', { assumed: false }); + recordBackupProvenance(backup, '/install/npm', { assumed: false }); + expect(readInstallProvenance(installProvenancePath(backup, '/install/native'))) + .toEqual({ install: '/install/native', assumed: false }); + expect(readInstallProvenance(installProvenancePath(backup, '/install/npm'))) + .toEqual({ install: '/install/npm', assumed: false }); + }); + + it('is idempotent, so repeated patches rewrite nothing', () => { + recordBackupProvenance(backup, '/install/native', { assumed: false }); + const before = readFileSync(installProvenancePath(backup, '/install/native'), 'utf8'); + recordBackupProvenance(backup, '/install/native', { assumed: false }); + expect(readFileSync(installProvenancePath(backup, '/install/native'), 'utf8')).toBe(before); + }); + + it('never downgrades an established record to a guess', () => { + // Callers ask for the confidence THIS run can prove. One that can prove less + // than an earlier run must not erase what the earlier one knew. + recordBackupProvenance(backup, '/install/npm', { assumed: false }); + expect(recordBackupProvenance(backup, '/install/npm', { assumed: true })).toBe('established'); + expect(readInstallProvenance(installProvenancePath(backup, '/install/npm'))) + .toEqual({ install: '/install/npm', assumed: false }); + }); + + it('promotes a guess when the caller has independent evidence', () => { + // A fresh snapshot inspects the install's own bytes, which no guess produces — + // refusing to promote there would let one old guess suppress the proof forever. + // Keeping a guess a guess is the CALLER's job: it passes `assumed` whenever its + // evidence is derived from an earlier one. + recordBackupProvenance(backup, '/install/npm', { assumed: true }); + expect(recordBackupProvenance(backup, '/install/npm', { assumed: false })).toBe('established'); + expect(readInstallProvenance(installProvenancePath(backup, '/install/npm'))) + .toEqual({ install: '/install/npm', assumed: false }); + }); + + it('reports the confidence that stands on disk', () => { + expect(recordBackupProvenance(backup, '/install/npm', { assumed: true })).toBe('assumed'); + expect(recordBackupProvenance(backup, '/install/npm', { assumed: true })).toBe('assumed'); + expect(recordBackupProvenance(backup, '/install/native', { assumed: false })).toBe('established'); + }); + + it('rejects a record whose assumed flag is not a boolean', () => { + // It would read as `false` — the ESTABLISHED value — so a damaged former guess + // would become the strongest evidence in the directory. + const record = installProvenancePath(backup, '/install/native'); + writeFileSync(record, JSON.stringify({ install: '/install/native', assumed: 'true' })); + expect(readInstallProvenance(record)).toBe('damaged'); + }); + + it('still records an established install beside an assumed one', () => { + recordBackupProvenance(backup, '/install/npm', { assumed: true }); + recordBackupProvenance(backup, '/install/native', { assumed: false }); + expect(readInstallProvenance(installProvenancePath(backup, '/install/native'))) + .toEqual({ install: '/install/native', assumed: false }); + }); + + it('reports an unreadable record as damaged rather than as absent', () => { + const record = installProvenancePath(backup, '/install/native'); + writeFileSync(record, '{"install": "/install/nat'); + expect(readInstallProvenance(record)).toBe('damaged'); + }); + + it('reports a record that names no install as damaged', () => { + const record = installProvenancePath(backup, '/install/native'); + writeFileSync(record, JSON.stringify({ assumed: false })); + expect(readInstallProvenance(record)).toBe('damaged'); + }); + + it('replaces a damaged record on the next patch', () => { + const record = installProvenancePath(backup, '/install/native'); + writeFileSync(record, 'not json'); + recordBackupProvenance(backup, '/install/native', { assumed: false }); + expect(readInstallProvenance(record)).toEqual({ install: '/install/native', assumed: false }); + }); + + it('leaves no temp file behind', () => { + recordBackupProvenance(backup, '/install/native', { assumed: false }); + expect(readdirSync(dir).filter(name => name.includes('.tmp-'))).toEqual([]); + }); }); describe('isPatchedClaudeSource', () => { @@ -159,6 +352,9 @@ function candidate(overrides: Partial = {}): BackupCandidate { path: contentAddressedBackupPath('2.1.220', PRISTINE, '/backups'), kind: 'content-addressed', sha256: PRISTINE, + installs: [], + assumedInstalls: [], + damagedProvenance: [], ...overrides, }; } @@ -248,7 +444,7 @@ describe('planInspectedPristineSource', () => { it('NEVER snapshots a patched binary — it errors instead', () => { const plan = planInspectedPristineSource(facts(), { patched: true }); expect(plan.action).toBe('error'); - expect((plan as { message: string }).message).toMatch(/already patched and no trustworthy pristine backup/); + expect((plan as { message: string }).message).toMatch(/holds no pristine backup of claude 2\.1\.220 it can attribute to/); }); it('restores the version\'s backup when the binary is patched', () => { @@ -396,7 +592,7 @@ describe('planRestoreOnly', () => { it('reports the ordinary no-backup error when the version has no backups at all', () => { const plan = planRestoreOnly(facts({ backups: [], manifest: otherInstall })); expect(plan.action).toBe('error'); - expect((plan as { message: string }).message).toMatch(/no trustworthy pristine backup/); + expect((plan as { message: string }).message).toMatch(/holds no pristine backup of claude 2\.1\.220 it can attribute to/); }); }); @@ -450,7 +646,7 @@ describe('planRestoreOnly', () => { describe('no manifest at all', () => { it('still restores, and says the version tag is the only thing tying the backup to the install', () => { const plan = planRestoreOnly(facts({ backups: [candidate()], manifest: null })); - expect(plan).toMatchObject({ action: 'restore', backupPath: candidate().path }); + expect(plan).toMatchObject({ action: 'restore', backupPath: candidate().path, assumedForThisInstall: true }); expect((plan as { notes: string[] }).notes.join(' ')).toMatch(/version tag alone/); }); @@ -459,8 +655,178 @@ describe('planRestoreOnly', () => { backups: [candidate()], manifest: { binaryPath: '/install/claude', backupPath: candidate().path, pristineSha256: PRISTINE }, })); - expect(plan).toMatchObject({ action: 'restore', backupPath: candidate().path }); + expect(plan).toMatchObject({ action: 'restore', backupPath: candidate().path, assumedForThisInstall: false }); + expect((plan as { notes: string[] }).notes).toEqual([]); + }); + }); + + // Issue #204. A successful restore deletes the manifest and the manifest holds + // one install, so it cannot be the only record of which install a backup belongs + // to. Each backup carries its own. + describe('provenance recorded beside the backup', () => { + const mine = () => candidate({ installs: ['/install/claude'] }); + const theirs = () => candidate({ + path: contentAddressedBackupPath('2.1.220', OTHER_INSTALL_PRISTINE, '/backups'), + sha256: OTHER_INSTALL_PRISTINE, + installs: ['/other-install/claude'], + }); + + it('refuses a backup recorded for another install when no manifest survives', () => { + const plan = planRestoreOnly(facts({ backups: [theirs()], manifest: null })); + expect(plan.action).toBe('error'); + expect((plan as { message: string }).message) + .toMatch(/recorded as the pristine content of \/other-install\/claude, not of \/install\/claude/); + }); + + it('restores this install\'s own backup while another install\'s sits beside it', () => { + const plan = planRestoreOnly(facts({ backups: [mine(), theirs()], manifest: null })); + expect(plan).toMatchObject({ + action: 'restore', + backupPath: mine().path, + pristineSha256: PRISTINE, + assumedForThisInstall: false, + }); expect((plan as { notes: string[] }).notes).toEqual([]); }); + + it('outranks a manifest that records some other install', () => { + // The manifest is about a different install, and used to be reason enough to + // refuse. A record naming THIS install is direct evidence, so it decides. + const plan = planRestoreOnly(facts({ + backups: [mine(), theirs()], + manifest: { binaryPath: '/other-install/claude', backupPath: theirs().path, pristineSha256: OTHER_INSTALL_PRISTINE }, + })); + expect(plan).toMatchObject({ action: 'restore', backupPath: mine().path }); + }); + + it('restores bytes recorded for both installs at once', () => { + // Content-addressed: two installs whose pristine bytes are identical share + // one backup file, and it is correct for either of them. + const shared = candidate({ installs: ['/other-install/claude', '/install/claude'] }); + const plan = planRestoreOnly(facts({ backups: [shared], manifest: null })); + expect(plan).toMatchObject({ action: 'restore', backupPath: shared.path, assumedForThisInstall: false }); + }); + + it('prefers a self-validating name over a legacy one recorded for the same install', () => { + const legacy = candidate({ + path: legacyBackupPath('2.1.220', '/backups'), + kind: 'legacy', + installs: ['/install/claude'], + }); + const plan = planRestoreOnly(facts({ backups: [legacy, mine()], manifest: null })); + expect(plan).toMatchObject({ action: 'restore', backupPath: mine().path, probeVersion: false }); + }); + + it('refuses when the manifest and a record disagree about this install\'s bytes', () => { + // Nothing dates either one, so "the manifest is newer" is an assumption. The + // state is reachable by replacing the executable at one path with a different + // build of the same claude version, where the MANIFEST is the stale one. + const contradicting = candidate({ + path: contentAddressedBackupPath('2.1.220', OTHER_INSTALL_PRISTINE, '/backups'), + sha256: OTHER_INSTALL_PRISTINE, + installs: ['/install/claude'], + }); + const plan = planRestoreOnly(facts({ + backups: [contradicting, mine()], + manifest: { binaryPath: '/install/claude', backupPath: mine().path, pristineSha256: PRISTINE }, + })); + expect(plan.action).toBe('error'); + expect((plan as { message: string }).message).toMatch(/holds different bytes/); + }); + + it('still lets the manifest decide when the records agree with it', () => { + const plan = planRestoreOnly(facts({ + backups: [mine()], + manifest: { binaryPath: '/install/claude', backupPath: mine().path, pristineSha256: PRISTINE }, + })); + expect(plan).toMatchObject({ action: 'restore', backupPath: mine().path, assumedForThisInstall: false }); + }); + + it('carries a guessing run\'s manifest forward as a guess', () => { + // The manifest a guessing run wrote is the guess, written down. Reading it as + // independent proof is what promoted the guess on the next run. + const plan = planRestoreOnly(facts({ + backups: [candidate()], + manifest: { + binaryPath: '/install/claude', + backupPath: candidate().path, + pristineSha256: PRISTINE, + pristineProvenance: 'assumed', + }, + })); + expect(plan).toMatchObject({ action: 'restore', assumedForThisInstall: true }); + }); + + it('refuses the fallback for an install these bytes were never guessed onto', () => { + // The first guess proves nothing about ownership, but running the fallback a + // SECOND time is how one install's bytes reach two. + const guessedElsewhere = candidate({ assumedInstalls: ['/other-install/claude'] }); + const plan = planRestoreOnly(facts({ backups: [guessedElsewhere], manifest: null })); + expect(plan.action).toBe('error'); + expect((plan as { message: string }).message).toMatch(/have already been restored onto/); + }); + + it('still repeats the fallback for the install it already guessed for', () => { + // Same decision, being made again — refusing here would strand the ordinary + // single-install machine on its second restore. + const guessedForMe = candidate({ assumedInstalls: ['/install/claude'] }); + const plan = planRestoreOnly(facts({ backups: [guessedForMe], manifest: null })); + expect(plan).toMatchObject({ action: 'restore', assumedForThisInstall: true }); + }); + + it('refuses when two records for this install disagree about its bytes', () => { + const contradiction = candidate({ + path: contentAddressedBackupPath('2.1.220', OTHER_INSTALL_PRISTINE, '/backups'), + sha256: OTHER_INSTALL_PRISTINE, + installs: ['/install/claude'], + }); + const plan = planRestoreOnly(facts({ backups: [mine(), contradiction], manifest: null })); + expect(plan.action).toBe('error'); + expect((plan as { message: string }).message).toMatch(/cannot tell which one that install holds now/); + }); + + it('keeps restoring a backup from before sidecars existed', () => { + // Refusing here would strand every backup written by an earlier clodex, so + // the version-tag fallback survives for records that do not exist at all — + // and only for those. The plan still says the association is assumed. + const plan = planRestoreOnly(facts({ backups: [candidate({ installs: [] })], manifest: null })); + expect(plan).toMatchObject({ action: 'restore', assumedForThisInstall: true }); + }); + + it('refuses when only SOME of the backups carry a record, none of them this install\'s', () => { + const unrecorded = candidate({ + path: contentAddressedBackupPath('2.1.220', sha('third install pristine'), '/backups'), + sha256: sha('third install pristine'), + }); + const plan = planRestoreOnly(facts({ backups: [theirs(), unrecorded], manifest: null })); + expect(plan.action).toBe('error'); + // Specifically the provenance refusal, not the conflicting-backups one: with + // two backups on disk both messages are available, and only this one names the + // install the bytes DO belong to. + expect((plan as { message: string }).message) + .toMatch(/recorded as the pristine content of \/other-install\/claude, not of \/install\/claude/); + }); + + it('refuses the version-tag fallback when a record beside the backup is damaged', () => { + // Something recorded which install those bytes belong to and the record cannot + // be read. Treating that as "never recorded" is what would let the fallback run + // on a backup that had already been claimed. + const plan = planRestoreOnly(facts({ + backups: [candidate({ damagedProvenance: ['/backups/claude-2.1.220-x.orig.for-abc.json'] })], + manifest: null, + })); + expect(plan.action).toBe('error'); + expect((plan as { message: string }).message).toMatch(/provenance record that clodex cannot read/); + }); + + it('still restores on a record for this install even when another backup\'s record is damaged', () => { + const damaged = candidate({ + path: contentAddressedBackupPath('2.1.220', OTHER_INSTALL_PRISTINE, '/backups'), + sha256: OTHER_INSTALL_PRISTINE, + damagedProvenance: ['/backups/other.for-abc.json'], + }); + const plan = planRestoreOnly(facts({ backups: [mine(), damaged], manifest: null })); + expect(plan).toMatchObject({ action: 'restore', backupPath: mine().path }); + }); }); }); diff --git a/tests/patcher-command.test.ts b/tests/patcher-command.test.ts index 14652f62..8bd9cbc8 100644 --- a/tests/patcher-command.test.ts +++ b/tests/patcher-command.test.ts @@ -27,11 +27,13 @@ import { tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { + getPatchManifestPath, runLaunchPatchCheck, runPatchCommand, readPatchManifest, resolveClaudeBinaryForPatch, } from '../src/patcher.js'; +import { installProvenancePath, readInstallProvenance } from '../src/patch-backup.js'; import { LEGACY_LAUNCHERS, NATIVE_LAUNCHERS } from './helpers/npm-launchers.js'; const NATIVE_PLACEHOLDER_BYTES = readFileSync(fileURLToPath( @@ -124,6 +126,9 @@ const versionOf = (path: string) => const sha256Of = (path: string) => createHash('sha256').update(readFileSync(path)).digest('hex'); const sha256OfBuffer = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); const backupFiles = () => (existsSync(tweakccDir) ? readdirSync(tweakccDir).sort() : []); +/** What the backup's own provenance record says about one install, if anything. */ +const provenanceFor = (backup: string, install: string) => + readInstallProvenance(installProvenancePath(backup, install)); /** Install path a native claude uses: versioned file + stable ~/.local/bin symlink. */ function installClaude(version: string, bundle = PRISTINE_BUNDLE): string { @@ -1053,7 +1058,7 @@ describe('runPatchCommand pristine backup safety', () => { const before = readFileSync(real); expect(await runPatchCommand({})).toBe(1); - expect(logs.join('\n')).toMatch(/already patched and no trustworthy pristine backup/); + expect(logs.join('\n')).toMatch(/holds no pristine backup of claude 2\.1\.220 it can attribute to/); expect(readFileSync(real)).toEqual(before); expect(backupFiles()).toEqual([]); }); @@ -1069,6 +1074,59 @@ describe('runPatchCommand pristine backup safety', () => { expect(logs.join('\n')).toMatch(/Refusing to use .*it reports version 2\.1\.215/); expect(readFileSync(real)).toEqual(before); }); + + it('records which install a backup belongs to, and records a guess only AS a guess', async () => { + const real = installClaude('2.1.220'); + expect(await runPatchCommand({})).toBe(0); + const backup = readPatchManifest()!.backupPath; + expect(provenanceFor(backup, real)).toEqual({ install: real, assumed: false }); + + // Take the machine back to what an older clodex left behind: a backup with no + // record beside it and no manifest. The patch below has to fall back to the + // version tag — and must not write that guess down as established provenance, + // which is what would make every later restore trust it on sight (issue #204). + rmSync(installProvenancePath(backup, real)); + rmSync(getPatchManifestPath()); + + expect(await runPatchCommand({})).toBe(0); + expect(logs.join('\n')).toMatch(/version tag alone/); + expect(provenanceFor(backup, real)).toEqual({ install: real, assumed: true }); + + // And the guess stays a guess: this re-patch reads a manifest the guessing run + // wrote, which is not independent evidence of anything. + rmSync(join(clodexHome, 'config.json')); + saveFavorites(); + expect(await runPatchCommand({})).toBe(0); + expect(provenanceFor(backup, real)).toEqual({ install: real, assumed: true }); + }); + + it('publishes no pristine backup it cannot record the owner of', async () => { + // The invariant the write order exists for: a published `.orig` with no record + // beside it is an unattributed same-version file, and the next restore of a + // DIFFERENT install matches it on its version tag. So the record is written + // first — if it cannot be written, nothing is published. Fault injected by + // parking a directory at the record's name. + const real = installClaude('2.1.220'); + const expectedBackup = join(tweakccDir, `claude-2.1.220-${sha256Of(real).slice(0, 16)}.orig`); + mkdirSync(installProvenancePath(expectedBackup, real), { recursive: true }); + + expect(await runPatchCommand({})).toBe(1); + expect(backupFiles().filter(name => name.endsWith('.orig'))).toEqual([]); + }); + + it('keeps the record beside a backup it has already published', async () => { + // A patch that fails AFTER publishing the pristine backup must not leave that + // backup unattributed: the next restore of a different same-version install + // would then match it on its version tag. Fault injected by making the tweakcc + // mirror path a directory, so publishing the mirror fails after the `.orig`. + const real = installClaude('2.1.220'); + mkdirSync(join(tweakccDir, 'native-binary.backup'), { recursive: true }); + + expect(await runPatchCommand({})).toBe(1); + const published = backupFiles().filter(name => name.endsWith('.orig')); + expect(published).toHaveLength(1); + expect(provenanceFor(join(tweakccDir, published[0]!), real)).toEqual({ install: real, assumed: false }); + }); }); describe('runPatchCommand legacy backup compatibility', () => { @@ -1088,6 +1146,30 @@ describe('runPatchCommand legacy backup compatibility', () => { expect(manifest.backupPath).toMatch(/claude-2\.1\.220-[0-9a-f]{16}\.orig$/); expect(readFileSync(manifest.backupPath)).toEqual(pristineBytes); expect(bundleOf(real)).toContain('"sol"'); + + // BOTH names are attributed. The legacy file is deliberately left on disk for + // `tweakcc --restore` and older clodex, so an unattributed one would be the one + // file a later restore could still match on its version tag alone. + expect(provenanceFor(manifest.backupPath, real)).toEqual({ install: real, assumed: false }); + expect(provenanceFor(legacy, real)).toEqual({ install: real, assumed: false }); + }); + + it('records both names before publishing the adopted backup', async () => { + // Same rule as the fresh snapshot: whichever file exists must already say whose + // bytes it holds. Fault injected after the canonical publish would be the wrong + // test — this asserts the ordering by failing the mirror, which runs after it. + const real = installClaude('2.1.220'); + const pristineBytes = readFileSync(real); + mkdirSync(tweakccDir, { recursive: true }); + const legacy = join(tweakccDir, 'claude-2.1.220.orig'); + writeFileSync(legacy, pristineBytes, { mode: 0o755 }); + mkdirSync(join(tweakccDir, 'native-binary.backup'), { recursive: true }); + + expect(await runPatchCommand({})).toBe(1); + const canonical = backupFiles().find(name => /^claude-2\.1\.220-[0-9a-f]{16}\.orig$/.test(name)); + expect(canonical).toBeDefined(); + expect(provenanceFor(join(tweakccDir, canonical!), real)).toEqual({ install: real, assumed: false }); + expect(provenanceFor(legacy, real)).toEqual({ install: real, assumed: false }); }); it('extracts the bundle exactly once when bootstrapping a pristine install', async () => { @@ -1218,6 +1300,51 @@ describe('runPatchCommand --restore', () => { // Issue #199. Two supported installs of ONE Claude Code version are different // files (npm platform package vs native installer), so "same version tag" was // never proof that a backup belongs to the install being restored. + it('records what a manifest for another version proved before a restore clears it', async () => { + // A manifest naming this same path at a DIFFERENT claude version is about an older + // install whose backup is still on disk. Restoring the current version clears that + // manifest — it matches on path — so its attribution has to be migrated first. + // Reachable by restoring an older `~/.clodex` from a machine backup, which is also + // why the manifest is read as untrusted elsewhere in this module. + const real = installClaude('2.1.220'); + expect(await runPatchCommand({})).toBe(0); + + // Stage the older version's backup and the manifest that is its only attribution. + const oldBackup = join(tweakccDir, 'claude-2.1.215-0123456789abcdef.orig'); + writeFileSync(oldBackup, 'pristine 2.1.215 bytes'); + writeFileSync(getPatchManifestPath(), JSON.stringify({ + ...readPatchManifest()!, + claudeVersion: '2.1.215', + backupPath: oldBackup, + })); + + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(provenanceFor(oldBackup, real)).toEqual({ install: real, assumed: false }); + }); + + it('records what an outgoing manifest proved before a patch replaces it', async () => { + // The manifest holds ONE install, and patching a second one overwrites it. For an + // upgrading user that manifest is the only thing attributing the first install's + // backup, so replacing it without migrating strips that install's rescue record. + const first = installClaude('2.1.220'); + expect(await runPatchCommand({})).toBe(0); + const firstBackup = readPatchManifest()!.backupPath; + const firstPristine = readFileSync(firstBackup); + rmSync(installProvenancePath(firstBackup, first)); + + const other = installOtherClaude('2.1.220'); + process.env.TWEAKCC_CC_INSTALLATION_PATH = other; + expect(await runPatchCommand({})).toBe(0); + expect(readPatchManifest()?.binaryPath).toBe(other); + expect(provenanceFor(firstBackup, first)).toEqual({ install: first, assumed: false }); + + // Which is what keeps the first install rescuable now that its manifest is gone. + delete process.env.TWEAKCC_CC_INSTALLATION_PATH; + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(readFileSync(first)).toEqual(firstPristine); + expect(logs.join('\n')).not.toMatch(/version tag alone/); + }); + it('refuses to restore another install\'s backup over a same-version install', async () => { const patched = installClaude('2.1.220'); expect(await runPatchCommand({})).toBe(0); @@ -1243,15 +1370,16 @@ describe('runPatchCommand --restore', () => { expect(readPatchManifest()).toBeNull(); }); - it('refuses even when both installs were patched and both backups are on disk', async () => { - // Tempting to restore "the backup the manifest did NOT name" — and unsound. - // The manifest holds one install, so an unnamed backup is only an install the - // manifest is silent about: this one, or a third whose backup was never - // recorded. Nothing on disk tells them apart, so this refuses. + // Issue #204. The manifest holds ONE install, so it could never rule a backup in + // by elimination and this used to refuse. Each backup now records the install it + // was made for, which decides it positively — and the manifest for the install + // that was NOT restored has to survive, or restoring one install would destroy + // the other's only rescue record. + it('restores each of two same-version installs from its own recorded backup', async () => { const other = installOtherClaude('2.1.220'); process.env.TWEAKCC_CC_INSTALLATION_PATH = other; expect(await runPatchCommand({})).toBe(0); - const otherPatched = readFileSync(other); + const otherPristine = readFileSync(readPatchManifest()!.backupPath); delete process.env.TWEAKCC_CC_INSTALLATION_PATH; const native = installClaude('2.1.220'); @@ -1260,10 +1388,353 @@ describe('runPatchCommand --restore', () => { expect(backupFiles().filter(name => name.endsWith('.orig'))).toHaveLength(2); process.env.TWEAKCC_CC_INSTALLATION_PATH = other; + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(readFileSync(other)).toEqual(otherPristine); + expect(readPatchManifest()?.binaryPath).toBe(native); + // Nothing was guessed: the backup itself records which install it belongs to. + expect(logs.join('\n')).not.toMatch(/version tag alone/); + + // And the OTHER one still restores from its own bytes afterwards — the point is + // that both work, not that the refusal moved to the other install. + const nativePristine = readFileSync(readPatchManifest()!.backupPath); + delete process.env.TWEAKCC_CC_INSTALLATION_PATH; + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(readFileSync(native)).toEqual(nativePristine); + expect(readPatchManifest()).toBeNull(); + expect(backupFiles().filter(name => name.endsWith('.orig'))).toHaveLength(2); + }); + + // Issue #204, the sequence that needed no lost files: a successful restore + // DELETES the manifest, so the backup outlives the only record of what it was + // made for. Version tags then matched, and the second install was overwritten. + it('refuses a backup whose install is gone once the manifest no longer exists', async () => { + const first = installClaude('2.1.220'); + const firstPristine = readFileSync(first); + expect(await runPatchCommand({})).toBe(0); + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(readPatchManifest()).toBeNull(); + expect(readFileSync(first)).toEqual(firstPristine); + + // A PATH change, an uninstall, or a corrected launcher now reaches a + // DIFFERENT install of the same version. + const other = installOtherClaude('2.1.220'); + const otherBytes = readFileSync(other); + process.env.TWEAKCC_CC_INSTALLATION_PATH = other; + expect(await runPatchCommand({ restore: true })).toBe(1); + expect(readFileSync(other)).toEqual(otherBytes); + expect(logs.join('\n')).toMatch(/recorded as the pristine content of/); + }); + + it('refuses to re-seed an install from another install\'s backup when its own is gone', async () => { + // Issue #204's laundering sequence. Patching here used to replace this + // install's bytes with the other install's pristine bytes AND write a manifest + // recording them as this install's pristine content — after which every later + // restore published them "correctly", with no warning left anywhere. + const other = installOtherClaude('2.1.220'); + process.env.TWEAKCC_CC_INSTALLATION_PATH = other; + expect(await runPatchCommand({})).toBe(0); + const otherBackup = readPatchManifest()!.backupPath; + const otherPatched = readFileSync(other); + + delete process.env.TWEAKCC_CC_INSTALLATION_PATH; + installClaude('2.1.220'); + expect(await runPatchCommand({})).toBe(0); + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(readPatchManifest()).toBeNull(); + + // clodex's own conflict message tells the user to remove a backup, so this + // state is reachable without inventing anything. + rmSync(otherBackup); + process.env.TWEAKCC_CC_INSTALLATION_PATH = other; + expect(await runPatchCommand({})).toBe(1); expect(readFileSync(other)).toEqual(otherPatched); - expect(readPatchManifest()?.binaryPath).toBe(native); - expect(logs.join('\n')).toMatch(/records a different Claude Code install/); + expect(readPatchManifest()).toBeNull(); + expect(logs.join('\n')).toMatch(/recorded as the pristine content of/); + }); + + it('still restores a backup written before provenance was recorded', async () => { + // Refusing here would strand every backup an earlier clodex wrote, so the + // version tag remains enough when there is no record at all — loudly. + const real = installClaude('2.1.220'); + const pristineBytes = readFileSync(real); + expect(await runPatchCommand({})).toBe(0); + const backup = readPatchManifest()!.backupPath; + rmSync(installProvenancePath(backup, real)); + rmSync(getPatchManifestPath()); + + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(readFileSync(real)).toEqual(pristineBytes); + expect(logs.join('\n')).toMatch(/version tag alone/); + // The guess is written down AS a guess, so a later run keeps warning about it + // instead of treating it as established. + expect(provenanceFor(backup, real)).toEqual({ install: real, assumed: true }); + }); + + it('records a second install whose bytes are identical to the first', async () => { + // Two installs can legitimately ship the same pristine bytes, and then one + // content-addressed backup is correct for both. The second install reaches it by + // the `reuse` plan — its live bytes already match a stored backup — and must be + // recorded there, or restoring it later is refused as another install's. + installClaude('2.1.220'); + expect(await runPatchCommand({})).toBe(0); + const backup = readPatchManifest()!.backupPath; + + const second = installOtherClaude('2.1.220', PRISTINE_BUNDLE); + const secondPristine = readFileSync(second); + // Byte-identical to the backup the FIRST install produced (`first` itself is + // patched by now), which is what makes one backup correct for both. + expect(sha256Of(second)).toBe(sha256Of(backup)); + process.env.TWEAKCC_CC_INSTALLATION_PATH = second; + expect(await runPatchCommand({})).toBe(0); + expect(provenanceFor(backup, second)).toEqual({ install: second, assumed: false }); + expect(backupFiles().filter(name => name.endsWith('.orig'))).toHaveLength(1); + + // Which is what lets it be restored on its own record, with nothing guessed. + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(readFileSync(second)).toEqual(secondPristine); + expect(logs.join('\n')).not.toMatch(/version tag alone/); + }); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'refuses a guessed restore it cannot record as a guess, leaving the binary alone', + async () => { + // The guess changes the live bytes to the backup's, after which "the live bytes + // match this backup" is no longer independent evidence — so the record saying it + // was a guess has to be on disk FIRST. It is the optional compatibility path, so + // refusing costs nothing. Fault injected by making the backup directory + // read-only, which fails the write while leaving nothing behind to read. + const real = installClaude('2.1.220'); + expect(await runPatchCommand({})).toBe(0); + const backup = readPatchManifest()!.backupPath; + const patched = readFileSync(real); + rmSync(installProvenancePath(backup, real)); + rmSync(getPatchManifestPath()); + chmodSync(tweakccDir, 0o500); + + try { + expect(await runPatchCommand({ restore: true })).toBe(1); + expect(readFileSync(real)).toEqual(patched); + expect(logs.join('\n')).toMatch(/Refusing to restore/); + } finally { + chmodSync(tweakccDir, 0o700); + } + }, + ); + + it('does not let a snapshot of the bytes a guess installed establish them', async () => { + // Nothing promotes a guess — not even a snapshot, which looks like independent + // evidence and is not: if the guess restored these very bytes onto this install, + // then "the install holds them" is a fact the guess created. Establishing on it + // would hand the bytes' true owner a refusal. + const real = installClaude('2.1.220'); + expect(await runPatchCommand({})).toBe(0); + const backup = readPatchManifest()!.backupPath; + rmSync(installProvenancePath(backup, real)); + rmSync(getPatchManifestPath()); + expect(await runPatchCommand({})).toBe(0); + expect(provenanceFor(backup, real)).toEqual({ install: real, assumed: true }); + + // The backup is lost, the install is pristine again at the same bytes, and only + // the guess remains to say where those bytes came from. + rmSync(backup); + rmSync(getPatchManifestPath()); + installClaude('2.1.220'); + + expect(await runPatchCommand({})).toBe(0); + expect(provenanceFor(backup, real)).toEqual({ install: real, assumed: true }); + expect(readPatchManifest()?.pristineProvenance).toBe('assumed'); + + // And the install is not stranded by that: its own restores still work, which is + // what refusing to promote has to cost nothing to be acceptable. + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(logs.join('\n')).toMatch(/version tag alone/); + }); + + it('keeps patching and restoring an install that has been snapshotted twice', async () => { + // tweakcc theming rewrites the binary in place, so one install legitimately has + // two pristine snapshots and two true records. Refusing on that contradiction + // made every later patch AND restore fail permanently, with a message telling the + // user to reinstall — which does not clear a record. + const real = installClaude('2.1.220'); + expect(await runPatchCommand({})).toBe(0); + const first = readPatchManifest()!.backupPath; + rmSync(getPatchManifestPath()); + installClaude('2.1.220', `${PRISTINE_BUNDLE}\n// themed by something else\n`); + expect(await runPatchCommand({})).toBe(0); + const second = readPatchManifest()!.backupPath; + expect(second).not.toBe(first); + expect(provenanceFor(first, real)).toEqual({ install: real, assumed: false }); + expect(provenanceFor(second, real)).toEqual({ install: real, assumed: false }); + + // A config change re-patches, and a restore returns the bytes the manifest + // describes — the one record that is provably current. + const secondPristine = readFileSync(second); + rmSync(join(clodexHome, 'config.json')); + saveFavorites(); + expect(await runPatchCommand({})).toBe(0); + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(readFileSync(real)).toEqual(secondPristine); + }); + + it('keeps a guessed manifest, so deleting the record cannot clear the guess', async () => { + // The manifest a guessing run wrote is the second carrier of that taint. Trading + // it for a record that only repeats the guess would mean the next run, with the + // record gone, reads a clean slate. + const real = installClaude('2.1.220'); + expect(await runPatchCommand({})).toBe(0); + const backup = readPatchManifest()!.backupPath; + rmSync(installProvenancePath(backup, real)); + rmSync(getPatchManifestPath()); + expect(await runPatchCommand({})).toBe(0); + expect(readPatchManifest()?.pristineProvenance).toBe('assumed'); + + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(readPatchManifest()?.pristineProvenance).toBe('assumed'); + }); + + it('refuses to guess a second install onto bytes already guessed onto a first', async () => { + // Issue #204's reachable sequence, with the pre-record backup an upgrading user + // has: A is restored by the version-tag fallback, and that guess is the reason + // not to repeat it for B. Before this, B was simply overwritten. + const first = installClaude('2.1.220'); + expect(await runPatchCommand({})).toBe(0); + const backup = readPatchManifest()!.backupPath; + rmSync(installProvenancePath(backup, first)); + rmSync(getPatchManifestPath()); + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(logs.join('\n')).toMatch(/version tag alone/); + + const other = installOtherClaude('2.1.220'); + const otherBytes = readFileSync(other); + process.env.TWEAKCC_CC_INSTALLATION_PATH = other; + expect(await runPatchCommand({ restore: true })).toBe(1); + expect(readFileSync(other)).toEqual(otherBytes); + expect(logs.join('\n')).toMatch(/have already been restored onto/); + }); + + it('does not establish a guess through a THIRD name another install created', async () => { + // The route a per-filename confidence check missed: the guess sits beside the + // legacy name, and the canonical name is created later by a DIFFERENT install's + // patch, so the guessing install's next patch found no record beside the name it + // chose. Confidence has to be resolved for the bytes, not the filename. + const native = installClaude('2.1.220'); + const pristineBytes = readFileSync(native); + mkdirSync(tweakccDir, { recursive: true }); + const legacy = join(tweakccDir, 'claude-2.1.220.orig'); + writeFileSync(legacy, pristineBytes, { mode: 0o755 }); + + // B is restored from the legacy backup on its version tag alone. + const other = installOtherClaude('2.1.220'); + process.env.TWEAKCC_CC_INSTALLATION_PATH = other; + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(provenanceFor(legacy, other)).toEqual({ install: other, assumed: true }); + + // A is patched in between, which adopts the legacy backup under its content + // address — a name B has no record beside. + delete process.env.TWEAKCC_CC_INSTALLATION_PATH; + expect(await runPatchCommand({})).toBe(0); + const canonical = readPatchManifest()!.backupPath; + expect(canonical).not.toBe(legacy); + + // B is patched. The guess must still be a guess under the new name. + process.env.TWEAKCC_CC_INSTALLATION_PATH = other; + expect(await runPatchCommand({})).toBe(0); + expect(provenanceFor(canonical, other)).toEqual({ install: other, assumed: true }); + expect(readPatchManifest()?.pristineProvenance).toBe('assumed'); + + // Which is what keeps a later reinstall of B safe: A owns these bytes. + rmSync(getPatchManifestPath()); + const replaced = installOtherClaude('2.1.220', `${PRISTINE_BUNDLE}\n// reinstalled\n`); + const replacedBytes = readFileSync(replaced); + expect(await runPatchCommand({ restore: true })).toBe(1); + expect(readFileSync(replaced)).toEqual(replacedBytes); + }); + + it('keeps a guess when only a record beside a deleted legacy backup remembers it', async () => { + // The scan finds records only beside an existing `.orig`, so a guess recorded + // against a legacy backup that is later deleted is invisible to it. The snapshot + // that follows would establish the very bytes the guess installed. + const native = installClaude('2.1.220'); + const pristineBytes = readFileSync(native); + mkdirSync(tweakccDir, { recursive: true }); + const legacy = join(tweakccDir, 'claude-2.1.220.orig'); + writeFileSync(legacy, pristineBytes, { mode: 0o755 }); + + const other = installOtherClaude('2.1.220'); + process.env.TWEAKCC_CC_INSTALLATION_PATH = other; + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(provenanceFor(legacy, other)).toEqual({ install: other, assumed: true }); + + // Everything but the record is gone, and B now holds the guessed bytes. + rmSync(legacy); + + expect(await runPatchCommand({})).toBe(0); + const canonical = readPatchManifest()!.backupPath; + expect(provenanceFor(canonical, other)).toEqual({ install: other, assumed: true }); + }); + + it('does not establish a guess under the content address a legacy backup is adopted into', async () => { + // A pre-content-addressing backup holds install A's bytes. B is restored from it + // by version tag — correctly recorded as a guess — and then patched, which copies + // those bytes to a content-addressed name. Establishing the new name would launder + // the guess through a filename change. + const real = installClaude('2.1.220'); + const pristineBytes = readFileSync(real); + mkdirSync(tweakccDir, { recursive: true }); + const legacy = join(tweakccDir, 'claude-2.1.220.orig'); + writeFileSync(legacy, pristineBytes, { mode: 0o755 }); + + const other = installOtherClaude('2.1.220'); + process.env.TWEAKCC_CC_INSTALLATION_PATH = other; + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(logs.join('\n')).toMatch(/version tag alone/); + expect(provenanceFor(legacy, other)).toEqual({ install: other, assumed: true }); + + expect(await runPatchCommand({})).toBe(0); + const canonical = readPatchManifest()!.backupPath; + expect(canonical).not.toBe(legacy); + expect(provenanceFor(canonical, other)).toEqual({ install: other, assumed: true }); + expect(readPatchManifest()?.pristineProvenance).toBe('assumed'); + }); + + it('records what the manifest proved before deleting it', async () => { + // An upgrading user's backup predates provenance records, so the manifest is the + // only thing tying it to this install — and a successful restore deletes the + // manifest. Without this migration the backup is left unattributed and the next + // same-version install is restored from it by version tag alone (issue #204). + const real = installClaude('2.1.220'); + expect(await runPatchCommand({})).toBe(0); + const backup = readPatchManifest()!.backupPath; + rmSync(installProvenancePath(backup, real)); + + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(readPatchManifest()).toBeNull(); + expect(provenanceFor(backup, real)).toEqual({ install: real, assumed: false }); + + // Which is what makes the second install safe now that the manifest is gone. + const other = installOtherClaude('2.1.220'); + const otherBytes = readFileSync(other); + process.env.TWEAKCC_CC_INSTALLATION_PATH = other; + expect(await runPatchCommand({ restore: true })).toBe(1); + expect(readFileSync(other)).toEqual(otherBytes); + }); + + it('keeps the manifest when it cannot record what the manifest proved', async () => { + // The migration must not turn a working rescue into a failure: the copy still + // happens, and the manifest is kept so the association is not lost either. + const real = installClaude('2.1.220'); + const pristineBytes = readFileSync(real); + expect(await runPatchCommand({})).toBe(0); + const backup = readPatchManifest()!.backupPath; + // Make the record unwritable by parking a directory at its name. + rmSync(installProvenancePath(backup, real)); + mkdirSync(installProvenancePath(backup, real), { recursive: true }); + + expect(await runPatchCommand({ restore: true })).toBe(0); + expect(readFileSync(real)).toEqual(pristineBytes); + expect(readPatchManifest()?.binaryPath).toBe(real); + expect(logs.join('\n')).toMatch(/Restoring anyway and keeping the patch manifest/); }); it('still restores an install the manifest passed over at a DIFFERENT version', async () => { @@ -1282,7 +1753,9 @@ describe('runPatchCommand --restore', () => { delete process.env.TWEAKCC_CC_INSTALLATION_PATH; expect(await runPatchCommand({ restore: true })).toBe(0); expect(readFileSync(real)).toEqual(pristineBytes); - expect(logs.join('\n')).toMatch(/version tag alone/); + // And it is not a guess any more: this backup records the install it was made + // for, so the restore rests on that rather than on the version in its name. + expect(logs.join('\n')).not.toMatch(/version tag alone/); }); it('refuses when the manifest records this install but its backup was deleted', async () => { @@ -1302,7 +1775,10 @@ describe('runPatchCommand --restore', () => { expect(await runPatchCommand({ restore: true })).toBe(1); expect(readFileSync(native)).toEqual(nativePatched); - expect(logs.join('\n')).toMatch(/as the pristine content of/); + // The manifest's own refusal, not one of the provenance ones — with a + // same-version backup on disk all three are available and they mean different + // things. + expect(logs.join('\n')).toMatch(/and clodex cannot use it/); }); it('reports an error instead of restoring when no trustworthy backup exists', async () => { @@ -1311,7 +1787,7 @@ describe('runPatchCommand --restore', () => { expect(await runPatchCommand({ restore: true })).toBe(1); expect(sha256Of(real)).toBe(before); - expect(logs.join('\n')).toMatch(/no trustworthy pristine backup/); + expect(logs.join('\n')).toMatch(/holds no pristine backup of claude 2\.1\.220 it can attribute to/); }); it('still restores when the binary is too broken to report its version', async () => {