d8bb5ab2628d66f9d3ea4dad15d31670d3d25412
341
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d8bb5ab262 |
Address review findings: durability, stale container ids, honest toasts
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 6m18s
Build App (Preview) / build-linux (pull_request) Successful in 7m48s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
An Opus review of the previous commit found several real gaps: - pending_cleanup::save used plain write-temp-then-rename, unlike migration_store's fsync'd write it claimed to mirror — a crash in that window left a truncated record that list() would skip forever, silently reproducing the exact bug this module exists to fix. Now matches migration_store's File::create/write_all/sync_all/rename/sync_dir shape, and the tests exercise the real save/list/clear functions against a temp dir instead of re-implementing their bodies inline. - remove_project and rebuild_project_container only ever looked at project.container_id, unlike every other container-destroying path in the codebase, which falls back to find_existing_container for exactly this race (a crash between creating a container and persisting its id). A miss here left a container that then blocked every subsequent volume removal with a 409, forever. Both now resolve the same way the rest of the codebase does, and record the container by its deterministic name rather than its id so a retry still has something that resolves. - remove_project's toast promised an automatic retry unconditionally, even when writing the pending-cleanup record itself failed (the one case where nothing will actually retry). ProjectRemovalReport now carries retry_scheduled, and the UI is honest about which case it's in. - remove_volumes_by_name now retries once after a short delay on a 409, since Docker releasing a volume's mount reference right after its container is removed is not always instantaneous, and this is exactly the sequence remove_project runs. - rebuild_project_container (Reset) returns ProjectResetOutcome so the UI can warn when Reset could not fully clear a project's volumes, instead of only logging it — the new container silently reuses old data otherwise, which is what Reset promises not to do. - retry_pending_cleanup_logged escalates a record's log level after it has failed for a week, since recorded_at was otherwise write-only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ |
||
|
|
4827170715 |
Report and retry Docker resources remove_project could not delete
Secret Scan / scan (push) Successful in 10s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Secret Scan / scan (pull_request) Successful in 8s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-linux (pull_request) Successful in 6m5s
Build App (Preview) / build-macos (pull_request) Successful in 2m45s
Build App (Preview) / build-windows (pull_request) Successful in 5m42s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
remove_project_volumes always returned Ok(()) regardless of what actually happened, making the `if let Err(e)` guarding it at every call site dead code. remove_project then dropped the project record unconditionally, so a volume, image or container that failed to delete became permanently unreachable — confirmed against a real orphaned volume pair found in the wild (fixes #31). remove_project_volumes/remove_snapshot_image/remove_container now report what they could not remove (treating "already gone" as success rather than a leftover), remove_project surfaces this to the user via a toast, and before dropping the project record it writes a pending-cleanup record that startup housekeeping retries automatically on the next launch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ |
||
|
|
1a79852f65 |
Merge pull request 'Remove a live credential from a test fixture, and scan for the next one' (#33) from fix/test-fixture-secret into main
Build App / compute-version (push) Successful in 3s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 4m50s
Build App / build-linux (push) Successful in 6m46s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 11s
Reviewed-on: #33 |
||
|
|
68b73a9102 |
Refuse a commit that adds something shaped like a credential
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m34s
Build App (Preview) / prune-previews (pull_request) Canceled after 0s
Build App (Preview) / build-linux (pull_request) Canceled after 3m23s
Build App (Preview) / build-windows (pull_request) Canceled after 3m26s
The companion to the fixture removal. A site-admin token sat in a test file for
92 commits and fourteen days on a public mirror, past five audit rounds and two
independent reviews, because all of them read the code under change and this was
not under change. A grep would have caught it the first day.
`scripts/scan-secrets.sh` is that grep, in three rules:
* vendor-prefixed credentials — `ghp_`, `github_pat_`, `glpat-`, `xox*-`,
`sk-`, `AKIA`/`ASIA`, `ya29.`, `AIza`, `npm_`, `dckr_pat_`. Shape alone
identifies these, so there is no context to get wrong.
* `BEGIN … PRIVATE KEY` blocks.
* an opaque literal assigned to a secret-shaped name — the rule that would
have caught this one.
The third rule needs **both** halves, and that is what makes it usable rather
than another disabled check. Measured before writing it: an entropy-only rule
flags 317 literals in this tree, and name-proximity alone flags four, three of
which are `secure::get_project_secret(&id, "aws-secret-access-key")` — a
keychain *key name* sitting next to the word `secret`. Requiring the literal
itself to be hex or base64 with no word structure is what excludes those.
Validated rather than asserted:
* **0 false positives** across every tracked file.
* **Catches the real incident** — `--range 9b2f4fe~1..9b2f4fe` is refused.
* Twelve shaped cases pass and fail as intended, including a sha256 in an
`assert_eq!`, a git sha in a comment and the new dummy fixture, none of
which trip it.
* The hook was proved to block an actual `git commit`, not just to exist.
Two halves, because each covers the other's gap:
* `.githooks/pre-commit`, enabled per clone by `npm run hooks`. Git will not
let a repository set its own hooks path — cloning would then be enough to
run its code — so this is opt-in everywhere and `--no-verify` skips it.
* `Secret Scan`, which nobody can bypass. It carries **no `paths:` filter** on
purpose: the leak lived in `app/**` and `build.yml` only runs for
`container/**`, so a path-filtered scan would have missed the very thing it
exists for. It scans the whole tracked tree rather than a range, because a
wrong range fails *open* and the full pass takes 0.5s.
Also fixed while here: `core.hooksPath` in this clone pointed at
`/workspace/.git/hooks`, a directory that does not exist — so git hooks were
disabled outright and anything dropped in `.git/hooks` would have been ignored
in silence. A hook that never runs is worse than no hook, because the checklist
says it is there.
`--tracked` skips binaries. Feeding a blob to grep gets "binary file matches"
instead of the line, so a genuine finding inside one would arrive as a sentence
nobody can act on.
A line ending `pragma: allowlist secret` is skipped — wordy on purpose, so it
reads as a claim and leaves something greppable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
|
||
|
|
d09e2a2743 |
Stop using a live credential as a test fixture
`the_custom_env_fingerprint_never_carries_the_value` asserted that the custom-env
fingerprint does not leak a secret — using the maintainer's real Gitea token as
the secret. It was committed on 2026-08-11 in
|
||
|
|
4371c9f03e |
Merge pull request 'Terminal newlines, OAuth callback, Claude Code settings, and the Files tab' (#30) from ship/core into main
Build App / compute-version (push) Successful in 5s
Build Container / build-container (push) Successful in 2m30s
Build App / build-macos (push) Successful in 2m37s
Build App / build-windows (push) Successful in 5m28s
Build App / build-linux (push) Successful in 7m49s
Build App / create-tag (push) Successful in 23s
Build App / sync-to-github (push) Successful in 11s
Five audit rounds of terminal, auth, settings and Files-tab work, plus the Files tab regaining its host transfers in a shape that opens the OS dialogs from Rust. 480 Rust tests, 602 frontend, no new clippy warnings. |
||
|
|
eead748222 |
Close what two reviews found in the Files tab transfers
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) Successful in 37s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / build-linux (pull_request) Successful in 5m11s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Two independent reviews of
|
||
|
|
2c9482a67d |
Give the Files tab back its uploads and downloads
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 1m35s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 5m51s
Build App (Preview) / build-linux (pull_request) Successful in 6m50s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
`upload_file_to_container` and `download_container_file` existed on main before
any of this work started. "Ship the Files tab container-side only" removed them
and called it narrowing scope; from a user's side it was a regression they
upgraded into. This restores the feature.
The reason for the removal was real — four consecutive audits found their
criticals in host paths crossing IPC — so the feature comes back only in the
shape that removes the class rather than patching it a fifth time. The dialogs
are opened by **Rust** (`pick_save_path`, `pick_files_to_upload`), not by the
webview. A frontend `open()`/`save()` handing the backend a path string is
exactly what failed, and the backend cannot tell such a string from one a
compromised webview invented. Now the webview can ask for a picker and that is
the whole of its influence: it cannot name a host path as an input. That is the
shape the previous round's own notes named as the honest one if this ever
returned.
None of the machinery the audits condemned returns. No `link(2)` destination
reservation, no placeholder rollback, no collision marker: the OS save dialog
already asks about overwriting and Docker's extractor overwrites on upload the
way `cp` does, so there was nothing left for it to do. Download reuses the
sequence `download_container_backup` has been using unchanged — resolve, stream
into a partial file beside the destination, rename last — so a failed transfer
never touches the file that was already there. Upload reuses the terminal
drop's hardened uploader, with the container's uid/gid resolved once per
selection rather than once per file.
Against a container that is actively hostile rather than merely surprising:
* the read is `dd iflag=nonblock`, not `cat`. `[ -f ]` and the `open` after it
are two syscalls and the container owns the filesystem in between; a loop
swapping the file for a FIFO wins that race, and `cat` then blocks forever
with no writer and no timeout anywhere on the path — the `invoke` never
settles and a partial is left in the user's directory for good. Verified in
a real container that `cat` hangs, that `iflag=nonblock` returns, and that
it is byte-identical on a regular file.
* the read is bracketed by a second `[ -f ]`, because non-blocking turns that
hang into an empty file that would otherwise be renamed over the
destination and reported as a successful save.
* an *undeterminable* exit code is a failure. Backup catches this class with
its `total == 0` check, which download cannot have because an empty file is
a legitimate save; without a replacement, a project restarted mid-download
renames a truncated partial over the user's file and reports the byte count
as if it were whole.
* container stderr is capped. Every other reader of container output in the
tree is capped for this reason; the two streaming commands were the
exception, and stdout was bounded by disk while stderr was bounded by
nothing.
* the script's refusals are framed rather than used verbatim, so a directory
named to look like one of our own sentences cannot become the toast
headline through `readableRefusal`.
* the partial name is capped at NAME_MAX. A bundler's 230-character content
hash is a name that fits its directory and produces a partial name that
does not.
Also: a non-UTF-8 dialog path is refused by name rather than silently mangled
into a different path by U+FFFD substitution; both actions carry in-flight
state, so a second click cannot open a second dialog and a slow save is not
indistinguishable from a dead button; and the upload's completion message names
the directory, since the picker is modal and the user can browse elsewhere
while it is open.
Not restored: drag-and-drop, in either direction. `drag:allow-start-drag` stays
ungranted and `hold/disk-and-dragout` still holds that work.
Two bugs the new tests caught while being written: a double-click on "Save to
host…" opened the file viewer on top of the save dialog, and an N-file upload
made N redundant execs to re-ask `id -u`.
Docs that asserted this feature did not and must not exist are corrected —
CLAUDE.md, README, HOW-TO-USE, TECHNICAL and the capability threat model. The
"no host path crosses IPC" claim is deliberately narrowed to the inbound
direction: paths do still travel outward inside error text, canonical ones
included, and the reviewed record should not overstate.
600 frontend tests, 473 Rust, no new clippy warnings. Every new test was
mutation-checked; four that survived their first mutation were rewritten,
including two whose mutations turned out to be unfaithful and one that was
blind to a dismissal leaving a row stuck on "Saving…".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
|
||
|
|
88ffb4744a |
Cancel the keydown on Shift+Enter, or xterm submits anyway
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 36s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m56s
Build App (Preview) / build-linux (pull_request) Successful in 5m8s
Build App (Preview) / build-windows (pull_request) Successful in 5m38s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
The handler returned `false` from xterm's custom key handler and a
comment claimed that was enough to stop the bare CR. It is not.
`_keyDown` returns the instant the handler says `false` — before it sets
`_keyDownHandled` and before it cancels the event — and `_keyPress` then
checks that same flag, finds it false, and emits a bare CR for Enter's
charCode 13.
So the headline feature of this branch did the wrong thing in a real
browser: Shift+Enter inserted the newline and then submitted the
half-written prompt, now with a stray blank line in it. Arguably worse
than before the fix. Reproduced in Chromium and confirmed against the
bundled xterm 5.5.0 source.
`preventDefault()` is what stops the browser firing keypress at all.
Applied at both call sites — the desktop terminal and the web terminal's
copy.
The test could not have caught this. jsdom never synthesizes the
follow-up keypress, so `expect(sent()).not.toContain("\r")` was asserting
a property the environment cannot falsify — a test named for a behaviour
it could not exercise. It now asserts `defaultPrevented`, which is the
mechanism that actually suppresses the keypress and which jsdom can
observe. Mutation-checked: removing the `preventDefault()` fails it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
016de8f641 |
Close the blockers from the fifth audit
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 10m5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 4m31s
Build App (Preview) / build-linux (pull_request) Successful in 5m21s
Build App (Preview) / build-windows (pull_request) Successful in 19m1s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Docs and disclosure. HOW-TO-USE.md's settings table still described the pre-fix behaviour — and help_commands.rs fetches that file from GitHub main at runtime, ahead of the embedded copy, so it would have reached every user's Help dialog the moment this merged. The Config tab named three settings that need a base-image update; there are four, and the omitted one (Session recap) is the one that fails *without* the "won't switch off" symptom the warning teaches. Both now also state the cost nobody had written down: changing any of these recreates the container, which commits a layer. Two stale comments that told a reviewer the code was safe when it was not. compute_claude_code_settings_fingerprint still claimed the historical fingerprint is preserved so an upgrade cannot churn every container — carried over from before the widening, false since the format string changed. And capabilities/default.json, which is the reviewed threat model of record, described a "Save to host…" action this branch deletes. Security and correctness. update_settings validated env vars and nothing else, so the *global* default_ssh_key_path — the fallback for every project without an override — took `/` and read-only bind-mounted the host, which entrypoint.sh then copies into the home volume. classify_ mount_source ran canonicalize on the raw string, which resolves a relative path against Triple-C's own cwd, so `.` and `..` were accepted or refused depending on where the app was launched; the daemon then refuses the mount and the project can never start. Its test passed only because its examples did not exist under app/src-tauri. bind_mount_exclusions still derived a path from every row while project_path_mounts had learned to skip unmountable ones, so a legacy row made /workspace/<name> ordinary container content that a migration would then exclude from staging and destroy. The skip is also logged now rather than silently dropping a folder. The terminal's file-in path checked is_dir() but not file type, so a dropped FIFO blocked forever with no timeout — and it is the only route in now. The web terminal labelled sessions from a global set at request time, so two quick opens swapped them; harmless until Shift+Enter became type-dependent, at which point a mislabelled Claude session submitted a half-written prompt. Opened now carries the type. Every ~/.claude.json write goes through one atomic helper. The awsAuthRefresh branches still truncated in place — the same corruption the Shift+Enter block was fixed for twenty lines later, and its own comment said so. Demonstrated: a failed write now leaves the original byte-identical. And the registration test I added yesterday could pass while the property was false: an audit got five real unregistered commands past its exact-string attribute match, and "exactly once" was in its name but not its body. Mutation-checked against all six shapes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
4d1a5a2417 |
Remove list_sibling_containers, which nothing called
It listed every container on the daemon — including the user's unrelated postgres, mysql and other work — and handed the summaries to the webview. It had a registration, a command, a docker-layer helper, a typed frontend wrapper and a `SiblingContainer` type, and zero call sites. An audit named it as step one of an escalation chain: enumerate the daemon's containers, then point `update_project`'s unvalidated `container_id` at one and read its files through the file-command surface. The second half of that chain is closed now, but a command that exposes the user's unrelated containers and serves no feature is surface with no upside. Found by the registration test added in the previous commit, which is the answer to "why test something the compiler already checks": the compiler is perfectly happy with a command nobody calls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
a323047964 | Merge branch 'r4/scrub' into ship/core | ||
|
|
11216c45e3 |
Assert every command is registered, and every registration exists
This is the shape of the bug behind the original OAuth-callback report: `set_auth_bridge_enabled` existed, worked, and had a typed frontend wrapper — with zero call sites. The switch the docs told users to flip was wired to nothing, so every login callback was refused. Both halves compiled, so nothing noticed. The reverse direction is the sharper one: a command that is registered but reachable from nowhere is still IPC surface a compromised webview can call. `list_sibling_containers`, which returns every container on the daemon including the user's unrelated work, sits in exactly that state. Mutation-checked both ways: removing a registration fails the test, restoring it passes. The first parser I wrote split the list on commas, which glued each `// Docker` style comment to the command after it and then dropped that command as a comment — silently, once per group, 17 in total. Line-based now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
913aa85805 |
Stop the pre-commit scrub running with its guards silently absent
Two fail-open gaps, both latent on the shipped image and both live on an ImageSource::Custom one. `--one-file-system` is the only bound on the scrub's `rm -rf`, and it was probed into `$rmopt` with `rm --one-file-system --help`. BusyBox answers that with `unrecognized option` and exits 1, so on any busybox- or toybox-derived image the variable was empty and the delete ran as root across mount boundaries — reported as a completed `Reclaimed(n)`. Measured in `busybox:latest` with a named volume one level below the match at /tmp/claude-x/inner: emptied, `###TRIPLE-C-SCRUBBED 102423`. It is a prerequisite now, probed by `rm --one-file-system -f -- ''` — the code path that matters, with the one operand no filesystem can name — and an image without it prints `###TRIPLE-C-SCRUB-UNAVAILABLE` and deletes nothing. That costs Alpine and busybox images their scrub, which is the cheaper half of the trade: declining costs disk, proceeding costs the mount. The second is that resetting `PATH` was never the whole of command lookup. `bash` builds functions out of `BASH_FUNC_<name>%%` environment variables, function lookup precedes `PATH` entirely, and `command -v` reports a function as found — so a planted `stat` passed the prerequisite probe and then answered the containment checks. Every in-shell answer is itself importable: `unset`, `command`, and — the point that settles it — `[`, `pwd` and `cd`, which are checks 0 to 2 rather than merely the tools. So the script is no longer run by the shell that read the environment. The exec's argv is a bootstrap using only reserved words, parameter expansions and command words containing a `/` (which `bash` refuses to import a function for), and it hands the script as `$1` to a second `/bin/sh` started by `env -i`. Measured on `/bin/sh -> bash` with `BASH_FUNC_stat%%` set on the container and a volume mounted at the match: the old invocation emptied it and printed 306565, the bootstrap left it intact and printed 65536. `/usr/bin/env` then `/bin/env`, because H3's lesson about hardcoded coreutils locations applies to `env` too. The comment claiming the `PATH` reset "defeats it just as completely as spelling /usr/bin/stat out" is corrected, as is the one claiming a sudo-written /usr/bin/stat does not survive a container restart — it lands in the writable layer, which is exactly what the commit this runs in front of captures. Also here: a stored project path row with an empty host_path or mount_name no longer becomes a mount. The first sends `field Source must not be empty` back for the whole create, so the project cannot start at all until the row is gone, and no amount of save-time validation reaches a record already on disk; the second mounts over /workspace itself and the daemon then creates the other rows' mount points inside the user's real folder. And in migration_commands, the deferred-reconcile claim is RAII rather than a trailing statement — a panic inside `reconcile_migration_now` stranded it for the rest of the process — and `await_release` looks before it sleeps, so a project released a moment later no longer costs a full twenty seconds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
e9902f0564 | Merge branch 'r4/narrow' into ship/core | ||
|
|
7488fc5b70 | Merge branch 'r4/host' into ship/core | ||
|
|
06ccb4d818 |
Ship the Files tab container-side only
Four successive audits found the same thing: host filesystem paths crossing
IPC is where the criticals in this work live. The most recent one found the
`link(2)` upload reservation returning success against a *directory* (linking
into it, leaving permanent stray files, and via a symlink-to-directory writing
outside the validated write root), failing every upload permanently on any
filesystem without hard links, and the post-resolution credential check
weakened from a general rule to an eleven-name denylist.
Rather than fix that a fifth time, the Files tab ships as what it is good at:
a browser, viewer and renamer that never touches the host.
Removed: `upload_file_to_container`, `download_container_file`, and everything
that existed only for them — the whole reservation (`UPLOAD_RESERVATION_SCRIPT`,
`reserve_upload_destination`, the placeholder rollback, `exec_oneshot_as_within`
which had no other caller), `stream_container_file_to_host`, `ChannelReader`,
`save_to_host`, the download ceiling, and the collision marker with its
frontend contract. On the frontend: the upload button, the pane's
`onDragDropEvent` handler, both "Save to host…" affordances, `uploadPaths` /
`downloadFile` / the overwrite prompt, and `OverwriteConfirmModal`.
`lib/uploadErrors.ts` is now `lib/refusalText.ts` and keeps only the half that
turns any backend refusal into the sentence a person reads.
Kept, and not weakened: `upload_host_file_to_terminal` and
`download_container_backup`. They predate this work, their hardening is a real
improvement over main, and they are now the whole answer to "how do I get a
file in or out" — drop it on the Terminal, or Back up container. The drop gate
(`lib/dropTarget.ts`, `PaneVisibility`) is untouched.
`resolve_host_path` gets the general hidden-component rule back. Round 3
replaced it with `HOST_CREDENTIAL_DIRS`, which is allow-by-omission for the
rest of `$HOME`: `~/.local/bin` (write there and you own the user's next shell
command), `~/.password-store`, browser profiles and `~/.pki/nssdb` were all
reachable through a planted symlink with a visible name — verified against a
real home directory, and all five refused now. It over-catches `.pnpm` and
`~/.cache`; for two occasional callers that is the cheaper mistake, and the
refusal says which folder it resolved through.
Two defects fixed while in here:
* A symlinked directory listed as empty. `find` defaults to `-P`, which does
not follow a symlink even as the starting point, so `-mindepth 1` discarded
the only match and a real directory rendered as "Empty directory" — a
first-order defect now that browsing *is* the feature. `-H` follows the
starting point and nothing else, so a loop is `ELOOP` rather than a walk
that does not end; verified against a live container for a symlinked
directory, a broken link and a loop. `find`'s errno for the loop case is
now a sentence.
* `finish_download`'s replace path fired on *any* rename failure with a
destination present — a vanished partial, a permission error, a directory
at the destination — and deleted the user's file to complete a move that
could not complete. It is now fenced to Windows (where a rename onto an
existing path genuinely fails) and to a partial that still exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
9472cb3c4c |
Resolve host paths before mounting them, and stop reading stored data as choice
Four fixes that share a shape: a value already on disk, or one spelled
around a check, being taken at face value.
`/..` bind-mounted the entire host filesystem read-write. `is_filesystem_root`
was purely lexical — trim trailing separators, refuse what was left only if it
was empty or a bare `C:` — and nothing in the file ever called `canonicalize`,
so `/..`, `/./`, `/home/..`, `/etc/../` and `C:\..` all passed. The daemon
resolves them: `docker run -v /..:/mnt/probe` mounts the host root, and the app
mounts read-*write* into a container whose agent has passwordless sudo. It is
the escalation `check_mount_name_stays_under_workspace` exists to close,
reached through the host-path half of the mount instead of the mount-name half.
`classify_mount_source` replaces it and asks the OS: `canonicalize` applies
`..`, follows symlinks, and resolves 8.3 aliases and UNC spellings on Windows.
A path that cannot be resolved — `projects.json` synced from another machine,
a folder not created yet — falls back to a lexical collapse rather than being
refused, because refusing would make such a project unsavable; the gap is
bounded, since what resolution adds is a property of paths that exist. A path
that names no location at all (`C:x`, a relative path) is refused rather than
guessed at. Same check now guards `ssh_key_path` and `ca_cert_path`, whose
read-only mounts were whole-host disclosure at /tmp/.host-ssh.
Custom env var names had no charset check anywhere, so `BASH_FUNC_stat%%` —
bash's wire format for an exported shell function, body in the value — reached
the container environment verbatim. Latent today because the image's /bin/sh is
dash, but the pre-commit scrub runs `/bin/sh -c` as root and nothing pins that.
Keys are now shell identifiers, on the project and the global list both, with
the same grandfathering the folder rows get: a stored key is admitted, a new or
edited one is not.
The blank workspace row was persisted. The comment said it was dropped on save;
the code computed the filtered list and then saved the unfiltered one, so
"+ Add folder" plus a blur stored `{"Target": "/workspace/", "Source": ""}` and
the project could never be started or recreated again. Every save in the
section now goes through one filter, and a blur that changed nothing saves
nothing.
Widening the five `ClaudeCodeSettings` booleans to `Option<bool>` reinterpreted
every stored record. They were plain `bool`s that always serialised, so every
project ever saved carries an explicit `"env_scrub": false` that nobody chose —
and under the new merge that `Some(false)` beats a global `Some(true)`, where
the old rule let the global win. Upgrading silently turned five settings off,
"strip credentials from subprocess environments" among them. Deserialisation
now goes through a shim that dates the record by the presence of the
pre-widening `enable_session_recap` key and reads its `false`s as unset. The
fields skip serialising when unset, so an older binary can still parse
`projects.json` after a downgrade — a `null` would fail to parse and take the
whole list down, since `ProjectsStore` parses all-or-nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
dd23a52b41 |
Fix four upgrade-path defects the coherence audit found
The ~/.claude.json write was `printf ... > "$CLAUDE_JSON"`, which truncates before it writes. A write that fails part-way — a full home volume, which is the exact condition half this release exists to prevent — leaves the file unparseable, and it never self-heals: the next start's jq fails on the corrupt file, MERGED is empty, and the guard skips the write that would have repaired it. That file holds the OAuth account, so the failure mode is a permanently lost login, in service of a cosmetic flag that suppresses a tip. Demonstrated: old pattern loses the credential, new tmp+rename leaves the original intact. The correct pattern was already in triple-c-task-runner. The web terminal scoped its xterm key handler to Claude sessions but not its mobile input bar or its dedicated newline button, so both sent ESC+CR into `bash -l`, where readline has no binding for it. Silent no-op, and worse from a button that stays on screen looking live. Both now consult the active session's type, and the button is disabled with a reason on a shell tab. The Config tab claimed "Off overrides a global On" without qualification. True for the env-var-driven settings, false for TUI mode, Effort level and Focus mode, whose off state is *removing* a key — an older base image's entrypoint ignores the instruction to remove it. The copy now says so and points at the base-image update. HOW-TO-USE.md said there is no add-task form; AutomationTab renders a "New task" button. That file is fetched from GitHub at runtime by help_commands.rs, so the error was live in every user's Help dialog. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
168b61d632 |
Close two cross-file handoffs from the round-3 fixes
The terminal's drop target derived the tar entry name from the host path *after* symlink resolution, so dropping ~/Downloads/latest.log — where latest.log is a symlink — landed the file in the container under the target's name. Nothing errored; the user got a name they never typed. The Files pane had the identical bug and was fixed with `host_upload_name`; the terminal now calls the same helper, so the two drop targets cannot drift. It also drops the "dropped-file" fallback, which silently renamed anything the old `file_name()` could not parse. `migration_store::load` told the user a backup existed when it had deliberately not written one. `load` runs on every reconcile, survey and reaper pass, so a persistently corrupt record reaches MAX_CORRUPT_BACKUPS within seconds; from then on the copy was skipped while the log still read "(a copy was kept at <path>)". Three outcomes are now distinct, and the "enough already" case says so rather than naming a file that is not there — that being the message someone reads immediately before going to look for their data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
47960e46df | Merge branch 'r3/drop' into ship/core | ||
|
|
73dfaf5785 | Merge branch 'r3/auth' into ship/core | ||
|
|
01fd38bc4b | Merge branch 'r3/files' into ship/core | ||
|
|
00128f9b1a |
Make the scrub work off /usr/bin, and stop a log level deciding whether it runs
H3 — the pre-commit scrub was a silent no-op on any base image whose coreutils
are not under /usr/bin. Hardening against a PATH-planted `stat` shim by naming
every tool absolutely bought nothing the `PATH` reset on the first line had not
already bought — the shim lives in the persisted home volume, and uid 1000
cannot write /usr/bin or /bin — and it cost the whole feature on Alpine, which
Settings -> Docker -> Custom accepts. Measured on one seeded tree in a real
container: the absolute-path script printed `###TRIPLE-C-SCRUBBED 0` and left
every planted file in place; the PATH-resolved one reclaims 77824 bytes. The
default image is unchanged at 521038.
It was silent three times over, and all three are fixed:
* The script now probes for all six things it needs (`command -v` for the five
tools, plus the root device id reading back as a number) and, if any is
missing, prints `###TRIPLE-C-SCRUB-UNAVAILABLE <what>` and no total at all.
* `scrub_writable_layer` reads that marker first and returns a new
`ScrubOutcome::Unavailable`, warning with what the image is missing; a
genuine `Reclaimed(0)` now leaves a debug line rather than nothing.
* `commit_log_suffix` renders `Reclaimed(0)` as "ran and found nothing to drop"
rather than "0.00 MB dropped", which is what a scrub that could not run used
to look like.
Verified in real containers: the mount-at-the-match defence still holds with a
home-volume `stat` shim first on PATH (the volume survives; deleting the PATH
reset from the same script empties it, so the harness can tell the difference).
H2 — the pre-migration scrub had been folded into `log::info!`'s argument list
to satisfy `#[must_use]`. `log::info!` expands to `if Info <= max_level() { … }`,
so the awaited scrub lived inside the level check, and `logging::init`
tolerates `dispatch.apply()` failing — which returns before `set_max_level` and
leaves the process at `Off`. In that state the scrub never ran and the layer
was committed into the longest-lived snapshot the app takes. The outcome is
bound first now, `logging::init` restores the level on failure and says so on
stderr, and a test scans all four files for an `.await` inside any `log::*!`
argument list.
Also:
* `reconcile_migration` deferred a held project instead of dropping it. Its
only caller fires once per "Docker became available", so a project held at
that instant was never revisited for the session — phase un-normalised, no
resume or rollback offered, pin left `Claimed`. It now waits for the holder
to let go (20s x 90, one waiter per project) and reconciles then.
* The scrub's byte total counts what a partly failed `rm` removed, by
re-measuring rather than dropping the whole subtree on a non-zero exit —
which was exactly the `--one-file-system` case.
* The scrub exec blanks `LD_PRELOAD`, `LD_AUDIT` and `LD_LIBRARY_PATH`.
`LD_PRELOAD` is in none of the reserved env families, so a project's custom
env var reached a root exec and injected code into every tool the scrub runs,
`PATH` reset or not. Verified against Engine 29.7 that `docker exec -e` wins.
* The device test is described honestly: it is a mount test under `overlay2`
and not under `vfs`, where checks 1 and 2 are what still hold.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
39934299f9 |
Stop the snapshot retry being an unconfirmed revoke, and unbrick legacy configs
Three things, all reachable from a single credential-handling round.
**The "Retry snapshot cleanup" button deleted the token.** `clear_claude_token`
called `secure::delete_claude_oauth_token()` unconditionally; the `mode`
argument only changed the toast wording, so there was no sweep-only path on the
wire. The leftover panel rendered on `leftover !== null` alone and
`onAuthenticated` never cleared it, so the sequence revoke -> snapshot skipped
-> re-authenticate from the button directly above -> press the retry the panel
is still offering threw away the token acquired seconds earlier, announced by a
message about images. The deliberate Revoke needs a confirmation modal; this
needed nothing.
`sweep_claude_token_snapshots` is the honest primitive: it rewrites the images
and never touches the keychain. The images are the durable record, so the retry
re-derives its work from Docker and needs no stored token. Re-authenticating
now clears the panel, and the Authenticate button is disabled while a cleanup
runs — a sweep is a per-image inspect/create/commit/rmi over the Docker socket
and takes minutes.
**Sweep-first left the token live for that whole window.** The keychain delete
sat behind `list_images` plus the per-image loop, at bollard's 120s-per-request
default, while the UI said "Revoking...". A quit or crash in there and nothing
was revoked at all; worse, `has_claude_token` stayed true and
`shared_claude_auth` reads the keychain at container-*create* time, so a
project whose `SecretScrub` guard had already released could be started later
in the same sweep and be handed a fresh copy of the credential in its env.
Keychain-first now, and the comment that claimed "no window in which a scrubbed
image is re-poisoned" — true of images, silent about containers — is corrected.
The reorder's original justification (crash-mid-sweep recoverability) is what
the sweep-only command covers.
A keychain refusal no longer discards a scrub report, because nothing has been
swept yet, and both remedies stay on screen: Revoke, and the image sweep, which
is now offered in every state rather than only when nothing is stored.
**`update_project` validating every folder list bricked existing projects.** It
validated nothing until recently while `WorkspaceSection` saved `{paths}` on
every blur, so `projects.json` can hold a half-filled row, a mount name with a
space, a duplicate, or `/` as a host path. Any such project became entirely
unsavable — every Config toggle, every permission-mode change and
`useTerminal.ts`'s tab rename came back with a message about folders — and
refusing the save did not unmount anything. `validate_project_paths_update`
admits a row carried over verbatim from what is stored and holds a new or
edited row to every rule, which keeps the escalation closed: introducing a bad
value through this command is exactly what a non-carried-over row is. The
`/workspace/../tmp/claude-x` chain is the one exception and runs on every row
regardless, because a stored one is live data loss rather than untidy data.
`ssh_key_path` and `ca_cert_path` had no check at all; a filesystem root there
read-only bind-mounts the whole host at /tmp/.host-ssh. Refused on change, with
the same grandfathering.
Tests are mutation-checked: reverting to sweep-first fails all four new Rust
ordering tests, pointing the retry back at `clear_claude_token` fails five
frontend tests, and validating an update in isolation fails the legacy-data
tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
c6086b0ab3 |
Stop the file panel refusing ordinary paths, and hanging on a FIFO
H6 (HIGH) — `resolve_host_path` canonicalised the host path and then re-ran the *lexical* policy over the answer, hidden-component rule included. Because canonicalisation resolves through symlinks, that rule started judging where a path happens to live rather than where the user pointed: uploading out of a dependency under pnpm (`node_modules/pkg` → `node_modules/.pnpm/…`) was refused, and so was every download into, or upload out of, a visible directory that leads to `~/.local/share`, `~/.cache`, `~/.var/app`, `~/.nvm` or `~/.cargo`. None of it was refused before the H4 fix landed. The two questions are now separate functions. `validate_host_path` judges the string the user chose, unchanged. `validate_resolved_host_path` judges the canonical form for the things only it can answer — the system roots (a Mac's `/etc` *is* `/private/etc`), the login-item directories, and a new `HOST_CREDENTIAL_DIRS` list. That last one is what keeps H4's escape closed: `Downloads/pub` → `~/.ssh` with a leaf of `authorized_keys` is refused because of where it lands, not because of how the directory is spelled. macOS handling is untouched — `/private/tmp` stays out of `HOST_SYSTEM_ROOTS` and `/var/folders` stays in the exceptions. H8 (HIGH) — the upload reservation claimed its destination with `sh -c 'set -C; : > "$0"'`, and the comment claiming that is `O_EXCL` was wrong for a destination that is not a regular file. Against a FIFO the shell opens it and blocks in `open(2)` forever; `exec_oneshot_raw` has no timeout, so `upload_file_to_container` never returned and the Files pane sat on "Uploading…" for the session with the rest of the batch abandoned. Verified in a fresh ubuntu:24.04: the old form times out and the blocked `sh` stays in `ps`; the new form answers in 35 ms. The reservation is now a `link(2)` — it claims a name atomically, never opens anything, and `EEXIST` is immediate whatever is in the way. A staging file at an unguessable name in the same directory is linked into place and unlinked, under a `trap … EXIT`. `exec_oneshot_as_within` adds a wall-clock ceiling as the second line of defence, opt-in per call site so migration's `apt-get` is unaffected. The upload contract is unchanged: default-refuse, `overwrite: Option<bool>`, and `FILE_EXISTS: <full container path> already exists`. Also fixed, all in the same surface: * A dangling symlink destination was a permanent dead end — `set -C` refused, the confirming `test -e` followed the link and said no, and raw shell text came back with no Replace on offer. `link(2)` does not follow the new-path link, and the script confirms with `[ -L ]`, so it reports as a collision. * An upload through a symlink renamed the file: the leaf came off the *resolved* path, so `~/Downloads/latest.log` landed as `2026-08-23.log` and the collision prompt named a file the user never chose. The name now comes from the path the user gave; the resolved path is still what gets opened. * `download_container_backup` leaked its partial file when the descriptor check fired. It now tracks `created` the way `stream_container_file_to_host` already did. * The failed-upload cleanup was `rm -f` on a path that, the reservation having succeeded, held whatever was written in the interim — a host file under `/workspace/…`. It now removes only an empty regular file, and the comment says what it is doing. * `resolve_container_dir` parsed a combined stdout+stderr buffer as a path. It uses the split-stream helper, like the listing next to it. * `verify_opened_path` failed open on a readlink error (`if let Ok(actual)`). A check that cannot see is not a check that saw nothing wrong; the macOS compile-time no-op is now spelled out too. * A trailing slash on a write path resolved to the directory itself. Nine new tests, all mutation-checked against the pre-fix behaviour. Two more cases added to the ignored live-Docker test: a FIFO and a dangling symlink, both timed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
f7db4323be |
Make the drop gate a state question, not a geometry one
The gate that decides whether a native file drop is accepted has been wrong twice in opposite directions, both times because it tried to be precise about *which points* a dialog covers: - Round 1 asked `el.contains(elementFromPoint(x, y))` and was handed the inner xterm host while the overlays are siblings, so the always-rendered Following/Paused button made the terminal's top-right corner permanently refuse drops. - Round 2 replaced that with "is a blocking overlay painted here?" and deleted the document-wide gate. `elementFromPoint` returns the *topmost* element, and ToastHost is z-[60] against the Modal backdrop's z-50 in the same stacking context — so a refused drop pushed a toast, the toast covered the dialog, and the next drop released on it was reported clear and landed in the directory the dialog was covering. The gate armed its own hole. Split the two questions instead of merging them: - Geometry answers *whose* drop it is (rect hit test, unchanged), so exactly one listener speaks for a drop and a hidden pane's zero-size rect still keeps TerminalView and FilesTab from both firing. - `dropIsBlocked` answers whether the app should take a drop at all — document-wide, no z-index in it. While a modal or blocking overlay is on screen anywhere, every drop is refused. There is no `elementFromPoint` call left, so no future overlay can become a drop hole by being painted high enough and no chrome can become a dead zone by being painted at all. The cost is over-refusal while a dialog is open, in a state the user entered deliberately, announced, writing nothing. Also: - `[aria-hidden="true"]` no longer disqualifies a blocker. It is not a visibility statement (it sits on visible decorative content), so a blocker nested in such a wrapper would have silently stopped blocking. - Modal drops `data-blocks-drop` when its pane hides, and moves focus out of itself rather than leaving it inside a `display:none` panel. - The refusal notice stays `kind: "info"` (an expected refusal is not an error, and an error card never auto-dismisses) and carries a `dedupeKey`, so repeated refusals replace rather than stack. Tests: mutation-checked against the previous implementation — four in dropTarget.test.ts, two in each of TerminalView/FilesTab, two in Modal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
ed91423666 |
Hold back the Disk panel and OS drag-out from the ship branch
This is a scope reduction, not an abandonment. Both subsystems are
preserved in full on `hold/disk-and-dragout` and are intended to come
back once they have been hardened separately. Nothing here is a
judgement that the features are unwanted — three successive
audit-and-fix cycles each closed a critical defect in these two areas
and each opened a new one, so the rest of the round ships now and these
two get their own cycle rather than holding it up.
Removed: the Disk settings panel and its whole reclaim / destroy /
compaction surface — `DiskSettings`, `DiskProjectTable`, `useDiskUsage`,
`docker/disk.rs`, `disk_tests.rs`, the disk commands in
`docker_commands.rs`, and their `generate_handler!` entries. Dropping
the IPC entries is the point: a UI-only removal would have left five
commands callable by a compromised webview, one of them a verified
arbitrary-DELETE primitive. `sweep_orphaned_snapshots`'s *command* goes
with them (the panel was its only caller); the sweep itself stays.
Removed: OS drag-out from the Files tab — `stage_container_file_for_drag`
and its host staging lifecycle, the pointer gesture and `dragPreview`,
`stageForDrag` / `isStagedHostPath`, the `tauri-plugin-drag` and
`@crabnebula/tauri-plugin-drag` dependencies, and the
`drag:allow-start-drag` capability grant, which could not be scoped.
The capability test's expected list is updated; its `*:default` and
`store:*` assertions are untouched.
Kept, deliberately: drag-and-drop *into* the app (Files pane and
terminal) and "Save to host…", which is now the only route out of a
container. The prevention work is untouched — the pre-commit scrub and
`SNAPSHOT_SCRUB_PATHS`, capped container logs, the `triple-c.base` /
`triple-c.managed` labels, `sweep_orphaned_snapshots` and the startup
housekeeping, the migration pin/probe reapers, scheduler log pruning,
`formatBytes.ts`, and `project_lock.rs` in full with every acquisition
site outside `disk.rs`.
Entanglements, resolved rather than deleted blind:
* `container.rs`'s `a_compaction_runs_this_module_s_scrub_script_byte_for_byte`
pinned the compaction Dockerfile against `snapshot_scrub_script()`.
Dropped — it existed only for compaction. `snapshot_scrub_script` and
its containment tests are untouched.
* `lib.rs`'s startup reap of `:compacting` tags and `triple-c-compact-*`
containers is dropped: nothing on this branch creates them.
* `project_lock`'s `Compaction` / `CacheClear` variants and
`any_held_excluding`, `migration_commands::is_migrating`, and
`formatBytes{Delta,Ceiling}` lose their last production caller but are
kept and still tested, annotated with why.
* `projects_store::corrupt_since` and `migration_store::peek_ownerless_since`
were read only by the disk survey and are removed. The corrupt-load
marker and `.bak` are still written.
Verified: `npm run test` 611 passing, `npx tsc --noEmit` clean,
`npm run build` green; `cargo test` 419 passed / 2 ignored,
`cargo build` 0 warnings. Every test removed belongs to a removed
feature — no kept-behaviour test was weakened or deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
6a8972980d |
Stop an untouched secret field from deleting the stored credential
Making `null` actually clear a secret — which it had to, since a blanked
token was previously never revoked — turned the config editors into a
credential shredder. Secrets are `#[serde(skip_serializing)]`, so the
inputs always render empty whether or not one is stored, and the blur
handlers sent `value || null` unconditionally. Focusing the Git token
field and tabbing away deleted it, with nothing shown and no undo.
`useSecretField` encodes the rule: only a field the user typed in may
speak about a secret. Untouched, `patch()` contributes no key at all, and
Rust already distinguishes an absent key from an explicit null.
`withoutUntouchedSecrets` covers the structural half. `saveBedrock`
spreads `{ ...bedrock, ...patch }`, and when that falls back to
DEFAULT_BEDROCK_CONFIG the literal spells every secret out as `null` — so
editing the AWS region would have wiped the credentials as a side effect.
Also here: `WorkspaceSection` no longer saves a half-filled folder row,
which `update_project`'s new validation would refuse on every keystroke
between the two inputs; `snapshots_skipped` is declared on the wire type
rather than widened locally; and `#[must_use]` on `ProjectGuard` and
`ScrubOutcome` — which immediately caught the migration path discarding
its scrub outcome, the one scrub whose silence is expensive because the
layer it declined to clean is about to be committed.
The capability test reads the real file and is mutation-verified: adding
`core:default` back makes it fail. That grant pulls in an unscoped
`std::fs::read` of any host path and went unnoticed for months, because
nothing in the suite read the file at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
7bbb699e4e | Merge branch 'r2/front' into integration/round-1 | ||
|
|
5df3e7996d | Merge branch 'r2/files' into integration/round-1 | ||
|
|
5d4d5d37df | Merge branch 'r2/disk' into integration/round-1 | ||
|
|
bb1c7696f9 | Merge branch 'r2/scrub' into integration/round-1 | ||
|
|
f2a84c18f9 |
Judge a host path by where it leads, not by how it is spelled
`validate_host_path` was a string test. Nothing in the module called
`canonicalize`, `read_link` or `O_NOFOLLOW`, so a path whose components are
all visible could still land somewhere hidden: with `~/Downloads/pub` a
symlink to `~/.ssh`, a `host_path` of `~/Downloads/pub/authorized_keys` has no
hidden component, no `..` and no system root — and writes into `~/.ssh`. The
container end is not hypothetical: `/proc/self/mountinfo` inside a Triple-C
container spells the host's project paths out verbatim, so code in there knows
both where to plant the link and what host path to ask for. The same bypass
read host files back the other way.
So the policy now runs twice: once on the string, and once on what the OS says
the string resolves to. A write resolves the parent and keeps the caller's
leaf, because the leaf is never followed — the partial file is created with
`O_EXCL` and the download finishes with a rename, which replaces a link rather
than writing through it. A read resolves the whole path, because the whole
path is opened. On Linux the descriptor is then checked against the path that
was validated (`/proc/self/fd`), which is what closes the window between
resolving and opening; elsewhere that window stays open and the comment says so.
Also here:
* The upload's overwrite guard is a guard again. `noOverwriteDirNonDir`
refuses only dir-over-non-dir and the reverse — file-over-file extraction
proceeds, which is exactly the `.credentials.json` case (verified against a
live daemon). The probe and the write are now one `set -C` exclusive
create, with the path travelling as `$0` rather than as script. The
`FILE_EXISTS: <path> already exists` contract with the frontend is
unchanged, and now pinned by a test — as is the claim the old comment made.
* Windows normalisation stopped being a string swap: `\\?\`, `\\?\UNC\` and
administrative shares all reach the same places and are compared as such,
and the rules are pure functions over a string, so the Windows entries are
exercised on any platform. The old test passed on Linux only because
`Path::is_absolute` was false for a Windows path.
* Container write roots are resolved inside the container too, and the
comment no longer claims more than the check does.
* A failed download can no longer delete a pre-existing file that happened to
collide with the partial's name.
* One-shot exec output is buffered as bytes and decoded once, so a filename
split across two Docker frames survives; stdout and stderr are tellable
apart, so `find`'s diagnostics stay out of the listing parser; and a
directory too big to buffer is described as one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
b49dddab45 |
Make a blanked project credential actually clear
Handoff from the credential change on `r2/sec`, which landed `secure::store_or_clear_project_secret` and left a `TODO` naming this call site. `store_secrets_for_project` could only ever write: a token blanked in the UI stayed in the keychain, `load_secrets_for_project` read it straight back onto the project, and the container went on receiving a credential the user had revoked. **Not the mechanical switch the handoff describes, deliberately.** Every secret field is `#[serde(skip_serializing)]`, so the project object the frontend holds carries no `git_token` key at all — a save from the Workspace, Runtime or Model section sends the field *absent*, while the editor that owns it sends `git_token: null` when the user blanks it. `Option<String>` maps both to `None`, so clearing on `None` would delete every project secret each time an unrelated setting was changed. `update_project` therefore takes the raw payload, records which secret fields arrived as an explicit `null`, and clears exactly those; absent still means "not mine to touch". **This commit does not build on `r2/scrub` alone** — it calls `secure::store_or_clear_project_secret`, which exists only on `r2/sec`. Verified green against that file: 464 tests pass with `r2/sec`'s `storage/secure.rs` in place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
dcd2dfe5a3 |
Stop a project id from steering a Docker API DELETE, and stop trusting a store that lost its list
C-2 (critical). `destroy_ownerless_rollback_pin` validated its tag and not its
project id, then interpolated both into `triple-c-snapshot-{id}:{tag}` and handed
the result to bollard. bollard does not percent-encode: `Uri::parse` joins an
absolute path onto the base URL, which replaces the path outright and applies RFC
3986 dot-segment removal. An id of `a/../../v1.47/volumes/<name>?` turns a
"remove image tag" into `DELETE /v1.47/volumes/<name>`. That arm is reached
*because* `find_project` failed, so the id is unconstrained IPC input, and the
typed confirmation is no barrier — it compares the caller's own two strings.
Reproduced against the live daemon, and now a test: with the check removed the
volume is gone and the test fails; with it, the volume survives and a legitimate
ownerless pin still deletes. The reference that reaches `remove_image` is now the
daemon's own repo_tag, matched on the parsed pair, so nothing built from IPC
input addresses the API at all. The same id check now guards the owned arms of
`destroy` and `compact_snapshot`, which build volume names and image references
from a `projects.json` field.
H-1. The ownerless arm decided ownership from the in-memory list alone and then
called `sweep_orphaned_snapshots()`, which deletes the freshly dangling image on
the same pass — so a corrupt `projects.json` could reap a pin whose migration is
still awaiting confirmation, the one thing `pin_is_reapable` orders its
conditions to prevent. It now re-reads the store from disk, runs
`project_store_trust`, refuses an id the store knows, takes the project lock
before reading anything a decision rests on, and checks `has_record`.
H-3. The corrupt-store guard keyed on "empty list + file exists", and
`ProjectsStore::new()` swallows a corrupt file without rewriting it — so the
first `save()`, as little as starting a project, wrote `[{new}]` over it and the
guard passed with every other project's volumes unclaimed. A corrupt load is now
recorded in a sticky `projects.json.corrupt` marker beside the file, and the
existing `.bak` is no longer clobbered by a second corruption. A missing
`projects.json` is refused too: it cannot be told from a moved or partially
restored data directory, and the genuinely fresh case has nothing to find.
Also: the three migration commands surface the lock's real refusal instead of
substituting "a migration is already running"; `note_ownerless_since` re-checks
`has_record` after writing a tombstone, closing the window that could plant one
behind a valid record and reap the pin with zero grace; corrupt migration-record
copies are capped at four; `reconcile_migration` yields to any lock holder, not
only a migration; and a 22-space run in a refusal string is gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
6d27f924ff |
Stop the scrub emptying a mount that is itself a glob match
The parent checks in `snapshot_scrub_script` validate the directory a
pattern is anchored to, and `rm --one-file-system` compares against its
own command-line argument's device — so with the mount planted *at* the
match there was nothing between the scrub and the mounted filesystem.
Verified against a live daemon with the byte-identical generated script:
mounted at the parent (`/var/log/apt`) refused, mounted one level below
(`/tmp/claude-x/inner`) refused, mounted as the match (`/tmp/claude-x`)
came back with the volume empty. The same run against a host directory
bound at `/workspace/../tmp/claude-x` — the target the daemon builds from
a mount name of `../tmp/claude-x`, confirmed through the API bollard uses
— emptied the host directory.
Each match is now checked against the root's device too, which for a
directory whose parent has already been validated is exactly a
"not a mount point" test. A symlinked match still reports the link's own
device, so `rm -rf -- link` goes on unlinking it and stopping.
The tools are also named absolutely and `PATH` is reset. The image's
`PATH` starts with three directories inside the container's persisted
home volume, and a three-line `stat` shim planted in the first of them
made the previously-refused `/var/log/apt` mount delete its contents.
"Missing `stat` fails closed" was true and beside the point.
`update_project` validated nothing while `add_project` validated its
folder list, so the mount name that reaches all of this was one
save-on-blur away from anything at all. Both now share
`validate_project_paths`, which also refuses `..`, a filesystem root as a
host path, and a half-filled row; `container_id`, `status` and
`created_at` are no longer writable through a project save.
`remove_project` was the only writer of a container, a snapshot image or
a volume that took no project claim (H-2), so removing a project during
a compaction let `restore_image_config` commit a flat image back over
`triple-c-snapshot-{id}:latest` for a project that no longer exists —
invisible to every reclaim path. It takes `ProjectOp::Destroy` for the
whole removal; the sidebar row survives a refusal because it is only
dropped after the command resolves.
Finally, a commit whose scrub was skipped no longer logs "0.00 MB
dropped", which every migration did.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
e70a40507c |
Stop granting an unscoped host-file read, and make a refused credential scrub recoverable
`core:default` was an alias for nine core plugins' default sets, and one of them — `core:image:default` — carries `allow-from-path`, whose handler is a bare `std::fs::read(path)` with no scope mechanism at all. Nothing imports `@tauri-apps/api/image`, so the plugin is dropped rather than scoped; there is nothing to scope it with. The capability file now enumerates what `app/src` actually invokes, which is `core:event`'s listen/unlisten and nothing else from core — every emit in this app originates in Rust. `core:menu`, `core:tray`, `core:window`, `core:path`, `core:resources` and the three dead `dialog:` grants go with it. `core:webview:allow-internal-toggle-devtools` stays because Tauri's own injected debug script calls it; both it and the command behind it are `cfg(any(debug_assertions, feature = "devtools"))`, so it is absent from a release bundle. Verified empirically: an unknown identifier fails the build, so every identifier kept is real and the regenerated `gen/schemas/capabilities.json` carries the opener scope verbatim rather than silently dropping it. `opener:allow-open-url` cannot be host-narrowed — the terminal opens links Claude printed inside the container — so what it does and does not buy is recorded instead, including the verified fact that each scope entry's `app` defaults to `Application::Default`, which matches only `with == None` and therefore refuses `openUrl(url, "/bin/sh")`. `clear_claude_token` deleted the keychain entry first and swept the snapshot images second. The sweep runs once and skips a project another operation holds, the deleted entry made `has_claude_token` false, and Revoke rendered only while a token was stored — so a project that happened to be starting during a revoke kept a live ~1-year OAuth token in its snapshot's `Config.Env` permanently, with Reset (which destroys both volumes) as the only remaining remedy. The sweep now runs first, so a crash mid-revoke leaves the app still saying "authenticated" with the same button still able to finish; a busy project is reported as `snapshots_skipped` rather than folded in with images that genuinely cannot be rewritten; and the panel keeps a retry visible independent of token status, plus offers the sweep outright when nothing is stored, because a snapshot committed by an older build carries the token either way. The retry is the same command — it is idempotent, and the images are the durable record. Also: `openai-compatible-api-key` was written but never deleted, so it outlived its project. The key list is now the single definition and an unlisted key is refused outright, so the writer cannot get ahead of the deleter again. `store_or_clear_project_secret` lands here unused on purpose: the editors send a blanked field as `null` and `store_secrets_for_project` skips `None`, so clearing a secret through the UI is impossible today. Its one call site is in `commands/project_commands.rs`, which belongs to another change in this round. No `devCsp` was added. `tauri dev` loads the main document straight from Vite, and Tauri only attaches a CSP to documents it serves itself — the dev server is proxied through `tauri://` only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))`. A `devCsp` here would be inert config that reads as protection. The reasoning, and the one place that could set one, are recorded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
5926a52ff6 |
Stop the terminal and Files panes refusing drops onto their own chrome
The z-order gate added last round asked `el.contains(elementFromPoint(x, y))` — "is the thing painted here mine?" — and was handed `TerminalView`'s inner xterm host while every overlay in that pane is a *sibling* of it. So any point under the pane's own chrome answered "not mine" and the drop was refused, with no message and no log line. The "▼ Following / ▽ Paused" toggle is rendered unconditionally at `absolute top-2 right-4`, and `ToastHost` is `fixed bottom-4 right-4` 24rem wide with error cards that never time out: two corners of the terminal, and one of the Files pane, that could not accept a file for as long as the app was running. It shipped green because jsdom has no `elementFromPoint`, so not one of the 81 drop tests entered that branch. The tests here install one. The question the gate asks is now "is a *blocking overlay* painted here?". Chrome the pane paints over itself is not one; a dialog backdrop is, and `ui/Modal` marks its own backdrop so the element `elementFromPoint` actually returns is the one carrying the marker. `classifyDrop` also separates "aimed at me and swallowed" from "not my drop", so the first gets a toast and a log line and the second stays silent. Three defects around it: - **A dialog now refuses only the points it covers.** `dropIsBlocked` is document-wide and `ui/Modal` portals to `document.body`, so any open dialog refused every drop in the window. The deeper half of that is that a dialog opened in project A really was still on screen after a tab switch — the pane hides itself with a `hidden` class, which a portal does not inherit — so `PaneVisibility` lets `App` tell a `Modal` its pane stepped aside, and a hidden one paints nothing, traps no focus, answers no Escape and blocks no drop while staying mounted with its state intact. - **`devicePixelRatio` is applied on Windows only.** Only wry's WebView2 backend hands over physical pixels; the macOS and GTK ones deliver logical points and `tauri-runtime-wry` does not rescale them. Halving those was survivable while the test was a bare rect and is a refused drop once z-order joins in. Read from the wry/tauri sources, not verified on a HiDPI Mac or GTK box. - **`isFileExistsError` can no longer be forged by a filename.** It matched `fileexists` anywhere in a normalised error, so uploading a host file called `file-exists.txt` turned *any* failure into a collision — and Replace re-invoked the upload with `overwrite: true`. The marker now has to stand alone in the backend's canonical form, or be a whole discriminant value. - **A refused compaction or cache-clear keeps its dialog.** `reclaim` reports refusals inside `Ok`, so "did it throw" read one as success: the dialog closed, the tick list was dropped, and the explanation appeared in the outcome panel several screens above the row that was clicked. The dialog now stays put and renders the backend's own sentence verbatim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
42ef1865cc |
Remove a stray .deb committed by an over-broad git add -A
An audit agent downloaded findutils to check GNU find's operand parsing;
|
||
|
|
4f6c012071 |
Make a rollback pin outliving its project visible and deletable
`survey_rollback_pins` walks images, not projects, and deliberately tolerates an absent project by falling back to the raw id as the display name. Two things then dropped it on the floor: `destroy` called `find_project` before the confirmation check, so it refused such a pin every time, and the per-project table joins destructive items to rows by project_id, where rows come only from projects in the store. The result was a multi-GB `pre-migration-*` image that the scan measured, the panel never rendered, and nothing could remove — in the one screen built to find exactly that. `destroy` takes the same early return `OrphanVolume` already takes, and still validates the tag: `latest` names the project's live snapshot, so the ownerless path must not be a way around that check. The UI grows a bucket for destructive items matching no row, rather than filtering them away. The typed gate already compared against the id via `project_name`; the dialog now says "project id" instead of asking for a project name that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
6b8d43414d | Merge branch 'fix/reconcile' into integration/round-1 | ||
|
|
1768240861 |
Unstick a project row whose lifecycle command was refused
Two more pieces of drift from the same merges, both invisible to `tsc`. **A refused Start/Stop/Reset strands the row.** `start`, `stop` and `rebuild` paint an optimistic "starting"/"stopping" so a click moves the row at once. That was safe while the only way these could fail was after the backend had begun changing things. `fix/sec`'s per-project lock ended that: all three now take the lock and are refused *before* any state changes, and `stop` could not fail this way at all before — it took no exclusion. So the optimistic paint has nothing to become, `isTransitioning` disables both Start and Stop, and the only thing that clears it is `reconcileProjectStatuses`, which runs once from `App.tsx` when Docker first appears. Clicking Stop during a compaction left the project unusable until the app was restarted. `withOptimisticStatus` re-reads the authoritative list when the command throws, and falls back to the status that was on screen if even that call fails — two failures in a row must not land on the one state there is no way out of. The error is rethrown unchanged, so the toast is unaffected. Five of the six new tests fail against the previous code; the sixth pins that the optimistic paint still happens on the way in. **Six secrets are typed as if they arrive, and they never do.** `git_token`, the four Bedrock credentials and `OpenAiCompatibleConfig .api_key` are `#[serde(skip_serializing)]` in Rust, so the key is absent from every project the backend returns — reading one gives `undefined`, not the `null` the type promised. Every current reader happens to use `?? ""`, so nothing is broken today; a single `=== null` would have been a branch that silently never ran. They are optional now, which makes that a compile error, and documented as write-only, which is what they are. 669 frontend tests pass, `tsc --noEmit` clean, `npm run build` green. Nothing under `src-tauri/` touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
7e1f8df1ff |
Reconcile the frontend with the round-1 backend contracts
Five backend branches merged and the TypeScript still compiled, because
none of this is a type error: a field that arrives `undefined`, a variant
nothing emits any more, a prompt whose loop never closes. Six things.
**Orphaned volumes are destructive now, not safe.** `ReclaimTarget::
OrphanVolume` is gone from Rust; the object is a `DestructiveTarget::
OrphanVolume { name, project_id }` confirmed against the *volume's* name,
there being no project to name. The TS union still listed it under
`ReclaimTarget`, and — worse — `DiskProjectTable` keys destructive items
off `project_id`, which an orphan's never matches. So the item existed in
the plan and appeared nowhere on screen. `DiskSettings` now splits the
plan's destructive list and gives orphans their own section with a
per-volume `TypedConfirmModal`. The copy says what a
`triple-c-claude-config-*` volume actually is — a Claude login
credential, every plugin and skill, every transcript that project had —
and keeps the sentence explaining that "no matching project" is a lookup
against the project list and is never inferred from a project being
stopped or having no image, which is the inference that once flagged two
live projects.
`TypedConfirmModal` grew a `subject` prop: asking a user for "the exact
project name" of a volume that has no project is asking for a string that
does not exist.
**Snapshot and Total reconcile.** `ProjectDiskRow.snapshot_attributed_bytes`
is the single figure `snapshot_attribution()` exists to produce. The
column rendered `snapshot_above_base_bytes` and fell back to `—` while
the Total was `size - shared` regardless — and in that branch `size -
shared` is the whole 4.7 GB base image, charged per project and then
added again as a base-image row. One field, one rule. The one branch
where the figure *is* the whole image says so rather than passing itself
off as a share.
**The overwrite loop closes.** Traced end to end: a `FILE_EXISTS:`
refusal raises the prompt, Replace re-invokes with `overwrite: true`,
Skip advances, "…all" answers the rest without asking, and picker and
host-drop both reach `uploadFileToContainer` through `uploadPaths`. Two
gaps: a second batch's `askOverwrite` overwrote the first's resolver,
leaving that batch awaiting an answer no dialog could produce; and the
backend's written refusals — a hidden host folder, a path outside the
write roots — were passed as a toast `detail`, which `ToastHost` renders
as collapsed monospace behind a "Details" button, so the only sentence
that explained anything was the part nobody saw. `readableRefusal`
promotes it to the headline when a batch failed the same way.
**The browser pane's sandbox is pinned.** `allow-same-origin` must stay
(the proxy's gate reads `Origin`/`Referer`, and an opaque origin sends
`null`); every top-navigation grant and `allow-popups-to-escape-sandbox`
must stay absent, and the test names the offending token rather than
printing a set diff.
**`@tauri-apps/plugin-store` is gone** from `package.json` — its
capability grants were removed as a host-file-write primitive and nothing
in `app/src` imports it. The lockfile was updated with
`--package-lock-only`, deliberately: `node_modules` is a symlink shared
with other worktrees and a real install would have pulled it out from
under them.
Nothing under `src-tauri/` is touched. 663 frontend tests pass (was 635),
`tsc --noEmit` clean, `npm run build` green, `cargo test` 446 unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
a76f2c0a17 |
Close the cross-stream gaps the parallel fix round left open
Three items each of which fell between two agents' file lists.
`scrub_secrets_from_snapshots` was the third unsynchronised writer of
`triple-c-snapshot-{id}:latest`, after a recreate's commit and a
compaction. It has the same read-modify-write shape — create a scratch
container from the snapshot, commit back over the same tag — and loses
the same race, which here means re-baking the very credential it exists
to remove. It now takes the project's claim, under a new
`ProjectOp::SecretScrub`, and reports a snapshot it had to skip rather
than rewriting it unsafely.
Its scratch container also now carries `triple-c.scrub=true`, so the
Disk panel's reclaim bucket discriminates by label and by the live claim
rather than by a clock. The 15-minute age gate stays as the backstop for
the cross-process case the claim cannot see.
The store plugin is unregistered and its dependency dropped. Its
capability grants were removed as a host-file-write primitive; the
registration without a grant was unreachable but dead.
Finally, container.rs's fold test was pinning a fold that no longer
exists — disk.rs now emits the JSON exec form. It asserts the stronger
property instead: the script a compaction runs is byte-for-byte the one
snapshot_scrub_script() produces, so the two files cannot drift silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
17f031a5d7 | Merge branch 'fix/disk' into integration/round-1 | ||
|
|
5fba7d6d35 | Merge branch 'fix/front' into integration/round-1 | ||
|
|
433afa5a49 | Merge branch 'fix/sec' into integration/round-1 | ||
|
|
fcea506dce | Merge branch 'fix/files' into integration/round-1 | ||
|
|
6abc7f27a4 |
Fix disk/migration defects and add a real per-project lock
The compaction panel's headline action had never worked, three reclaim paths could delete data with no confirmation and no grace period, and the app's only mutual-exclusion primitive was one-way. **A per-project lock (`project_lock.rs`).** `ACTIVE_MIGRATIONS` was the app's only exclusion and everything but migration merely *polled* it once at entry. Compaction, start/stop/recreate, Reset and destroy now **acquire** a `ProjectGuard` and hold it for the whole operation; `is_migrating` is a view onto the same registry. Closes the three verified interleavings where a compaction commits `flat(A)` over a `:latest` that a migration, a recreate or a Reset had already moved. In-process only — the two-instance case is documented in the module, not solved, and the daemon-wide reapers gained age gates to bound it. **H1: compaction never ran.** `fold_shell_script` joined the scrub script's lines with a space, so every build died on `syntax error: unexpected "do"`. Replaced with the JSON exec form, which carries any script verbatim; `sh -n` and a real end-to-end build now cover it (159.5 MB / 9 layers -> 33.7 MB / 1 layer, setuid and multi-line env preserved). **H2/H4:** `reclaim_migration_pins` and `survey_rollback_pins` apply `parse_rollback_tag` and `pin_is_reapable` like every other path, and stop double-counting an image with two pin tags. The 14-day grace period is re-anchored from the tag's timestamp (when the migration *started*) to a tombstone recording when the record went missing, with clock skew handled in both directions. **H3:** `migration_store::load` no longer renames a corrupt record aside — that destroyed the `has_record` signal both pin reapers depend on. `save` fsyncs the file and the directory, and corruption backups are timestamped. **H2b/M2:** a crashed compaction's `:compacting` tag and `triple-c-compact-*` container are reaped at startup; the stale-container sweep moved from the end of a compaction to the start, where its doc always claimed it was. **M5/M6:** orphan-volume deletion moved from a `Safety::Safe` tick to the destructive path with a typed volume name; `project_store_trust` reads the real `projects.json` so a second instance's project is not offered as an orphan. Numbers: `images_total_bytes` uses `df()`'s deduplicated `layers_size`; the Total column is derived from the same figure the Snapshot column shows; partial container/staging reclaims report their failure count; `human()` no longer prints "1000.0 KB"; `docker_cli` has a timeout; blocking `fs` calls moved to `spawn_blocking`. Also fixes `ProbeContainerGuard::remove_now`, which disarmed before the await and so did nothing on the cancellation path it exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |