73dfaf5785929a894f08cf8b7190f6caeb89ea6e
93
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
17f031a5d7 | Merge branch 'fix/disk' 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 |
||
|
|
3329e07d3d |
Fix the file command surface: argument injection, unbounded reads, unchecked paths
`list_container_files` could delete the user's files. It builds `["find", path, "-mindepth", …]`, and GNU find ends its starting-point list at the first argument beginning with `-`, so a `path` of `-delete` gave it zero starting points (defaulting to `.`, which for an exec that sets no working_dir is the container's WorkingDir — the bind-mounted project) and an expression starting with `-delete`. Verified against a live container on findutils 4.10.0: files and empty directories went out of the host bind mount, and because `exec_oneshot` discards the exit code the panel then reported an empty folder. The rest of the module had the same shape of hole: * Every path parameter — `path`, `from_path`, `parent_path`, `container_dir`, `container_path`, `host_path` — arrived over IPC unchecked. There is now one validator for container paths (absolute, no `..`, no NUL, length-capped), a second for the ones that *change* something (contained in /workspace, /home/claude or /tmp), and one for host paths, which refuses traversal, system locations and hidden components. The `save()`/`open()` dialog in front of these commands is a UI convention, not a boundary. * `download_container_file` passed `None` for the fetch cap, so the cap was inert: the whole transfer was buffered in host RAM twice, and the directory refusal came *after* the buffer, so `/` meant buffering the container's filesystem before erroring. Downloads now stream through a bounded channel into the tar reader, which refuses a non-regular entry and an oversize one before the host file is created at all. Verified against a real container: a 300 MiB download peaks at 10 MiB RSS, a 9 GiB sparse file is refused in 0.01s with nothing written. * Both download paths (file and backup) used to create — i.e. truncate — the user's destination up front and delete it on a stream error, which is precisely the wrong order for a path that already holds something. They now write beside it and rename on success. * `upload_file_to_container` silently clobbered: no existence check anywhere in the stack. It now refuses by default with a FILE_EXISTS marker the frontend turns into a Replace/Skip prompt, and takes an `overwrite` flag for the retry. * `exec_oneshot_inner` read an undeterminable exit status as 0, so rename and mkdir reported success for an exec nobody could read the outcome of. It fails closed now. * A tab in a filename forged the type/size/permission columns of a listing row, and a newline forged a whole row. `find` now prints the name last with NUL-terminated records. While verifying the size ceiling against a real container, the tar header's size field turned out to be unusable past ustar's 8 GiB octal limit — Docker's Go writer puts the real size in a PAX record — so both readers take it from `entry.size()` instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
0003793abb | Merge branch 'feat/disk-ui' into integration/round-1 | ||
|
|
77ef2291d7 |
Add a Disk section: see where the bytes went, and get them back
Every recreation runs `docker commit`, which stacks a layer and never rewrites one, and 24 conditions in `container_needs_recreation` trigger a recreation. Prevention landed earlier on this branch; this is the half a user can act on. The per-project table leads with the two numbers that explain the mechanism rather than just the total: how many commit layers a snapshot has stacked above its base, and what the container's writable layer will add at the next commit. Backend (`docker/disk.rs`, commands in `docker_commands.rs`): - `get_docker_disk_usage` — one `df()` joined against the project store, behind an explicit Scan button because it walks the whole daemon. - `list_reclaimable` / `reclaim` — classified buckets with measured bytes, planned off the existing report so re-planning costs no second scan. - `destroy_project_disk_object` — one object, typed confirmation. - `sweep_orphaned_snapshots` — exposed, so its report is finally visible. Safety is structural: `reclaim` takes `ReclaimTarget`, which has no variant that can name a live project's data. Destructive work is a separate type reached only through `destroy`. No unfiltered prune is called anywhere, and nothing outside a `triple-c*` name or `triple-c.*` label is touched. Orphan detection subtracts ids from the project store and consults nothing else. From the daemon's side an idle live project and a deleted one are indistinguishable — volumes present, no container, no image — so inferring from container or image absence would offer a live project's credentials and transcripts for deletion. A store that loaded empty from an existing `projects.json` is treated as a failed load, not as "no projects", because `ProjectsStore::new()` recovers from a corrupt file by starting empty. Three things verified against a live Docker 29.7.2 rather than assumed: - Compaction is a two-stage build (`FROM scratch` + `COPY --from`), which keeps every byte inside the daemon; bollard's import buffers a whole image into memory. uid/gid and setuid survive; a 192.6 MB/4-layer synthetic came out 45.7 MB/1 layer. Image config does not survive, so it is replayed via create+commit, which round-trips a multi-line env var that a Dockerfile `ENV` could not. - Flattening breaks base-layer sharing, so the result carries its own copy of the base. Eight of ten real projects had a 0.10–1.32 GB delta over a 4.72 GB shared base — compacting those costs ~4 GB. The bound now subtracts that penalty, such projects are not offered at all, and the run compares unique bytes and abandons a rewrite that would grow. - `docker builder prune` reports `Total:`, not `Total reclaimed space:`, so the first parser scored every prune as freeing nothing. The Windows/WSL2 note is mandatory and its copy lives in Rust beside the tests that pin it: pruning frees space inside `ext4.vhdx`, which never shrinks on its own, so C: does not change until the disk is compacted. Also adds `lib/formatBytes.ts` — the app had four disagreeing copies, and `projects/home/format.ts` and `migrationCopy.ts` now delegate to it with byte-identical output. Base 1000 by default, matching what Docker prints. Tests: 502 frontend (was 453), 365 Rust (was 322). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
1c834a0b08 |
Drag a file out of the Files tab onto the host desktop
The Files tab could accept a drop but never produce one: getting a file
out meant "Save to host…" and a file picker. This adds the other
direction.
Two constraints shape it. `dragDropEnabled` is on — TerminalView needs
it, since the native drag-drop event is the only one carrying dropped
file paths — and it blocks HTML5 drag inside the webview, so `draggable`
plus `DataTransfer.setData("DownloadURL", …)` was never available. The
gesture is therefore pointer events into `tauri-plugin-drag`, the same
shape and the same reason as the tab strip's drag. And the file being
dragged does not exist on the host at all: it lives in a container, and
the OS can only drag a real host path.
So a drag-out is a copy first and a drag second.
`stage_container_file_for_drag` materialises the file into
`<os-temp>/triple-c-drag-out/<session>/<slot>/<name>` through the same
`fetch_container_file` the download and the viewer use, keeps the
original filename (a dropped `tmp1234` is not a file anyone wants), and
caps at the 256 MiB an upload already caps at, naming "Save to host…" in
the refusal. The path comes from Tauri's path API rather than `/tmp`,
because on Windows it is neither.
The staging directory has a lifecycle, because whole files accumulating
in the host temp dir would be the disk problem this project just fixed,
in a new place: cleared on exit inside the existing teardown (still
guarded on the main window), and reaped at startup for whatever a crash
left behind.
The copy is also an async gap in the middle of a gesture that feels
instantaneous, and the OS only adopts a drag while the button is still
down. Small files beat the pointer; large ones do not — so the staged
path is cached per entry (keyed on size and mtime, so an edited file
re-stages) and the pane says the copy is ready and to drag again, which
is an instruction rather than an apology because the retry is immediate.
A per-file slot keeps `a/notes.txt` and `b/notes.txt` from becoming the
same host path.
"Save to host…" stays exactly as it was. Drag-out is the enhancement;
a platform that refuses `startDrag` says so and points back at it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
2ca86bb5d8 |
Scrub the writable layer on the migration path too, and de-duplicate CLAUDE_JSON
Two integration fixes after merging the three feature branches. `scrub_writable_layer` is a `docker exec`, so it only works while the container runs. `migrate_project_to_base` stops its container one line before the pre-swap commit, which meant the single largest snapshot Triple-C ever takes was the one path that committed unscrubbed. Call the scrub explicitly before the stop instead of relying on the call inside `commit_container_snapshot`. Also drop a duplicate `CLAUDE_JSON=` assignment in entrypoint.sh. The Shift+Enter block re-declared it defensively to avoid a merge conflict with the awsAuthRefresh block; the conflict did not materialise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
df6d2f1ca4 | Merge branch 'feat/file-manager' into integration/round-1 | ||
|
|
dd2894cc60 |
Stop Docker disk growth, and fix the Claude Code settings that never worked
Two independent sets of fixes.
## Disk: stop the growth, no UI this round
The dangling-snapshot sweep was already correct and was never the leak. The
leak is that every `docker commit` **stacks** a layer and nothing compacts one:
a file deleted after it has been committed becomes a whiteout, not free bytes.
24 conditions trigger recreation+commit, so changing one settings field costs a
multi-gigabyte layer for the life of the project. One project was measured with
14 stacked commit layers, ~5.1 GB above its base.
* **Scrub the writable layer before every commit** (`docker/container.rs`,
`SNAPSHOT_SCRUB_PATHS` / `scrub_writable_layer`). The one moment those bytes
are still free to drop is before the commit that captures them. Measured on
one container's 4.48 GB pending layer: 3.0 GB of agent scratchpad under
`/tmp/claude-*`, the terminal drag-drop staging area (256 MiB per file, with
no `rm` for it anywhere in the repo), a PNG per pasted image, and the apt
lists/cache/logs that `browser_view/install.rs` and `triple-c-playwright-heal`
leave behind with no `apt-get clean`. A hardcoded list, never a heuristic:
`/workspace/{mount_name}` is a host bind mount and nothing here may reach one,
and the three `/tmp` globs cannot select the read-only `.host-ca`/`.host-aws`
mounts. Failure is a log line — a scrub must never block a snapshot.
* **Cap container logs** (`capped_log_config`). There was no `LogConfig`
anywhere, so containers ran on the daemon's unbounded `json-file` default.
Deliberately *not* wired into `container_needs_recreation`: participating
would recreate every project once, and a recreation costs a commit, which is
the thing being fixed. Picked up on the next natural recreation.
* **Make superseded base images sweepable** (`container/Dockerfile`). It carried
no `LABEL` at all, so `orphan_sweep_filters`' `dangling` + `triple-c.managed`
pair provably could not match one — ~11.9 GB observed stranded. Stamping
`triple-c.managed=true` is the whole fix; the sweep needed no change.
`create_container` writes the new `triple-c.base` key explicitly empty, or
Docker's label inheritance plus `docker commit` would make every snapshot
claim to be a base image. `force: false` stays, and now says why.
* **Sweep at startup** (`lib.rs`), not only after recreation: probes first
(a probe pins an image the unforced sweep then refuses), pins second, sweep
last. `sweep_orphaned_snapshots_logged` exists because all three callers threw
the report away — `reclaimed_bytes`, `failed` and `unavailable` included.
* **Reap migration leftovers.** `rollback_migration` retagged and orphaned the
migrated snapshot with no sweep. Stale `pre-migration-*` pins are now
age-reaped by scanning the tag pattern rather than trusting the state file —
`migration_store::load` reports an unparseable record as absent, which
stranded a 4-12 GB pin nothing could name again; `load` now moves a corrupt
record aside so `has_record` is trustworthy. A pin whose migration is still
awaiting confirmation is never reaped at any age. The probe container's
removal was a plain statement after an await, so a dropped future (an app quit
mid-migration) leaked a container pinning a multi-gigabyte image; it is a
`Drop` guard now, with `reap_probe_containers` for the case where the process
itself dies.
* **Prune scheduler logs.** `remove` deleted a task's JSON but never its log
directory, and the task runner appended uncapped `claude -p` output.
* **Fix the delete copy.** It said "the container, config volume, and stored
credentials"; it removes *both* volumes and the snapshot image.
No prune UI, and no unfiltered `prune_images`/`prune_volumes` anywhere — the
daemon is shared with the user's unrelated work.
## Claude Code settings: two invented keys, one inverted default, one sticky bug
Verified against code.claude.com/docs/en/settings-reference.md and env-vars.md.
* `effort` -> **`effortLevel`**, the key Claude Code actually reads; the old one
was written and silently ignored. `xhigh` added to the dropdown.
* `focusMode` -> **`viewMode: "focus"`**. `focusMode` was invented. The real key
does exactly what the existing UI hint already described.
* **Session recap was inverted.** Claude Code's recap is on by default, so
`CLAUDE_CODE_ENABLE_AWAY_SUMMARY=1`-when-enabled was a no-op and the control
could never turn the recap *off*. The field is renamed to
`session_recap_disabled` rather than reused: reusing the name with the
opposite meaning would have read every stored `enable_session_recap: false` —
which is every project that never touched the control — as "the user turned
this off".
* **The stickiness, which is the important one.** Keys were emitted only when
non-default, and the entrypoint *merges* into a settings.json on a persisted
volume, so switching a setting off omitted its key, the merge preserved the
stale on-value, and the setting stayed on until a destructive Reset. The fix
already existed in the same file — the sandbox block is emitted
unconditionally for exactly this reason — and is now applied to all five keys.
A key whose neutral state is *unset* (`tui`, `effortLevel`, `viewMode`,
`awaySummaryEnabled`) is emitted as JSON `null` and the entrypoint deletes it,
because a stand-in value is not neutral: `tui: "default"` pins the classic
renderer where unset lets Claude Code choose, and `viewMode: "default"`
overrides the user's own sticky `/focus` choice.
* The same stickiness existed, unnoticed, in the **env vars**: `docker commit`
bakes container env into the snapshot image, so a `=1` written once rode it
forever. All four are now emitted on every create, extracted into
`claude_code_env_vars` and unit tested. Two use an empty value for "off"
rather than `0`, because they outrank a setting the user can change from
inside their own container and Triple-C's default must not overrule a
`/config` choice it never asked about.
* TUI mode is now a genuine three-way choice (automatic / classic / fullscreen),
which the always-emitted key makes both necessary and possible.
`merge_claude_code_settings` is untouched by choice: a project-level OFF still
cannot override a globally-ON setting.
Tests: 364 frontend (+5), 308 Rust (+23), covering the scrub path list and
script, log rotation, pin reaping, and that toggling a setting off actually
clears a previously-set ON value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
15e05e2197 |
Turn the Files tab into a real file manager
Rename, an in-app viewer for text and images, host-to-container drag and drop, New folder, keyboard operation — plus the pre-existing bugs the new surface would otherwise have been built on top of. New Tauri commands (file_commands.rs, registered in lib.rs): * rename_container_path — `mv -n -- <from> <parent>/<name>` through exec_oneshot_as, so the *exit code* is checked. exec_oneshot discards the status and interleaves stderr into stdout, which would have made a permission failure look like a success. `mv -n` on its own is not enough either: GNU coreutils makes its refusal to clobber silent and exits 0, so an explicit `test -e` on the destination is what turns a name clash into an error the user sees. `mv`'s own words are surfaced, since renames outside /workspace legitimately fail on permissions. The new name is validated in Rust (no `/`, no NUL, not "." / ".." / empty, ≤255 bytes) — it is user text going into argv, and a name with a separator would be a move rather than a rename. * read_container_file — exact bytes via Docker's archive endpoint, returned as base64. Deliberately not exec_oneshot, which runs every chunk through String::from_utf8_lossy and merges stderr, so it would corrupt any non-UTF-8 file and could splice diagnostics into content. Base64 rather than Vec<u8> because Tauri serialises a byte vec as a JSON number array. Capped and truncation-reporting; the caller picks the cap (images get 5 MiB against text's 1 MiB, being the kind that blows a text-sized budget) and Rust clamps it to 8 MiB regardless. * create_container_directory — `mkdir` without -p, so a clash is an error rather than a silent success. Named for its siblings rather than the bare `create_directory` in the brief. The tar-extraction half of download_container_file is now the shared fetch_container_file() both commands use, and it abandons the transfer once a capped read has what it needs. Frontend: * Single click selects, double click opens. Directory navigation moved onto double click too — a single click used to navigate, which made it impossible to select a directory in order to rename it. Rows are now focusable and the table is a real `grid`: Enter opens, F2 renames, arrows walk the rows. No outline suppression; the global :focus-visible ring is what shows focus. * FileViewerModal (built on ui/Modal, the only correct dialog) renders text in a <pre> and images from a revocable blob: URL. tauri.conf.json's img-src had neither `data:` nor `blob:`, so an in-app image was blocked by CSP; `blob:` is added — revocable, and no megabytes of base64 in the DOM. The asset protocol stays disabled. Anything else gets a "Save to host" state instead of a broken preview, decided by extension and then by sniffing the bytes for NUL. * Host drag-and-drop uses Tauri's native onDragDropEvent, mirroring TerminalView: HTML5 ondrop carries no paths and is blocked in the webview on Windows by dragDropEnabled, which the terminal needs. The listener is window-wide, so it routes by hit-testing the payload position (physical pixels, hence the devicePixelRatio divide) against the pane's rect — a hidden pane has a zero-size rect and never matches, which is what keeps this and the terminal's listener apart. enter/over/leave drive a drop highlight. * Per-row Download is now "Save to host…"; directories no longer offer it. Pre-existing bugs fixed: * Uploaded files landed root:root with a 1970 mtime. tar::Header::new_gnu() zeroes uid/gid/mtime and Docker honours the header verbatim, so uploads were not writable by `claude`. All four single-file tar builds now go through build_single_file_tar() with the container user's ids, read from the container because entrypoint.sh remaps them to the host user on Unix and deliberately does not on Windows. * Symlinked directories could not be opened: `find -printf '%y'` reports `l`. The listing now prints `%Y` as well, so is_directory dereferences and a new is_symlink carries what `%y` used to say. The row labels the link. * upload_file_to_container had no size cap and did a synchronous fs::read on an async worker. Now 256 MiB (matching the terminal drop path) with the read and tar build in spawn_blocking, and the host mtime preserved. * A directory passed to upload reached fs::read and produced an opaque "Is a directory". Rejected with an explanation instead — recursive upload is a larger feature than this panel needs. * download_container_file wrote the *first tar entry*, so downloading a directory silently produced a garbage file. Non-regular entries are now an explicit error. Tests: 46 new (33 frontend across FilesTab, useFileManager and filePreview; 12 Rust covering the find-output parser and the rename validator, neither of which had any). 405 frontend / 297 Rust, both green. No drag-out dependency was added — tauri-plugin-drag is not introduced and OS drag-out is not attempted; that stays deferred, with "Save to host…" as the way files leave the container. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
84a67fcd0d |
Stop an empty base-image label from silencing the migration notice
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 1m5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-linux (pull_request) Successful in 5m31s
Build App (Preview) / build-windows (pull_request) Successful in 6m24s
Build App (Preview) / prune-previews (pull_request) Successful in 8s
A project can be out of date and say nothing about it, in two ways that
compound: the lineage lookup treats "unknown" as an answer, and the
fallback that exists for unknown lineage disappears when its probe fails.
`create_container` always writes triple-c.base-image-id, even when the
value is unknown — deliberately, so an inherited image label cannot ride
a snapshot forever. That makes Some("") the ordinary reading from a
container whose lineage was never established. The lookup filtered for
emptiness only on the final result, so that empty string satisfied the
container branch and skipped the snapshot entirely: a snapshot that had
recorded a real lineage was never consulted, and the project reported
"unknown" with the answer one lookup away. Each source is now filtered
before it can answer, in pick_recorded_lineage, which is a plain function
so the case has a test that fails against the old logic.
A genuinely pre-label project stays unknown, and should: its ancestor is
not knowable, and inventing one would make it look permanently current.
The probe is the intended signal for those — but if the probe failed,
get_container_staleness returned early with nothing populated, the banner
found no gaps and rendered null, and the probe_error it already knew how
to display sat behind a gate that returned before reaching it. Silence
there is indistinguishable from "up to date", and it is likeliest for the
oldest and largest projects, whose manifests are the ones apt to exceed
the inspection limit — one real project measured 6.93 MB against an 8 MB
cap. An unknown-lineage container whose probe failed now says the check
could not be completed, with the reason, under the tone that means
unresolved rather than the one that means something is wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
88f2e73474 |
Merge branch 'main' into fix/scheduler-home-clobber
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) Successful in 34s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-linux (pull_request) Canceled after 0s
Build App (Preview) / prune-previews (pull_request) Canceled after 0s
Build App (Preview) / build-macos (pull_request) Canceled after 21s
Build App (Preview) / build-windows (pull_request) Canceled after 22s
|
||
|
|
fa4940dd7d |
Say when a scheduled task is running
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build Container / build-container (pull_request) Successful in 2m53s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 6m2s
Build App (Preview) / build-linux (pull_request) Successful in 6m53s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
A run is detached — cron has no terminal, and the app fires it as a detached exec — so triggering one and watching the log was indistinguishable from triggering one that died. Worse, `claude -p` writes its answer in a single burst at the end, so a healthy run shows nothing but its log header for as long as it is thinking. The honest reading of the old UI was "it stalled". triple-c-task-runner now publishes a state file per run (pid, start time, log path) and removes it from an EXIT trap. flock remains what actually prevents overlapping runs; this is purely observability, so every reader verifies the pid rather than trusting the file — a container stopped mid-run cannot fire a trap, and a task stuck on "running" forever would be a worse lie than no indicator at all. Stale files are cleared on read. On top of that: - `list` grows a status column: "running 4m12s" or "idle". - `status [--id] [--watch]` answers "is it still going?" directly, with elapsed time and the tail of the log when there is any output yet. - `run` streams the log instead of blocking silently, and refuses to start a task that is already running. - The Automation tab marks a running task, disables its Run now button, and polls while anything is in flight — including the second or two between firing a run and the runner registering it, which is the exact window that used to read as dead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5f990dd28b |
Sweep the snapshot commits recreation leaves behind
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-linux (pull_request) Successful in 5m37s
Build App (Preview) / build-windows (pull_request) Successful in 6m16s
Build App (Preview) / prune-previews (pull_request) Successful in 5s
Every recreation commits the container to triple-c-snapshot-{id}:latest
and moves that tag; the image it pointed at keeps its layers and loses
its name. Nothing deleted those, so they accumulate — measured on one
real host, 7 orphans holding 7.4 GB, three of them from a single day's
work.
`sweep_orphaned_snapshots` removes them, under two conditions that are
the whole safety argument. Untagged: every image the app depends on
carries a tag, so a project's live `:latest` and a migration's
`pre-migration-*` rollback pin cannot match the filter at all. And
labelled `triple-c.managed=true`, which `docker commit` copies from the
container onto the image — the user's own dangling images are not ours
to delete. Removal is unforced on top of that, so Docker refuses while
any container is still built from the image, including the stopped
containers of projects that are not running; those are counted and left
for the next sweep.
It runs after a recreation, which is when the orphan it just made
becomes removable, and after a migration is accepted, which is the
moment dropping the pin turns the pre-migration snapshot into an orphan.
Both detached: this is housekeeping, and a full disk beats a project
that will not start. Each sweep clears every orphan it finds, so
recreations that predate it are cleaned up too.
The label string is now a constant rather than four literals, and a test
pins both filter conditions in place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
77567ac2ae |
Merge remote-tracking branch 'origin/main' into feature/corporate-ca
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m28s
Build App / build-windows (pull_request) Successful in 5m20s
Build Container / build-container (pull_request) Successful in 9m58s
Build App / build-linux (pull_request) Successful in 5m12s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
# Conflicts: # app/src/lib/tauri-commands.ts |
||
|
|
7a5823cb2b |
Inject the corporate CA certificate into containers
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Behind a TLS-terminating corporate proxy every HTTPS call inside a container fails — npm, pip, git, curl, the browser-view pane, and Claude Code's own API requests. There was no mechanism at all: installing the certificate by hand inside a container is lost on Reset and had to be repeated per project. A global CA path in AppSettings with a per-project override on Project, taking either a single certificate file or a directory. It is bind-mounted read-only at /tmp/.host-ca (mirroring /tmp/.host-ssh and /tmp/.host-aws) and applied by entrypoint.sh on every start, so it survives recreation, migration and Reset. Four things this gets right that are easy to get wrong: * update-ca-certificates globs *.crt case-sensitively, so a .pem that is merely copied in is ignored in silence. Certificates are renamed, by container_cert_name() in Rust and a mirrored few lines of shell. * The system store only serves curl/git/apt. Node — and so Claude Code itself — needs NODE_EXTRA_CA_CERTS, Python needs REQUESTS_CA_BUNDLE/SSL_CERT_FILE, and Chromium reads neither: it wants ~/.pki/nssdb, seeded with certutil (libnss3-tools, added to the image). * Those vars are set from Rust at creation, never exported by the entrypoint — a terminal is a docker exec and sees nothing the entrypoint exported. They are emitted empty when no CA is configured, since docker commit bakes env into the snapshot image. * triple-c.ca-fingerprint hashes the certificate bytes as well as the path, so a CA rotated in at the same location still forces a recreation. Verified end to end against a real container and a self-signed CA: curl, node, python and git all complete a TLS handshake against a server signed by it and all three fail in the same container without it; the env vars are visible from a docker exec session; the store is cleaned when the setting is cleared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k |
||
|
|
c3f92674b1 |
Fix the shared Claude auth flow: whole sign-in URL, recoverable rejected code
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / build-linux (pull_request) Successful in 5m50s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Two compounding bugs made `claude setup-token` unusable, both measured against 2.1.226 under a pty rather than reasoned about. **The sign-in URL was truncated.** The CLI emits it as an OSC 8 hyperlink and slices the *visible* text of that hyperlink to the terminal width: a 346 character URL arrives at 80 columns as five separate hyperlink emissions, each carrying the whole URL in its parameter and 80 characters of it on screen. The transcript scraper picked up the first slice — a URL that parses, points at claude.com, and cannot authorise anything. The ANSI stripper now surfaces the OSC 8 target and `claude-token-link` carries it to the UI, which prefers it over the scraped text. It still goes through `sanitizeRelayUrl` with the ANTHROPIC_SIGN_IN_HOSTS allowlist before display and again before `openUrl` — an OSC 8 parameter is never rendered, which makes it the easier place to hide a hostile host, not a trusted one. The wrapped-display fallback is kept for CLI versions that print a bare URL. **A rejected code hung the flow.** On a bad paste the CLI prints `OAuth error: Invalid code…` / `Press Enter to retry.` and blocks on stdin instead of exiting; nothing recognised that, so the exec sat until the 15-minute timeout with the UI still saying "Finishing sign-in". Given the first bug handed the user a truncated URL, an invalid code was the likely first outcome. The streamed output is now scanned for that message, `claude-token-code-rejected` reopens the input with an explanation, and the Enter is sent so the next code has a prompt to land in — bounded by MAX_CODE_ATTEMPTS, after which the flow reports a failure. An undeterminable exec exit status is logged rather than silently read as success. **A wrapped token was rejected *and* leaked.** `stty cols` fails silently, and an 80-column fallback splits the ~103 character token across two lines: the parser saw a too-short fragment and failed, while the redactor masked the first line — which carries the `sk-ant-` marker — and printed the second, the tail of a live credential, to the UI in clear. `scan_credential_body` now reassembles a run across hard wraps and both the parser and the redactor use it, so they cannot disagree about where a credential ends. A join only happens across a break at a plausible terminal margin (>= 40 columns) and only for a run not already long enough to be a whole credential — without that second guard a repainting TUI welds one frame's token onto the next frame's first word. The length floor is applied to the reassembled body, so a fragment is still never accepted. Also: `stty cols` raised 200 -> 400 (the URL alone needs ~350), and `ESC ( B` is handled as the three-byte charset designation it is — it prefixes every repaint frame, and treating it as two bytes emitted a stray `B` that could glue itself onto a token and make the parser refuse it. `submit_claude_token_code`'s single-write behaviour is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k |
||
|
|
2de00b3c55 |
Fix review findings: secrets in snapshots, URL spoofing, migration data loss
Adversarial review of the branch produced findings across four areas. This addresses them, plus the Windows CI environment. Secrets. commit_container_snapshot baked the container's full env into the per-project snapshot image, so the shared OAuth token — and the AWS keys, git token and gateway master key — outlived revocation and were readable via docker inspect. Verified against Engine 29.6 that a commit body's config merges over the container's: keys cannot be dropped but can be overwritten, so all of them now commit as KEY=. clear_claude_token additionally rewrites images from earlier builds and reports honestly when a tag could not be rewritten. The recommendation to move the token out of env entirely was not taken, with reasoning: apiKeyHelper is a different auth method that outranks CLAUDE_CODE_OAUTH_TOKEN rather than a transport for it, and no file-based delivery exists. The durable exposure — the image — is what is closed here. Separately noted, not fixed: entrypoint.sh captures the token into the scheduler's .env inside the persisted volume. URL spoofing. Three call sites reached openUrl with container-controlled strings, one of which the review missed (the WebLinksAddon handler). The sign-in URL was scraped from container output with a longest-match tie-break and no userinfo check, so claude.ai@evil.tld rendered as "claude.ai…" in a truncating element. There is now one sanitizer in front of every sink — scheme allowlist, no userinfo, C0/C1 and quote rejection, host allowlist for the sign-in case, first-match — and the origin renders un-truncated. The toast is keyed so a changed URL remounts, closing a bait-and-switch where the user read one URL and clicked another. Migration. The rollback pin was best-effort: a tag failure was logged and the migration continued past remove_container, after which the final commit overwrote the only copy of the old system layer. It now aborts before anything destructive and reads the tag back. /var was destroyed while the ordinary recreate path preserves it — making the "safe" alternative to Reset more destructive than Reset's alternative; data-bearing subtrees are now detected and disclosed in the pre-flight rather than copied, since tarring a live database onto a different base's packages is a corruption risk. resume_migration now verifies the migration-state label instead of reporting success for a container that never swapped. dismiss actually resolves the record rather than leaving the feature permanently refusing to migrate. Start and Reset are guarded while a migration is live. Lifecycle. The gateway no longer publishes on 0.0.0.0 — bind address and advertised URL are derived together so they cannot drift. Disabling it now stops it. App exit runs teardown concurrently under a budget with a visible shutting-down state instead of blocking for minutes. Auto-starts retry when Docker is not up yet, and the polling-recovery path now reconciles, so interrupted migrations are still recovered. Auth-bridge forwards are capped, closing a container-driven fd exhaustion. Windows CI. build-windows failed on this branch with "linker link.exe not found". The runner had no MSVC build tools and the workflow assumed a hand-provisioned machine, so a bare runner registers, accepts jobs and fails at link time after downloading the whole crate graph. The job now installs the VC++ workload when vswhere cannot find it, matching how it already conditionally installs Rust and Node. 192 Rust tests, 274 frontend tests, both builds clean, zero warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d42b741337 |
Migrate a project onto a new base image without losing its volumes
Projects were pinned to the image they were first created from. Both create paths preferred triple-c-snapshot-<id>:latest whenever it existed, and container_needs_recreation compared the container's live image against the triple-c.image label — which create_container wrote from the same image it created from. A tautology that could never fire. The only escape was Reset, which calls remove_project_volumes and destroys the login, skills and transcripts. Measured consequences on this host: real projects are missing socat (so the auth bridge cannot tunnel) and bubblewrap (so sandbox mode does not work), plus Mission Control and triple-c-sso-refresh, and sit 61 packages behind the base including ca-certificates, openssl and curl. Detection. create_container now writes triple-c.base-image-id (the image ID, not RepoDigests, which local-built and custom images do not have) and triple-c.create-image. container_needs_recreation takes the expected create-image and compares against the latter, so the check means something. base-image-id is deliberately NOT compared: a base bump would otherwise silently recreate from the snapshot, consuming the "you should migrate" signal without migrating. Staleness is surfaced, never acted on automatically. Migration keeps the volumes. /home/claude and ~/.claude are volumes and the image's copy is seed-only — permanently masked after first mount — so the login, ~/.claude.json, skills, transcripts, scheduler tasks, SSH keys, cargo, uv, ruff and Claude Code itself re-attach untouched. Only root-level state is rebuilt: apt packages are replayed against the new base rather than copied, so no stale libc is dragged forward, and /usr/local, /opt and the non-bind-mounted parts of /workspace are copied verbatim with tar --skip-old-files so they can never clobber a newer base binary. docker diff is not used: on a snapshot-derived container it reports only changes since the last commit. Raw image-vs-image diffing is filtered through dpkg ownership because it otherwise lies — 8,677 raw path differences on a real project reduced to 2 genuinely user-authored files, both loose /workspace-root files. Crash safety. snapshot:latest keeps pointing at the old image until the final commit, so any crash before it self-heals on next start. Later crashes are caught by reconcile_project_statuses. The rollback pin is a docker tag: 0.057s and 0 bytes. Rollback restores the system layer only — volumes are never touched — and the UI says so rather than implying a time machine. Fixes an infinite recreation loop shipped with the MCP removal. docker commit propagates labels to the image, so a container created from a snapshot inherited its non-empty triple-c.mcp-fingerprint and the one-shot shim recreated it again on every start, forever. Lineage labels are now always written explicitly. Documents the second, separate bug this uncovered: Dockerfile changes under /home/claude never reach an existing project, migration or not, because the volume masks them. Anything that must stay upgradable belongs in /usr/local/bin or /opt, or must be seeded by entrypoint.sh. 145 Rust tests, 227 frontend tests, both builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cc5f691677 |
Add llama.cpp backend, model gateway, URL relay and browser view
Four features, plus a latent bug fix.
llama.cpp backend. Claude Code only ever speaks the Anthropic Messages
API — confirmed empirically by pointing it at a logging server, which
received POST /v1/messages?beta=true. llama-server implements that
natively (verified in its README, alongside --port default 8080), so
this is a plain base-URL backend with no translation shim, the same
shape as Ollama. Its --api-key defaults to none, so the auth token is a
placeholder Claude Code requires and llama-server ignores.
Model alias fix. ANTHROPIC_DEFAULT_HAIKU_MODEL is documented as "also
used for background functionality", and Triple-C set none of the alias
vars. So on every custom-endpoint backend, Claude Code resolved `haiku`
to an Anthropic model id and sent it to a local server that does not
have it — background features failed silently. All four
ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL vars are now pinned to
the backend's configured model, with an optional Haiku override, and
blanked for Anthropic and Bedrock so those keep Claude Code's defaults.
The deprecated ANTHROPIC_SMALL_FAST_MODEL is never emitted. Existing
Ollama and OpenAI-Compatible containers are recreated once so the new
env reaches them; the snapshot is preserved.
Model gateway. Optional LiteLLM sibling container, off by default,
mirroring stt.rs — this is what makes real OpenAI usable, since
api.openai.com has no /v1/messages. Pinned to v1.96.0 by tag and digest:
the 1.82.7/1.82.8 malware was PyPI-only and never affected the official
images, which is precisely why this builds FROM the image rather than
pip-installing, but 1.84.0 is still the floor for proxy CVEs (API-key
SQLi, Host-header auth bypass, MCP auth bypass). Binds 0.0.0.0 because
project containers consume it, and therefore always sets a master_key —
LiteLLM without one accepts any key. The provider key lives in the OS
keychain and is uploaded into a volume, never an image layer or label.
URL relay. A container-side xdg-open/BROWSER shim opens URLs in the
host's browser. Uses an OSC sequence to /dev/tty rather than a printed
sentinel, because the shim usually runs as a grandchild of a process
capturing its children's output. Degrades to printing the URL when no
terminal is attached, so scheduled tasks do not hang. Only http/https,
with control characters rejected before new URL() — which strips
newlines, so java\nscript: would otherwise parse as javascript:. Nothing
auto-opens; the user confirms. The web terminal shows a tap-to-open
banner instead, since that browser may be a phone across a tunnel.
Browser view. A Project Home tab that watches and takes over the browser
Claude drives with Playwright, using Playwright's own dashboard. Zero
image cost — Playwright stays user-installed. It does not reuse the auth
bridge's PortForward, which binds an unauthenticated port: correct for a
throwaway OAuth listener, wrong for mouse and keyboard control of a
browser in a passwordless-sudo container. Instead a token-gated loopback
proxy checks Host, then token or a forbidden-header origin signal,
before a byte reaches the container. Host ports are confined to
47820..=47827 so CSP frame-src can enumerate them rather than widening
to a wildcard, with a test asserting the two agree.
188 frontend tests, 107 Rust tests, both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
7d00390e1f |
Add scheduled task creation, and stop a bad cron unscheduling everything
Build App / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 9m35s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Failing after 2m26s
Build App / build-macos (pull_request) Successful in 2m49s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Completes the Automation tab: it could list, toggle, run, log and remove tasks but not create them, so task creation still meant dropping to the CLI. Adds add_scheduled_task and update_scheduled_task, plus a task editor with cron presets and a plain-English reading of the expression. Every field is free user text, so all of it goes to the scheduler as a bare argv vector through bollard — no shell, no quoting. Validation is shape-only rather than metacharacter scrubbing: length caps, no control characters in single-line fields, no leading-dash name, absolute working_dir. Verified by round-tripping a prompt containing `; rm -rf /`, `$(id)`, backticks and newlines: it landed byte-for-byte in the task JSON with nothing executed. The scheduler CLI has no `edit`, so update is add-then-remove with the add first — a rejected edit leaves the original intact. The new id is surfaced in the editor rather than hidden. Root-cause fix, and the more serious half of this commit: triple-c-scheduler never validated --schedule, and rebuild_crontab regenerates the entire crontab and pipes it to `crontab`, which rejects the whole file if any line is malformed — with the error thrown away by `2>/dev/null || true`. A single bad schedule therefore silently unscheduled every other task in the container while reporting success. Reproduced directly. It matters because the global CLAUDE.md tells Claude to drive this CLI, so Claude could trigger it unprompted. `add` now validates the expression and exits non-zero, and rebuild_crontab reports a rejected crontab instead of swallowing it, keeping the offending file for inspection. Verified against the real CLI in this container: a bad schedule is refused without disturbing an existing task's crontab entry, and `0 9 * * 1-5`, `*/30 * * * *`, `0,30 8-17 * * *` and `0 0 1 1 *` are all still accepted. The Rust layer validates independently, agreeing with vixie cron on 23 probed expressions including `1/2` and `*/0` being invalid. 121 frontend tests, 44 Rust tests, both builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d95ba54a69 |
Add shared-auth-token UI and make cancelling actually cancel
UI for the shared Claude token: a Settings section showing token state with Authenticate and Revoke, an acquisition modal built on the shared Modal (sign-in link handed to the host browser via the opener plugin, plus the code input that answers `setup-token`'s stdin prompt — the flow cannot complete without it), and a per-project opt-out toggle shown only for the Anthropic backend. Cancellation: acquire_claude_token previously had only two exits, completion and a 15-minute timeout, and held the single-flight guard for the whole time. Closing the dialog therefore locked the user out of retrying for up to 15 minutes. Adds cancel_claude_token, backed by a oneshot claimed and released in lockstep with the input guard, selected on in the run loop so it wins the race and tears the exec down. The dialog's Cancel now calls it and closes either way. Also refreshes CLAUDE.md, which had drifted: it documented the deleted ProjectCard, and asserted that new IPC commands need permission grants in capabilities/default.json — they do not, that file covers plugin commands only. Adds the conventions that would otherwise bite: container_needs_recreation() is purely label-based and never diffs env, so container-affecting state needs its own label; and #[serde(default)] on a bool yields false regardless of intent. Corrects the claim that Reset preserves credentials. Reset calls remove_project_volumes, which deletes both the home and claude-config volumes, so it wipes ~/.claude, the OAuth token, installed skills and session transcripts. 84 frontend tests, 34 Rust tests, both builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
01a2f6aec8 |
Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish
Project Home (DESIGN-REVIEW §B2): the project is promoted from a 280px
sidebar card to a first-class main-area view. ProjectCard.tsx (1,257
lines) is replaced by a select-only ProjectRow plus tabs for Overview,
Sessions, Automation, Config and Files. The PortMappings, FileManager
and ContainerProgress modals are absorbed rather than reimplemented.
Config gains a Saved/Saving/Failed indicator — save-on-blur failures
previously reached only console.error.
Tier-1 polish (DESIGN-REVIEW §A): new elevation, muted-accent, disabled
and focus-ring tokens; a global :focus-visible ring with every
focus:outline-none removed; filled buttons moved to --accent-emphasis
and white-on-success toggles retired, fixing three WCAG AA failures
(2.1:1, 2.5:1, 2.4:1); a shared Modal primitive with role="dialog",
focus trap and restore, adopted by all remaining modals; status
indicators that carry a glyph and word rather than colour alone.
Ctrl+Shift+W closes a tab, deliberately not Ctrl+W — that is readline's
kill-word, used constantly in the terminal this app is built around.
Auth Bridge: a general loopback-callback bridge so browser logins run
inside a container (aws sso login, Concourse fly login, claude login)
can complete against the host browser. Listeners are discovered from
/proc/net/tcp{,6} — ss/netstat/lsof are absent from the image — bound on
host 127.0.0.1 only, and tunnelled in over the Docker API via socat,
which keeps working on Docker Desktop where container IPs are not
routable. Falls back to [::1] because Node resolves localhost to IPv6
first, so claude login often binds ::1 alone. Opt-in per project.
This extracts create_attached_exec() and moves the existing terminal
session path onto it, so there is one attached-exec implementation
rather than two.
Shared auth token: `claude setup-token` is run in a container, the token
is stored in the OS keychain and injected as CLAUDE_CODE_OAUTH_TOKEN
into Anthropic-backend projects. Contrary to the initial design note,
setup-token uses an Anthropic-hosted redirect and blocks on a stdin
paste prompt rather than a loopback callback, so a stdin command is
required for the flow to complete.
The token is never logged, never returned to the frontend, and is
redacted from the streamed output with a stateful matcher that withholds
any tail that could still grow into a secret. Change detection uses a
random rotation id rather than a hash, since a hash in a docker-inspect
readable label would be an offline verification oracle.
Frontend 33 -> 51 tests; Rust 34 tests. Both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
0ac4e5030c |
Add permission modes and container introspection backend
Permission modes: replaces the binary full_permissions flag with a PermissionMode enum (Plan/Default/AcceptEdits/Bypass). Flag mapping is defined once in PermissionMode::cli_args() and used by the terminal, the web terminal, and the scheduler: Plan -> --permission-mode plan Default -> (no flag) AcceptEdits -> --permission-mode acceptEdits Bypass -> --dangerously-skip-permissions Choices verified against `claude --permission-mode` on 2.1.226. full_permissions is retained and effective_permission_mode() falls back to it, so existing projects.json needs no migration. Bug fix: triple-c-task-runner ran `claude -p ... --dangerously-skip- permissions` unconditionally, ignoring the project's setting entirely. It now reads TRIPLE_C_PERMISSION_MODE, which is injected into the container, added to the reserved env blocklist, propagated through the entrypoint's cron env filter, and tracked by a new triple-c.permission-mode label so a change forces recreation. Introspection: new commands/inspect_commands.rs exposes read-only views into the container over docker exec — Claude sessions (parsed from ~/.claude/projects/<cwd>/<uuid>.jsonl), installed capabilities (skills, agents, commands, hooks, plugins, natively-configured MCP servers), and the triple-c-scheduler task list, logs and notifications. Task/session ids are validated against a strict allowlist and every parameterized call runs as a bare argv vector via bollard, so no shell is involved. Stopped containers return empty results rather than errors. No UI yet; that lands with the Project Home view. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d0bb631d4d |
Remove MCP backend, entrypoint injection, and docs; add migration shim
Completes the removal begun in the previous commit. Backend: deletes models/mcp_server.rs, storage/mcp_store.rs and commands/mcp_commands.rs, the McpStore on AppState, the four IPC handlers, Project::enabled_mcp_servers, build_mcp_servers_json(), compute_mcp_fingerprint(), the MCP_SERVERS_JSON env injection, the mcp-fingerprint label, and the whole MCP container lifecycle. create_container() and container_needs_recreation() lose their mcp_servers/network_name parameters. Container: entrypoint.sh no longer merges MCP_SERVERS_JSON into ~/.claude.json. MCP_SERVERS_JSON stays in the reserved env blocklist. Security: the Docker socket is no longer auto-mounted for stdio+Docker MCP servers — it now mounts only when allow_docker_access is set. Migration: old containers were created with network_mode=triple-c-net-<projectId> and refuse to start once that network is gone. docker/network.rs becomes docker/legacy_cleanup.rs with label-driven, best-effort removal of leftover MCP containers and the per-project network, called on both delete and recreate. container_needs_recreation() now forces a rebuild for any container carrying a non-empty triple-c.mcp-fingerprint label or attached to a triple-c-net-* network, moving it onto the default bridge. Both can be dropped a release later. Docs: drops the MCP sections from README/HOW-TO-USE/TECHNICAL and adds a short note pointing at Claude Code's native `claude mcp` / `/mcp` / .mcp.json instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
26adccce5b |
Rename backup archive root so extraction dir mode isn't clobbered
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m14s
Build App / build-windows (pull_request) Successful in 4m26s
Build App / build-linux (pull_request) Successful in 5m0s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The transform used `s,^\./,workspace/,`, which rewrites the workspace *contents* (`./foo` -> `workspace/foo`) but leaves tar's root member as a bare `./`. That `./` entry carries the source root's mode/mtime, and on extraction tar stamps them onto the extraction directory itself. Match the leading `.` instead (`s,^\.,workspace,`) so the root member is renamed `./` -> `workspace`, giving the archive a proper `workspace/` directory entry and no bare `./`. The extraction directory is left untouched. Contents, hidden files, excludes, symlink targets and the `flags=rh` hardlink handling are unchanged. Verified in-container: archive top level is exactly `workspace/` + `home-claude/`, no `./` member, node_modules excluded, extraction into a 0755 dir leaves it 0755, workspace/.git preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5cd528a4ef |
Use flags=rh so intra-workspace hardlinks survive the transform
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 4m24s
Build App / build-linux (pull_request) Successful in 5m3s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Review caught that `flags=r` disables rewriting of both symlink AND
hardlink target names. Leaving symlink targets alone is intended, but a
hardlink's stored target is an archive-internal reference to another
member's name — when member names become `workspace/...` but the
hardlink target stays `./hard_link`, extraction fails hard:
tar: workspace/file.txt: Cannot hard link to './hard_link':
No such file or directory
`flags=rh` rewrites regular member names and hardlink target names
together (keeping the pair consistent) while still leaving symlink
targets untouched. Verified in-container: extract exit 0, symlink target
preserved, hardlink pair shares one inode, nesting under workspace/ intact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
c3fc029b1d |
Nest workspace under workspace/ in project backup
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m17s
Build App / build-linux (pull_request) Successful in 5m7s
Build App / build-windows (pull_request) Successful in 5m9s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The backup archive placed the workspace at the archive root (`./...`) while the sanitized home config sat under `home-claude/`. On extraction the workspace files scattered loose into the extraction directory and only `home-claude/` showed up as a distinct folder, so the backup read as "config only, workspace missing" — and some archive viewers didn't surface the root-level entries at all. Add `--transform='flags=r;s,^\./,workspace/,'` so the workspace nests under `workspace/`, parallel to `home-claude/`. `flags=r` scopes the rewrite to member names only, leaving symlink targets (relative and absolute) intact. Excludes still match the pre-transform names. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
01a8f5c503 |
Apply remaining review findings (L-e, L-f)
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 1m30s
Build App / build-macos (pull_request) Successful in 2m16s
Build App / build-windows (pull_request) Successful in 3m6s
Build App / build-linux (pull_request) Successful in 6m11s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- L-e: route terminal file drops purely by a bounds hit-test instead of the `active` flag. Inactive panes are display:none (zero-size rect) so they never match; a zero-size guard makes that explicit. Correct for the current tabbed layout and future-proof for split panes, where a drop on a visible-but-unfocused pane previously matched no handler. - L-f: stream the dropped file straight into the upload tar inside a blocking task (new exec::upload_host_file_to_container) instead of reading the whole file into a Vec and then re-packing it. Peak memory drops from ~2x to ~1x the file size, and the synchronous file IO no longer runs on the async worker. cargo check / tsc / vitest all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
0945e21eb1 |
Address second review pass: fix start-time race + minor cleanups
Build App / compute-version (pull_request) Successful in 9s
Build Container / build-container (pull_request) Successful in 1m48s
Build App / build-macos (pull_request) Successful in 2m16s
Build App / build-windows (pull_request) Successful in 3m11s
Build App / build-linux (pull_request) Successful in 4m51s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- M1 (race): don't mount the host AWS dir for static-credential Bedrock. sync_bedrock_credentials() is the sole writer of ~/.aws/credentials in that mode, and mounting /tmp/.host-aws let the entrypoint's `rm -rf ~/.aws; cp -a` race that write at startup (only when a global aws_config_path was also set). Static keys + AWS_REGION env are self-sufficient and don't need the host config, so skipping the mount removes the dual-writer entirely. - L-a: exit codes are now read via wait_for_exec_exit(), which polls inspect_exec until the exec reports finished, so a non-zero tar/cred exit isn't missed by reading exit_code too early. The backup only fails on a definitively non-zero code (falls back to the empty-output check if undeterminable). - L-b: fixed two comments that referenced the old write_bedrock_static_credentials name (now sync_bedrock_credentials). - L-c: entrypoint only rewrites ~/.claude.json when awsAuthRefresh is actually present, avoiding a needless jq reformat on every non-SSO start. - L-d: backup script traps EXIT to remove its mktemp staging dir even when tar fails, so failed backups don't accumulate temp dirs (with the sanitized config copy) in the container. L-e (drop routing) is a non-issue: the layout is tabbed, so only one terminal pane is ever visible; the active-guard routing is correct. Verified the race fix, trap cleanup, grep guard, and exit-code polling. cargo check / tsc / vitest all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d65872dc94 |
Address remaining review items: L4/M1 + L2/L5 cleanup
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 57s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 2m54s
Build App / build-linux (pull_request) Successful in 5m44s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- L4: sync_bedrock_credentials (renamed from write_bedrock_static_ credentials) now also clears a stale ~/.aws/credentials when the project no longer uses static-credential Bedrock, so static keys don't linger unused in the persistent home volume after switching backends. Skipped when /tmp/.host-aws is mounted (host-managed ~/.aws). HOME is also set explicitly on the exec env for robustness. - M1: the Backup button now has a tooltip and the success toast notes that the archive includes MCP/config which may contain MCP-embedded API keys (OAuth tokens are excluded) — keep it private. - L2: backup now uses async file IO (tokio::fs::File + AsyncWriteExt, tokio::fs::remove_file) instead of blocking std::fs between awaits; dropped-file reads use tokio::fs::metadata/read. - L5: upload_host_file_to_terminal explicitly `mkdir -p`s /tmp/triple-c-drops instead of relying on Docker's tar extractor to create the parent dir. Verified L4 cleanup guard, L5 mkdir, async IO, and exit-code paths against real containers. cargo check / tsc / vitest all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
edf0698774 |
Address PR review: backup correctness/security + drop hardening
Build App / compute-version (pull_request) Successful in 8s
Build Container / build-container (pull_request) Successful in 51s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 2m52s
Build App / build-linux (pull_request) Successful in 6m5s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Fixes from the code review of this branch: - Backup requires a running container (it runs via `docker exec`, which can't run on a stopped one). Removed the misleading "Backup" button from the stopped-project actions, added an explicit running check with a clear error, and corrected the doc comment. (H1) - jq sanitization fallback no longer leaks secrets: if ~/.claude.json can't be parsed, the backup substitutes an empty object and warns to stderr instead of copying the raw file (which held primaryApiKey / oauthAccount). Verified the raw key never reaches the archive. (H2) - Dropped-file paths typed into the terminal are now always single-quoted (with '\'' escaping), not only when they contain whitespace — a name like `foo$(whoami).txt` was previously sent raw into the shell. (M2) - write_bedrock_static_credentials checks the exec exit code via the new exec_oneshot_env_status and fails loudly on a write/chmod error instead of silently reporting success. exec_oneshot keeps its ignore-exit-code behavior so list_container_files is unaffected. (M4) - Backup removes a partial/truncated archive on any stream error and treats a non-zero tar exit code as failure (a truncated gzip was previously reported as success). (L1) - Dropped files are capped at 256 MiB to avoid ballooning host RAM (the file is read fully into memory then re-tarred). (M3) - Stopped excluding .git/objects from the backup so git history, including unpushed commits, is preserved faithfully. (L3) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
84e0bdf7b4 |
Add file drag-and-drop onto the terminal
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m25s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-windows (pull_request) Successful in 4m14s
Build App / build-linux (pull_request) Successful in 4m54s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Drop files onto a terminal pane and they're copied into the container and their in-container paths typed into the prompt, so Claude Code can read them for reference — mirroring the existing image-paste flow. Backend: upload_host_file_to_terminal reads the dropped host file and writes it under /tmp/triple-c-drops/<name> in the session's container, returning that path. Rejects directories and unreadable paths. Frontend: TerminalView subscribes to Tauri's webview onDragDropEvent (OS file drops are intercepted at the webview level, so HTML5 ondrop wouldn't expose paths). The window-wide event is guarded by the pane's `active` flag plus a bounds hit-test so a drop only affects the terminal it landed on; multiple files are uploaded and their paths inserted space-separated (quoted when they contain spaces). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
10e689eaa6 |
Include sanitized home config in project backup
Extends download_container_backup to also capture the container's home config so MCP servers, settings, and skills set up directly via Claude Code (stored in ~/.claude.json / ~/.claude, on the home/config volumes that a Reset wipes) survive a backup/restore cycle. Secrets are stripped per the "exclude secrets" choice: ~/.claude.json is filtered through jq to drop primaryApiKey/oauthAccount/customApiKeyResponses (mcpServers and settings are kept), and ~/.claude/.credentials.json (the OAuth tokens) is omitted. Staged config is archived under home-claude/ in the tarball. Verified on the Ubuntu/jq container base. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d07dcdfea9 |
Add "Backup" action to download a project's /workspace as .tar.gz
Adds a manual backup button on each project card (next to Start/Reset when stopped, and next to Files when running) that saves a gzipped tarball of the container's /workspace to a host path via the native save dialog. Backend: download_container_backup runs `tar czf -` inside the container (so excludes + compression happen there rather than streaming a 16 GB workspace) and pipes stdout straight to the chosen file. Regenerable build artifacts (node_modules, target, .git/objects) are excluded so the archive stays restore-sized. Returns bytes written; stderr is captured for error reporting and a zero-byte result is treated as failure. Works whether the container is running or stopped (only requires that it exists). Verified on the Ubuntu/GNU-tar container base. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |