Skip to content

fix(patch): keep two Claude Code installs of one version from clobbering each other on restore - #228

Merged
bman654 merged 1 commit into
mainfrom
fix/backup-provenance
Sep 12, 2026
Merged

bman654 merged 1 commit into
mainfrom
fix/backup-provenance

Conversation

@bman654

@bman654 bman654 commented Sep 12, 2026

Copy link
Copy Markdown
Owner

If you have two Claude Code installs of the same version on one machine — the native installer and
the npm package, say — clodex patch --restore could put the wrong one's original bytes over the
other, 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:

  1. clodex patch install A — backup X is stored, and the patch manifest records A.
  2. clodex patch --restore — succeeds, and deletes the manifest. X stays on disk.
  3. A is uninstalled, or TWEAKCC_CC_INSTALLATION_PATH/PATH now reaches a different same-version
    install B.
  4. clodex patch --restore → exit 0, B's bytes replaced with A's pristine bytes, one warning.

A wiped ~/.clodex or a different CLODEX_HOME reach the same state. And it could launder itself:
patch B, patch A, restore A, then answer the conflicting pristine backups refusal by deleting B's
own backup — clodex patch re-seeded B from A's bytes and wrote a manifest recording them as B's
pristine 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 --restore deletes it. #199
made 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_HOME while the backup directory is shared, so a read-merge-rewrite could
lose an entry; a create-once file has nothing to merge.

selectRestoreSource then:

  • refuses a backup recorded for some OTHER install, exactly like a manifest that records one;
  • lets a record naming THIS install outrank a manifest that records a different one — it is direct
    evidence, where the manifest was only evidence about the directory, and the manifest for the
    install that was not restored has to survive;
  • refuses when two records for one install disagree about its bytes;
  • refuses when a record exists but cannot be read — damaged evidence that those bytes were
    claimed is not the same as no evidence. The message names the file; deleting it opts back into the
    fallback;
  • keeps the version-tag fallback only for a backup carrying no record at all, with the existing
    loud note.

Four consequences worth calling out, each of which is where a reviewer found this incomplete:

  • --restore records what the manifest proved before clearing it. An upgrading user's backup
    predates 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.
  • --restore clears the manifest only when it records the install being restored. Selection can
    now 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.
  • A guess is recorded AS a guess and nothing promotes it. Skipping it 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; a guessing clodex patch
    writes 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.
  • A backup is never 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 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 — scanning starts from the .orig files,
    so an orphan record is inert.
  • A manifest that disagrees with a record about one install refuses — but only when it does not
    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 === liveSha256 separates the two: when it
    holds, 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

  • The pre-record backup still restores on its version tag alone. 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 migrates the manifest's evidence, and any patch of that install
    writes a record.
  • Path identity is still plain string equality, 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 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's binaryPath and so needs
    a migration. Filed separately.
  • 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 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
    --restore to 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 is
    Mach-O/ELF/PE sensitive. Filed separately.
  • A record's confidence is written with temp + rename, not compare-and-swap. Two processes
    recording the same install under different CLODEX_HOMEs 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 that already lets --restore (which takes none at
    all) interleave with a patch on main. Filed separately.
  • ~/.tweakcc/native-binary.backup is still a single slot holding whichever install clodex
    patched last, so tweakcc --restore on a two-install machine still performs the copy this change
    refuses. 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:

  1. the first --restore migration was missing entirely, and skipping the record for a guess was not
    enough to keep it from being re-derived;
  2. assumed as a one-way latch then blocked independent evidence while the manifest was still
    deleted, 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;
  3. the manifest-vs-record refusal added in round 2 turned an ordinary single-install machine into a
    permanent dead end once anything rewrote its binary in place, and a snapshot of the bytes a guess
    had installed still promoted that guess.
  • 34 mutants across three batteries, all killed, full-file runs (never -t). Deletion and
    predicate-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 reuse record, the snapshot promotion, and keeping a guessed
    manifest — and the tests that kill them now were written for them.
  • End-to-end tests replay the issue's sequences through runPatchCommand itself (records are
    written 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 build green: 2622 tests, 116 files. Also green under
    CLODEX_HOME=$(mktemp -d). Node 24.14.1, macOS arm64.
  • scripts/probe-patch-mechanism.mjs passes 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.
  • tweakcc never enumerates ~/.tweakcc for backups — it names native-binary.backup explicitly
    (checked in the installed tweakcc dist), 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 in finally, and the live binary is
never 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 publishBackupFile to
publishFileByRename and touches the same lines around the mirror publish, so it conflicts
mechanically in src/patcher.ts.

Closes #204

…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
bman654 force-pushed the fix/backup-provenance branch from 11061c8 to c83e19f Compare September 12, 2026 00:42
@bman654
bman654 merged commit 3581e39 into main Sep 12, 2026
5 checks passed
@bman654

bman654 commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

Follow-ups filed for the three limitations this PR documents rather than fixes: #230 (path string identity), #231 (--restore never inspects the live binary — #204's fix 1), #232 (the patch lock is scoped to CLODEX_HOME while the backup directory is shared).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

clodex patch --restore still picks a backup by version tag when no manifest exists

1 participant