fix(patch): keep two Claude Code installs of one version from clobbering each other on restore - #228
Merged
Merged
Conversation
…ing each other on restore Each pristine backup now records which installs its bytes are the pristine content of -- one small file per install beside the backup -- so `clodex patch --restore` no longer falls back to matching on the claude version in a file name when no patch manifest survives. The manifest could not carry this on its own: it records ONE install, and a successful restore deletes it. So patch install A, restore A, then have the target resolve to a different same-version install B, and the next restore published A's pristine bytes over B with nothing but a warning -- no lost files anywhere in that sequence. The same gap let a user who answered the "conflicting pristine backups" refusal by deleting a file launder the wrong bytes into a manifest that every later restore then trusted on sight. - A machine with two same-version installs now restores each from its own backup instead of refusing. That case previously had no evidence to decide on. - A record naming this install outranks a manifest that records a different one; a manifest that DISAGREES with a record about the same install refuses, because nothing dates either one. - Both `--restore` and `clodex patch` migrate what a manifest proved before replacing or clearing it, so an upgrading user's older backup is not left unattributed by the one command that had the evidence in hand. The manifest is dropped only once an established record stands in its place. - A backup matched on its version tag alone -- the remaining compatibility fallback -- is recorded AS a guess, and that guess is never re-derived as proof: not through the live bytes matching the backup the guess copied them from, not through the manifest the guessing run wrote, and not through the content address a legacy backup is adopted into. It is written before the binary changes, and refused outright if it cannot be. Running the same fallback for a SECOND install is refused. - Nothing promotes a guess, including a fresh snapshot of the very bytes the guess installed: the install holding them is a fact the guess created, and establishing on it would hand their true owner a refusal. An install whose record stays a guess is not stranded -- its own restores work. Confidence is resolved for the BYTES, not one filename, or a third name could still carry it. - A manifest that disagrees with a record about one install refuses only when it does NOT describe the live bytes. One install legitimately has two pristine snapshots when something rewrites its binary in place, and refusing there failed every later patch and restore permanently. - A record that exists but cannot be read refuses rather than falling back; damaged evidence that those bytes were claimed is not the same as no evidence. - No backup is published before its record, so an interrupted patch cannot leave an unattributed one behind. One file per install rather than a shared list: the patch lock lives under CLODEX_HOME while the backup directory is shared, so a merged list could lose an entry. Closes #204
bman654
force-pushed
the
fix/backup-provenance
branch
from
September 12, 2026 00:42
11061c8 to
c83e19f
Compare
Owner
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
If you have two Claude Code installs of the same version on one machine — the native installer and
the npm package, say —
clodex patch --restorecould put the wrong one's original bytes over theother, report success, and leave you with a working-looking install that is not the one you had.
That is now refused. As a bonus, the case that used to give up with "Found conflicting pristine
backups" just works: each backup records which install it came from, so each install restores from
its own.
The failure
Remaining leg of #199, filed as #204. Nothing had to be lost or hand-edited to reach it:
clodex patchinstall A — backup X is stored, and the patch manifest records A.clodex patch --restore— succeeds, and deletes the manifest. X stays on disk.TWEAKCC_CC_INSTALLATION_PATH/PATH now reaches a different same-versioninstall B.
clodex patch --restore→ exit 0, B's bytes replaced with A's pristine bytes, one warning.A wiped
~/.clodexor a differentCLODEX_HOMEreach the same state. And it could launder itself:patch B, patch A, restore A, then answer the
conflicting pristine backupsrefusal by deleting B'sown backup —
clodex patchre-seeded B from A's bytes and wrote a manifest recording them as B'spristine content, after which every later restore published them "correctly", with no warning left
anywhere.
Root cause
The manifest was the only record of which install a backup belonged to, and it is structurally
unable to be that record: it holds one install, and a successful
--restoredeletes it. #199made the manifest refuse where it had evidence; where it had none, selection still rested on the
claude version in the backup's file name, which is not install provenance — the npm platform
package and the native installer ship different files under the same version.
The change
Each backup gets one small file per install beside it:
claude-<ver>-<sha>.orig.for-<hash of install path>.json, holding{"install": "<path>", "assumed": <bool>}. Multiple installs is the normal case, not an edge —backups are content-addressed, so two installs with identical pristine bytes legitimately share one
backup and it is correct for either. A file per install rather than a shared list because the patch
lock lives under
CLODEX_HOMEwhile the backup directory is shared, so a read-merge-rewrite couldlose an entry; a create-once file has nothing to merge.
selectRestoreSourcethen:evidence, where the manifest was only evidence about the directory, and the manifest for the
install that was not restored has to survive;
claimed is not the same as no evidence. The message names the file; deleting it opts back into the
fallback;
loud note.
Four consequences worth calling out, each of which is where a reviewer found this incomplete:
--restorerecords what the manifest proved before clearing it. An upgrading user's backuppredates these records, so the manifest is its only attribution — and restore deletes the
manifest. Without the migration, the one command that had the evidence in hand leaves the backup
unattributed, which is the destructive state above. If that write fails, the rescue still happens
and the manifest is kept.
--restoreclears the manifest only when it records the install being restored. Selection cannow succeed on a record while the manifest holds a different install; clearing it there would
delete that install's only rescue record — clodex patch --restore can overwrite an install with a different install's backup #199's damage from the other direction.
warned version-tag restore the live bytes match the backup because the guess put them there, so
the next patch took the
reusepath and recorded it as established; a guessingclodex patchwrites a manifest the next run reads as independent evidence; and canonicalizing a legacy backup
laundered the guess through a filename change. Every plan inherits the confidence already recorded
for those bytes, including a fresh snapshot — the install holding them is a fact the guess
created. An install whose record stays a guess is not stranded: its own restores still work, with
the note. A guess is written before the binary changes, and a guess that cannot be recorded is
refused outright.
name a legacy backup is adopted into. A published
.origwith no record beside it is exactly theunattributed 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 — scanning starts from the
.origfiles,so an orphan record is inert.
describe the live bytes. One install legitimately has two pristine snapshots when something
rewrites its binary in place (tweakcc theming does, and the snapshot path keeps both files on
purpose). Refusing on that contradiction unconditionally 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.
manifest.patchedSha256 === liveSha256separates the two: when itholds, clodex provably wrote those bytes last. Where neither claim can be dated the refusal stands,
and it now names the record files, because deleting the stale one is the way out.
What I deliberately left out
backup an earlier clodex wrote, including on the single-install machine where the guess is always
right. It self-heals:
--restoremigrates the manifest's evidence, and any patch of that installwrites a record.
APFS reads as another install: the restore is refused rather than falling back (safe, and the
message names the 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 every existing manifest'sbinaryPathand so needsa migration. Filed separately.
record pointing at a path whose bytes are no longer the ones it was written for. Two disagreeing
records now refuse, and so does a manifest that disagrees with a record and cannot be dated, so the
destructive form is narrowed to the case where neither exists yet. Closing it entirely needs
--restoreto read the live bundle for a/*ccpatch:marker first — clodex patch --restore still picks a backup by version tag when no manifest exists #204's fix 1 — which isMach-O/ELF/PE sensitive. Filed separately.
recording the same install under different
CLODEX_HOMEs can race and the last rename wins, so anestablished 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 that already lets--restore(which takes none atall) interleave with a patch on
main. Filed separately.~/.tweakcc/native-binary.backupis still a single slot holding whichever install clodexpatched last, so
tweakcc --restoreon a two-install machine still performs the copy this changerefuses. clodex writes that file but does not own the command. Unchanged, noted in clodex patch --restore still picks a backup by version tag when no manifest exists #204.
Evidence
This went through three rounds of adversarial review, and every round found real defects — including
two rounds' worth in fixes made for the previous round. That history is why the confidence rules above
are shaped the way they are:
--restoremigration was missing entirely, and skipping the record for a guess was notenough to keep it from being re-derived;
assumedas a one-way latch then blocked independent evidence while the manifest was stilldeleted, destroying proof rather than migrating it; and the guess still hardened through a legacy
filename change, a failed write after the copy, and a damaged-record repair;
permanent dead end once anything rewrote its binary in place, and a snapshot of the bytes a guess
had installed still promoted that guess.
-t). Deletion andpredicate-rewrite forms of every guard: each refusal (other-install, damaged, contradictory,
manifest-vs-record, guessed-elsewhere), "recorded for this install" weakened to "recorded for
anything", assumed records counted as established, downgrade allowed, promotion allowed, the
confidence inheritance and each of its sources, the manifest confidence field, both migration sites,
the manifest-deletion guard, the write-failure guards, the record/publish ordering for both the
snapshot and the adopted legacy name, the reuse-path record, the scanner's legacy branch and its
name/payload check, and the refusals' positions relative to one another. Four survived their first
battery — the write ordering, the
reuserecord, the snapshot promotion, and keeping a guessedmanifest — and the tests that kill them now were written for them.
runPatchCommanditself (records arewritten by the real command, never planted) and assert the destructive outcome is absent — bytes
unchanged, no laundered manifest — not merely that the exit code is 1. The two write-failure
behaviours are proven by real fault injection (a directory parked at the record's name, and at the
tweakcc mirror's), not by reasoning.
pnpm typecheck && pnpm test && pnpm buildgreen: 2622 tests, 116 files. Also green underCLODEX_HOME=$(mktemp -d). Node 24.14.1, macOS arm64.scripts/probe-patch-mechanism.mjspasses on all three formats — Mach-O (darwin-arm64), ELF(linux-x64), PE (win32-x64), real Claude Code 2.1.260 release binaries, 0 failed checks each.
This change does not touch the read/repack path, so the probe is a control rather than the thing
under test; those are the binaries cached locally, not the newest release.
tweakccnever enumerates~/.tweakccfor backups — it namesnative-binary.backupexplicitly(checked in the installed
tweakccdist), so the record files are inert to it.Failure behaviour
On the patch path a record that cannot be written fails the patch before anything is published:
applyPatch's catch returns an error, the candidate is discarded infinally, and the live binary isnever renamed over. On the restore path, a guess that cannot be recorded is refused with the binary
untouched; an established restore proceeds, warns, and keeps the manifest. A damaged record refuses
rather than misleading. Both patch-path writes run inside the existing patch lock.
Every new refusal has a stated way out, and reinstalling Claude Code clears each one — that was
checked per refusal, because a refusal without an exit is worse than the bug it prevents.
Land order
Rebase after #226 and #227. #227 is conflict-free; #226 renames
publishBackupFiletopublishFileByRenameand touches the same lines around the mirror publish, so it conflictsmechanically in
src/patcher.ts.Closes #204