Bucket backup: snapshot over the API, scrub secrets, and never trust a default - #38
Merged
Merged
Conversation
…a default
The backup on this Space had never once succeeded. Its dataset held one
commit — "initial commit" — after a day of hourly runs. This replaces the
step that failed, and closes a way it could have published the bucket.
The old step 2 handed a mounted folder to hf upload and let it enumerate.
That enumeration was the whole cost, paid on every file whether wanted or
not. Measured on this bucket: a stat-only walk of the mount takes 14m51s for
108,960 files, the same tree lists over the API in 8s, and the same content
on the Job's local disk walks in 0.14s. So: list over the API, decide the
scope in code, copy just those paths server-side by xet hash into a private
ephemeral staging bucket (metadata only, ~880 files/s), download that to
local disk, scrub, verify, commit, delete the staging bucket in a finally.
End to end at prod scale: 4m24s.
Being frozen matters as much as being fast. The live bucket is written while
a run works, and reading it directly died with "not a file on the local file
system" when the Space rotated a file away mid-upload. A snapshot cannot
change underneath the run.
Secrets never reach the history, two ways, because either alone is not
enough. Credential-bearing files are skipped by exact final path segment — a
substring test for "/credentials" sails straight past "/.credentials.json",
which is how one reached a probe commit. Stray occurrences are scrubbed and
then VERIFIED, and a leftover match ends the run with no commit. The patterns
are boundary-anchored: a looser sk-[A-Za-z0-9_-]{24,} matched
"sk-abstraction-and-chart-selection" in ordinary prose, and a scrub that
rewrites real content is worse than one that over-reports. This is not
hygiene: the Hub's scanner is TruffleHog and it verifies a find by
authenticating with it, which invalidates a live token.
Nothing is private by default, and this file assumed otherwise. hf upload
into a dataset that does not exist creates it PUBLIC — verified — under a
comment claiming the opposite, with the privacy gate skipped in exactly that
case. On any Space whose backup dataset did not exist yet, the first run
published the bucket. Every destination is now created with visibility stated
explicitly and then read back before a byte is written, including the source.
The default skip list is rebuilt on one criterion, and it is not size: a
command can put it back. Excluding only manager-owned, cache and temp dirs
drops 86,615 files and 8.05 GB of this bucket and keeps 23,975 files. An
earlier draft used a 10 MB per-file cap, which quietly discarded 1.81 GB
including four session transcripts of 292, 237, 142 and 141 MB — the most
history-shaped content there is. No size rule survives. .git stays: unpushed
commits and staged work live nowhere else, and a partial .git is corrupt.
The mirror is gone. It was a second full copy that never deleted anything, so
it archived every credential the Space had ever held — 174 files present
there and absent from the live bucket. What it offered, "the Space exactly as
it was, latest only", is what the bucket already is. A lost bucket now means
re-logging in; an existing mirror is left alone for the operator to remove.
Settings gains "Restore defaults" next to the skip list, since the list is 30
names picked from measurements and trimming it otherwise means retyping them.
It compares against the local edit, not the server's copy, so it disappears
the moment it would be a no-op.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Why
The backup on this Space has never once succeeded.
lvwerra/agent-manager-backupholds a single commit —
initial commit— after a day of hourly runs. This PRreplaces the step that fails, and closes a path by which it could have published
the bucket.
1. The mount had to go (measured)
The old step 2 handed a mounted folder to
hf uploadand let it enumerate. Thatenumeration is the entire cost, and it is paid on every file whether wanted or not.
find /live -type f)list_bucket_tree)~8 ms per file on the mount; ~6,000× faster on local disk. The Job has 453 GB free,
so the download is bounded by bandwidth (~5 MB/s measured), not space.
The new pipeline: list over the API → decide the scope in code → copy just those
paths server-side by Xet hash into a private, ephemeral staging bucket (metadata
only, ~880 files/s) → download to local disk → scrub → verify → commit → delete
staging in a
finally.Measured end to end at prod scale: list 8.3 s → snapshot 12 s → download 129 s →
scrub 2 s → verify → commit 113 s. 4m24s, for a job that had never finished.
Being frozen matters as much as being fast. The live bucket is written while a run
works, and reading it directly died with
not a file on the local file systemwhenthe Space rotated a file away mid-upload. A snapshot cannot change underneath a run.
Three failure modes die with the mount, all observed here: the
.cache/pathrejection,
Job timeoutat 1h33m–2h07m (against the--timeout 3000swe ask for,which is not honoured), and the vanishing file above.
2. Secrets never reach the history
A fourth failure mode can't be outrun: the Hub secret-scans dataset commits, and
buckets are not scanned. The scanner is TruffleHog, and it verifies a find by
authenticating with the token — which invalidates it. One of this Space's HF
tokens was invalidated exactly that way. So this isn't hygiene; a leaking commit
breaks the operator's credentials.
Two mechanisms, because either alone is insufficient:
segment. A substring test for
/credentialssails straight past/.credentials.json— that gap put a credentials file into a probe commit.mandatory: a leftover match ends the run with no commit.
Patterns are boundary-anchored. A looser
sk-[A-Za-z0-9_-]{24,}matchedsk-abstraction-and-chart-selectioninside ordinary prose — a scrub that silentlyrewrites real content is worse than one that over-reports. Re-scanning 73 flagged
files with tight patterns: 52 real, 21 false positives.
3. Nothing is private by default — and this file assumed otherwise
Both verified against the Hub:
create_bucket(id)with noprivate=hf upload <new-dataset>(repo does not exist)The second was live on
main, under a comment asserting the opposite —"Not existing yet is fine — the upload below creates it private" — with the
privacy gate skipped in exactly that case. On any Space whose backup dataset
didn't exist yet, the first run published the bucket. This Space escaped only
because its dataset was created by an earlier iteration and nothing has committed
since.
Now: every destination is created with visibility stated explicitly, then
read back and checked before a byte is written. The source bucket is gated too.
hf cpis fine — it refuses to auto-create in either form.4. The default skip list, rebuilt on reproducibility
One criterion, and it is not size: a command can put it back. Excluding only
manager-owned, cache and temp directories drops 86,615 files / 8.05 GB of this
bucket and keeps 23,975 files / 3.78 GB.
An earlier draft of mine used a 10 MB per-file cap. It quietly discarded 1.81 GB
including four session transcripts of 292, 237, 142 and 141 MB — the most
history-shaped content on the bucket. No size rule survives anywhere, and
there's a test asserting that.
.gitstays in: unpushed commits and staged changes live nowhere else, and apartially-copied
.gitis a corrupt repo. Git worktrees stay in as a class, sinceuncommitted work in a clone isn't reproducible from its remote.
dist/build/targetstay opt-in — a source folder is calledbuildoften enough.5. The mirror is gone
It was a second full copy that never deleted anything, so it archived every
credential the Space had ever held — 174 files present in the mirror and absent
from the live bucket. What it offered ("the Space exactly as it was, latest only")
is what the bucket already is; restarts never involved backup, since the bucket is
the persistent store.
The trade this accepts: a lost bucket now means re-logging in, because the history
holds no credentials by design. An existing mirror bucket is not deleted
automatically — that's the operator's call.
6. Restore defaults
The skip list is 30 names picked from measurements, so trimming it otherwise means
retyping them. The control compares against the local edit, not the server's
copy, so it disappears the moment it would be a no-op — verified in a browser: 2
chips → click → 30 chips → button gone.
Verified
-vin the launch, noAM_MIRRORanywhere, every destination created private and re-read, stagingtorn down in a
finally, commit gated behind a self-verifying scrub, exact-namecredential matching, empty-scope refusal, defaults-skip-only-reproducible,
no-
.git-and-no-size-rule, and restore-defaults semantics.tsc --noEmitand the web build are clean.being written up; the numbers above are from those runs, not estimates.
Not done
shell_snapshotsfiles with HF tokens,
state/claude/.credentials.json,home/.config/gh/hosts.ymlstill hold live secrets in the bucket and its mirror. This PR keeps them out of
the history; it does not clean or revoke them, which is the operator's call.
--timeoutbeing ignored is still unexplained. It matters less now that a runtakes ~4 minutes, but a stuck run still occupies whatever the platform's limit is.
workspaces/the-gatherer/workis 3,740 files / 1.37 GBof downloaded arxiv PDFs — re-downloadable in principle, but not by one command,
so by the reproducibility rule it stays. It's about a third of the transfer if you
want it gone.