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 () => {