11216c45e387359d6e12332a6669cd623354933f
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
11216c45e3 |
Assert every command is registered, and every registration exists
This is the shape of the bug behind the original OAuth-callback report: `set_auth_bridge_enabled` existed, worked, and had a typed frontend wrapper — with zero call sites. The switch the docs told users to flip was wired to nothing, so every login callback was refused. Both halves compiled, so nothing noticed. The reverse direction is the sharper one: a command that is registered but reachable from nowhere is still IPC surface a compromised webview can call. `list_sibling_containers`, which returns every container on the daemon including the user's unrelated work, sits in exactly that state. Mutation-checked both ways: removing a registration fails the test, restoring it passes. The first parser I wrote split the list on commas, which glued each `// Docker` style comment to the command after it and then dropped that command as a comment — silently, once per group, 17 in total. Line-based now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
e9902f0564 | Merge branch 'r4/narrow' into ship/core | ||
|
|
7488fc5b70 | Merge branch 'r4/host' into ship/core | ||
|
|
06ccb4d818 |
Ship the Files tab container-side only
Four successive audits found the same thing: host filesystem paths crossing
IPC is where the criticals in this work live. The most recent one found the
`link(2)` upload reservation returning success against a *directory* (linking
into it, leaving permanent stray files, and via a symlink-to-directory writing
outside the validated write root), failing every upload permanently on any
filesystem without hard links, and the post-resolution credential check
weakened from a general rule to an eleven-name denylist.
Rather than fix that a fifth time, the Files tab ships as what it is good at:
a browser, viewer and renamer that never touches the host.
Removed: `upload_file_to_container`, `download_container_file`, and everything
that existed only for them — the whole reservation (`UPLOAD_RESERVATION_SCRIPT`,
`reserve_upload_destination`, the placeholder rollback, `exec_oneshot_as_within`
which had no other caller), `stream_container_file_to_host`, `ChannelReader`,
`save_to_host`, the download ceiling, and the collision marker with its
frontend contract. On the frontend: the upload button, the pane's
`onDragDropEvent` handler, both "Save to host…" affordances, `uploadPaths` /
`downloadFile` / the overwrite prompt, and `OverwriteConfirmModal`.
`lib/uploadErrors.ts` is now `lib/refusalText.ts` and keeps only the half that
turns any backend refusal into the sentence a person reads.
Kept, and not weakened: `upload_host_file_to_terminal` and
`download_container_backup`. They predate this work, their hardening is a real
improvement over main, and they are now the whole answer to "how do I get a
file in or out" — drop it on the Terminal, or Back up container. The drop gate
(`lib/dropTarget.ts`, `PaneVisibility`) is untouched.
`resolve_host_path` gets the general hidden-component rule back. Round 3
replaced it with `HOST_CREDENTIAL_DIRS`, which is allow-by-omission for the
rest of `$HOME`: `~/.local/bin` (write there and you own the user's next shell
command), `~/.password-store`, browser profiles and `~/.pki/nssdb` were all
reachable through a planted symlink with a visible name — verified against a
real home directory, and all five refused now. It over-catches `.pnpm` and
`~/.cache`; for two occasional callers that is the cheaper mistake, and the
refusal says which folder it resolved through.
Two defects fixed while in here:
* A symlinked directory listed as empty. `find` defaults to `-P`, which does
not follow a symlink even as the starting point, so `-mindepth 1` discarded
the only match and a real directory rendered as "Empty directory" — a
first-order defect now that browsing *is* the feature. `-H` follows the
starting point and nothing else, so a loop is `ELOOP` rather than a walk
that does not end; verified against a live container for a symlinked
directory, a broken link and a loop. `find`'s errno for the loop case is
now a sentence.
* `finish_download`'s replace path fired on *any* rename failure with a
destination present — a vanished partial, a permission error, a directory
at the destination — and deleted the user's file to complete a move that
could not complete. It is now fenced to Windows (where a rename onto an
existing path genuinely fails) and to a partial that still exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
9472cb3c4c |
Resolve host paths before mounting them, and stop reading stored data as choice
Four fixes that share a shape: a value already on disk, or one spelled
around a check, being taken at face value.
`/..` bind-mounted the entire host filesystem read-write. `is_filesystem_root`
was purely lexical — trim trailing separators, refuse what was left only if it
was empty or a bare `C:` — and nothing in the file ever called `canonicalize`,
so `/..`, `/./`, `/home/..`, `/etc/../` and `C:\..` all passed. The daemon
resolves them: `docker run -v /..:/mnt/probe` mounts the host root, and the app
mounts read-*write* into a container whose agent has passwordless sudo. It is
the escalation `check_mount_name_stays_under_workspace` exists to close,
reached through the host-path half of the mount instead of the mount-name half.
`classify_mount_source` replaces it and asks the OS: `canonicalize` applies
`..`, follows symlinks, and resolves 8.3 aliases and UNC spellings on Windows.
A path that cannot be resolved — `projects.json` synced from another machine,
a folder not created yet — falls back to a lexical collapse rather than being
refused, because refusing would make such a project unsavable; the gap is
bounded, since what resolution adds is a property of paths that exist. A path
that names no location at all (`C:x`, a relative path) is refused rather than
guessed at. Same check now guards `ssh_key_path` and `ca_cert_path`, whose
read-only mounts were whole-host disclosure at /tmp/.host-ssh.
Custom env var names had no charset check anywhere, so `BASH_FUNC_stat%%` —
bash's wire format for an exported shell function, body in the value — reached
the container environment verbatim. Latent today because the image's /bin/sh is
dash, but the pre-commit scrub runs `/bin/sh -c` as root and nothing pins that.
Keys are now shell identifiers, on the project and the global list both, with
the same grandfathering the folder rows get: a stored key is admitted, a new or
edited one is not.
The blank workspace row was persisted. The comment said it was dropped on save;
the code computed the filtered list and then saved the unfiltered one, so
"+ Add folder" plus a blur stored `{"Target": "/workspace/", "Source": ""}` and
the project could never be started or recreated again. Every save in the
section now goes through one filter, and a blur that changed nothing saves
nothing.
Widening the five `ClaudeCodeSettings` booleans to `Option<bool>` reinterpreted
every stored record. They were plain `bool`s that always serialised, so every
project ever saved carries an explicit `"env_scrub": false` that nobody chose —
and under the new merge that `Some(false)` beats a global `Some(true)`, where
the old rule let the global win. Upgrading silently turned five settings off,
"strip credentials from subprocess environments" among them. Deserialisation
now goes through a shim that dates the record by the presence of the
pre-widening `enable_session_recap` key and reads its `false`s as unset. The
fields skip serialising when unset, so an older binary can still parse
`projects.json` after a downgrade — a `null` would fail to parse and take the
whole list down, since `ProjectsStore` parses all-or-nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
dd23a52b41 |
Fix four upgrade-path defects the coherence audit found
The ~/.claude.json write was `printf ... > "$CLAUDE_JSON"`, which truncates before it writes. A write that fails part-way — a full home volume, which is the exact condition half this release exists to prevent — leaves the file unparseable, and it never self-heals: the next start's jq fails on the corrupt file, MERGED is empty, and the guard skips the write that would have repaired it. That file holds the OAuth account, so the failure mode is a permanently lost login, in service of a cosmetic flag that suppresses a tip. Demonstrated: old pattern loses the credential, new tmp+rename leaves the original intact. The correct pattern was already in triple-c-task-runner. The web terminal scoped its xterm key handler to Claude sessions but not its mobile input bar or its dedicated newline button, so both sent ESC+CR into `bash -l`, where readline has no binding for it. Silent no-op, and worse from a button that stays on screen looking live. Both now consult the active session's type, and the button is disabled with a reason on a shell tab. The Config tab claimed "Off overrides a global On" without qualification. True for the env-var-driven settings, false for TUI mode, Effort level and Focus mode, whose off state is *removing* a key — an older base image's entrypoint ignores the instruction to remove it. The copy now says so and points at the base-image update. HOW-TO-USE.md said there is no add-task form; AutomationTab renders a "New task" button. That file is fetched from GitHub at runtime by help_commands.rs, so the error was live in every user's Help dialog. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
168b61d632 |
Close two cross-file handoffs from the round-3 fixes
The terminal's drop target derived the tar entry name from the host path *after* symlink resolution, so dropping ~/Downloads/latest.log — where latest.log is a symlink — landed the file in the container under the target's name. Nothing errored; the user got a name they never typed. The Files pane had the identical bug and was fixed with `host_upload_name`; the terminal now calls the same helper, so the two drop targets cannot drift. It also drops the "dropped-file" fallback, which silently renamed anything the old `file_name()` could not parse. `migration_store::load` told the user a backup existed when it had deliberately not written one. `load` runs on every reconcile, survey and reaper pass, so a persistently corrupt record reaches MAX_CORRUPT_BACKUPS within seconds; from then on the copy was skipped while the log still read "(a copy was kept at <path>)". Three outcomes are now distinct, and the "enough already" case says so rather than naming a file that is not there — that being the message someone reads immediately before going to look for their data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
47960e46df | Merge branch 'r3/drop' into ship/core | ||
|
|
73dfaf5785 | Merge branch 'r3/auth' into ship/core | ||
|
|
01fd38bc4b | Merge branch 'r3/files' into ship/core | ||
|
|
00128f9b1a |
Make the scrub work off /usr/bin, and stop a log level deciding whether it runs
H3 — the pre-commit scrub was a silent no-op on any base image whose coreutils
are not under /usr/bin. Hardening against a PATH-planted `stat` shim by naming
every tool absolutely bought nothing the `PATH` reset on the first line had not
already bought — the shim lives in the persisted home volume, and uid 1000
cannot write /usr/bin or /bin — and it cost the whole feature on Alpine, which
Settings -> Docker -> Custom accepts. Measured on one seeded tree in a real
container: the absolute-path script printed `###TRIPLE-C-SCRUBBED 0` and left
every planted file in place; the PATH-resolved one reclaims 77824 bytes. The
default image is unchanged at 521038.
It was silent three times over, and all three are fixed:
* The script now probes for all six things it needs (`command -v` for the five
tools, plus the root device id reading back as a number) and, if any is
missing, prints `###TRIPLE-C-SCRUB-UNAVAILABLE <what>` and no total at all.
* `scrub_writable_layer` reads that marker first and returns a new
`ScrubOutcome::Unavailable`, warning with what the image is missing; a
genuine `Reclaimed(0)` now leaves a debug line rather than nothing.
* `commit_log_suffix` renders `Reclaimed(0)` as "ran and found nothing to drop"
rather than "0.00 MB dropped", which is what a scrub that could not run used
to look like.
Verified in real containers: the mount-at-the-match defence still holds with a
home-volume `stat` shim first on PATH (the volume survives; deleting the PATH
reset from the same script empties it, so the harness can tell the difference).
H2 — the pre-migration scrub had been folded into `log::info!`'s argument list
to satisfy `#[must_use]`. `log::info!` expands to `if Info <= max_level() { … }`,
so the awaited scrub lived inside the level check, and `logging::init`
tolerates `dispatch.apply()` failing — which returns before `set_max_level` and
leaves the process at `Off`. In that state the scrub never ran and the layer
was committed into the longest-lived snapshot the app takes. The outcome is
bound first now, `logging::init` restores the level on failure and says so on
stderr, and a test scans all four files for an `.await` inside any `log::*!`
argument list.
Also:
* `reconcile_migration` deferred a held project instead of dropping it. Its
only caller fires once per "Docker became available", so a project held at
that instant was never revisited for the session — phase un-normalised, no
resume or rollback offered, pin left `Claimed`. It now waits for the holder
to let go (20s x 90, one waiter per project) and reconciles then.
* The scrub's byte total counts what a partly failed `rm` removed, by
re-measuring rather than dropping the whole subtree on a non-zero exit —
which was exactly the `--one-file-system` case.
* The scrub exec blanks `LD_PRELOAD`, `LD_AUDIT` and `LD_LIBRARY_PATH`.
`LD_PRELOAD` is in none of the reserved env families, so a project's custom
env var reached a root exec and injected code into every tool the scrub runs,
`PATH` reset or not. Verified against Engine 29.7 that `docker exec -e` wins.
* The device test is described honestly: it is a mount test under `overlay2`
and not under `vfs`, where checks 1 and 2 are what still hold.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
39934299f9 |
Stop the snapshot retry being an unconfirmed revoke, and unbrick legacy configs
Three things, all reachable from a single credential-handling round.
**The "Retry snapshot cleanup" button deleted the token.** `clear_claude_token`
called `secure::delete_claude_oauth_token()` unconditionally; the `mode`
argument only changed the toast wording, so there was no sweep-only path on the
wire. The leftover panel rendered on `leftover !== null` alone and
`onAuthenticated` never cleared it, so the sequence revoke -> snapshot skipped
-> re-authenticate from the button directly above -> press the retry the panel
is still offering threw away the token acquired seconds earlier, announced by a
message about images. The deliberate Revoke needs a confirmation modal; this
needed nothing.
`sweep_claude_token_snapshots` is the honest primitive: it rewrites the images
and never touches the keychain. The images are the durable record, so the retry
re-derives its work from Docker and needs no stored token. Re-authenticating
now clears the panel, and the Authenticate button is disabled while a cleanup
runs — a sweep is a per-image inspect/create/commit/rmi over the Docker socket
and takes minutes.
**Sweep-first left the token live for that whole window.** The keychain delete
sat behind `list_images` plus the per-image loop, at bollard's 120s-per-request
default, while the UI said "Revoking...". A quit or crash in there and nothing
was revoked at all; worse, `has_claude_token` stayed true and
`shared_claude_auth` reads the keychain at container-*create* time, so a
project whose `SecretScrub` guard had already released could be started later
in the same sweep and be handed a fresh copy of the credential in its env.
Keychain-first now, and the comment that claimed "no window in which a scrubbed
image is re-poisoned" — true of images, silent about containers — is corrected.
The reorder's original justification (crash-mid-sweep recoverability) is what
the sweep-only command covers.
A keychain refusal no longer discards a scrub report, because nothing has been
swept yet, and both remedies stay on screen: Revoke, and the image sweep, which
is now offered in every state rather than only when nothing is stored.
**`update_project` validating every folder list bricked existing projects.** It
validated nothing until recently while `WorkspaceSection` saved `{paths}` on
every blur, so `projects.json` can hold a half-filled row, a mount name with a
space, a duplicate, or `/` as a host path. Any such project became entirely
unsavable — every Config toggle, every permission-mode change and
`useTerminal.ts`'s tab rename came back with a message about folders — and
refusing the save did not unmount anything. `validate_project_paths_update`
admits a row carried over verbatim from what is stored and holds a new or
edited row to every rule, which keeps the escalation closed: introducing a bad
value through this command is exactly what a non-carried-over row is. The
`/workspace/../tmp/claude-x` chain is the one exception and runs on every row
regardless, because a stored one is live data loss rather than untidy data.
`ssh_key_path` and `ca_cert_path` had no check at all; a filesystem root there
read-only bind-mounts the whole host at /tmp/.host-ssh. Refused on change, with
the same grandfathering.
Tests are mutation-checked: reverting to sweep-first fails all four new Rust
ordering tests, pointing the retry back at `clear_claude_token` fails five
frontend tests, and validating an update in isolation fails the legacy-data
tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
c6086b0ab3 |
Stop the file panel refusing ordinary paths, and hanging on a FIFO
H6 (HIGH) — `resolve_host_path` canonicalised the host path and then re-ran the *lexical* policy over the answer, hidden-component rule included. Because canonicalisation resolves through symlinks, that rule started judging where a path happens to live rather than where the user pointed: uploading out of a dependency under pnpm (`node_modules/pkg` → `node_modules/.pnpm/…`) was refused, and so was every download into, or upload out of, a visible directory that leads to `~/.local/share`, `~/.cache`, `~/.var/app`, `~/.nvm` or `~/.cargo`. None of it was refused before the H4 fix landed. The two questions are now separate functions. `validate_host_path` judges the string the user chose, unchanged. `validate_resolved_host_path` judges the canonical form for the things only it can answer — the system roots (a Mac's `/etc` *is* `/private/etc`), the login-item directories, and a new `HOST_CREDENTIAL_DIRS` list. That last one is what keeps H4's escape closed: `Downloads/pub` → `~/.ssh` with a leaf of `authorized_keys` is refused because of where it lands, not because of how the directory is spelled. macOS handling is untouched — `/private/tmp` stays out of `HOST_SYSTEM_ROOTS` and `/var/folders` stays in the exceptions. H8 (HIGH) — the upload reservation claimed its destination with `sh -c 'set -C; : > "$0"'`, and the comment claiming that is `O_EXCL` was wrong for a destination that is not a regular file. Against a FIFO the shell opens it and blocks in `open(2)` forever; `exec_oneshot_raw` has no timeout, so `upload_file_to_container` never returned and the Files pane sat on "Uploading…" for the session with the rest of the batch abandoned. Verified in a fresh ubuntu:24.04: the old form times out and the blocked `sh` stays in `ps`; the new form answers in 35 ms. The reservation is now a `link(2)` — it claims a name atomically, never opens anything, and `EEXIST` is immediate whatever is in the way. A staging file at an unguessable name in the same directory is linked into place and unlinked, under a `trap … EXIT`. `exec_oneshot_as_within` adds a wall-clock ceiling as the second line of defence, opt-in per call site so migration's `apt-get` is unaffected. The upload contract is unchanged: default-refuse, `overwrite: Option<bool>`, and `FILE_EXISTS: <full container path> already exists`. Also fixed, all in the same surface: * A dangling symlink destination was a permanent dead end — `set -C` refused, the confirming `test -e` followed the link and said no, and raw shell text came back with no Replace on offer. `link(2)` does not follow the new-path link, and the script confirms with `[ -L ]`, so it reports as a collision. * An upload through a symlink renamed the file: the leaf came off the *resolved* path, so `~/Downloads/latest.log` landed as `2026-08-23.log` and the collision prompt named a file the user never chose. The name now comes from the path the user gave; the resolved path is still what gets opened. * `download_container_backup` leaked its partial file when the descriptor check fired. It now tracks `created` the way `stream_container_file_to_host` already did. * The failed-upload cleanup was `rm -f` on a path that, the reservation having succeeded, held whatever was written in the interim — a host file under `/workspace/…`. It now removes only an empty regular file, and the comment says what it is doing. * `resolve_container_dir` parsed a combined stdout+stderr buffer as a path. It uses the split-stream helper, like the listing next to it. * `verify_opened_path` failed open on a readlink error (`if let Ok(actual)`). A check that cannot see is not a check that saw nothing wrong; the macOS compile-time no-op is now spelled out too. * A trailing slash on a write path resolved to the directory itself. Nine new tests, all mutation-checked against the pre-fix behaviour. Two more cases added to the ignored live-Docker test: a FIFO and a dangling symlink, both timed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
ed91423666 |
Hold back the Disk panel and OS drag-out from the ship branch
This is a scope reduction, not an abandonment. Both subsystems are
preserved in full on `hold/disk-and-dragout` and are intended to come
back once they have been hardened separately. Nothing here is a
judgement that the features are unwanted — three successive
audit-and-fix cycles each closed a critical defect in these two areas
and each opened a new one, so the rest of the round ships now and these
two get their own cycle rather than holding it up.
Removed: the Disk settings panel and its whole reclaim / destroy /
compaction surface — `DiskSettings`, `DiskProjectTable`, `useDiskUsage`,
`docker/disk.rs`, `disk_tests.rs`, the disk commands in
`docker_commands.rs`, and their `generate_handler!` entries. Dropping
the IPC entries is the point: a UI-only removal would have left five
commands callable by a compromised webview, one of them a verified
arbitrary-DELETE primitive. `sweep_orphaned_snapshots`'s *command* goes
with them (the panel was its only caller); the sweep itself stays.
Removed: OS drag-out from the Files tab — `stage_container_file_for_drag`
and its host staging lifecycle, the pointer gesture and `dragPreview`,
`stageForDrag` / `isStagedHostPath`, the `tauri-plugin-drag` and
`@crabnebula/tauri-plugin-drag` dependencies, and the
`drag:allow-start-drag` capability grant, which could not be scoped.
The capability test's expected list is updated; its `*:default` and
`store:*` assertions are untouched.
Kept, deliberately: drag-and-drop *into* the app (Files pane and
terminal) and "Save to host…", which is now the only route out of a
container. The prevention work is untouched — the pre-commit scrub and
`SNAPSHOT_SCRUB_PATHS`, capped container logs, the `triple-c.base` /
`triple-c.managed` labels, `sweep_orphaned_snapshots` and the startup
housekeeping, the migration pin/probe reapers, scheduler log pruning,
`formatBytes.ts`, and `project_lock.rs` in full with every acquisition
site outside `disk.rs`.
Entanglements, resolved rather than deleted blind:
* `container.rs`'s `a_compaction_runs_this_module_s_scrub_script_byte_for_byte`
pinned the compaction Dockerfile against `snapshot_scrub_script()`.
Dropped — it existed only for compaction. `snapshot_scrub_script` and
its containment tests are untouched.
* `lib.rs`'s startup reap of `:compacting` tags and `triple-c-compact-*`
containers is dropped: nothing on this branch creates them.
* `project_lock`'s `Compaction` / `CacheClear` variants and
`any_held_excluding`, `migration_commands::is_migrating`, and
`formatBytes{Delta,Ceiling}` lose their last production caller but are
kept and still tested, annotated with why.
* `projects_store::corrupt_since` and `migration_store::peek_ownerless_since`
were read only by the disk survey and are removed. The corrupt-load
marker and `.bak` are still written.
Verified: `npm run test` 611 passing, `npx tsc --noEmit` clean,
`npm run build` green; `cargo test` 419 passed / 2 ignored,
`cargo build` 0 warnings. Every test removed belongs to a removed
feature — no kept-behaviour test was weakened or deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
6a8972980d |
Stop an untouched secret field from deleting the stored credential
Making `null` actually clear a secret — which it had to, since a blanked
token was previously never revoked — turned the config editors into a
credential shredder. Secrets are `#[serde(skip_serializing)]`, so the
inputs always render empty whether or not one is stored, and the blur
handlers sent `value || null` unconditionally. Focusing the Git token
field and tabbing away deleted it, with nothing shown and no undo.
`useSecretField` encodes the rule: only a field the user typed in may
speak about a secret. Untouched, `patch()` contributes no key at all, and
Rust already distinguishes an absent key from an explicit null.
`withoutUntouchedSecrets` covers the structural half. `saveBedrock`
spreads `{ ...bedrock, ...patch }`, and when that falls back to
DEFAULT_BEDROCK_CONFIG the literal spells every secret out as `null` — so
editing the AWS region would have wiped the credentials as a side effect.
Also here: `WorkspaceSection` no longer saves a half-filled folder row,
which `update_project`'s new validation would refuse on every keystroke
between the two inputs; `snapshots_skipped` is declared on the wire type
rather than widened locally; and `#[must_use]` on `ProjectGuard` and
`ScrubOutcome` — which immediately caught the migration path discarding
its scrub outcome, the one scrub whose silence is expensive because the
layer it declined to clean is about to be committed.
The capability test reads the real file and is mutation-verified: adding
`core:default` back makes it fail. That grant pulls in an unscoped
`std::fs::read` of any host path and went unnoticed for months, because
nothing in the suite read the file at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
7bbb699e4e | Merge branch 'r2/front' into integration/round-1 | ||
|
|
5df3e7996d | Merge branch 'r2/files' into integration/round-1 | ||
|
|
5d4d5d37df | Merge branch 'r2/disk' into integration/round-1 | ||
|
|
bb1c7696f9 | Merge branch 'r2/scrub' into integration/round-1 | ||
|
|
f2a84c18f9 |
Judge a host path by where it leads, not by how it is spelled
`validate_host_path` was a string test. Nothing in the module called
`canonicalize`, `read_link` or `O_NOFOLLOW`, so a path whose components are
all visible could still land somewhere hidden: with `~/Downloads/pub` a
symlink to `~/.ssh`, a `host_path` of `~/Downloads/pub/authorized_keys` has no
hidden component, no `..` and no system root — and writes into `~/.ssh`. The
container end is not hypothetical: `/proc/self/mountinfo` inside a Triple-C
container spells the host's project paths out verbatim, so code in there knows
both where to plant the link and what host path to ask for. The same bypass
read host files back the other way.
So the policy now runs twice: once on the string, and once on what the OS says
the string resolves to. A write resolves the parent and keeps the caller's
leaf, because the leaf is never followed — the partial file is created with
`O_EXCL` and the download finishes with a rename, which replaces a link rather
than writing through it. A read resolves the whole path, because the whole
path is opened. On Linux the descriptor is then checked against the path that
was validated (`/proc/self/fd`), which is what closes the window between
resolving and opening; elsewhere that window stays open and the comment says so.
Also here:
* The upload's overwrite guard is a guard again. `noOverwriteDirNonDir`
refuses only dir-over-non-dir and the reverse — file-over-file extraction
proceeds, which is exactly the `.credentials.json` case (verified against a
live daemon). The probe and the write are now one `set -C` exclusive
create, with the path travelling as `$0` rather than as script. The
`FILE_EXISTS: <path> already exists` contract with the frontend is
unchanged, and now pinned by a test — as is the claim the old comment made.
* Windows normalisation stopped being a string swap: `\\?\`, `\\?\UNC\` and
administrative shares all reach the same places and are compared as such,
and the rules are pure functions over a string, so the Windows entries are
exercised on any platform. The old test passed on Linux only because
`Path::is_absolute` was false for a Windows path.
* Container write roots are resolved inside the container too, and the
comment no longer claims more than the check does.
* A failed download can no longer delete a pre-existing file that happened to
collide with the partial's name.
* One-shot exec output is buffered as bytes and decoded once, so a filename
split across two Docker frames survives; stdout and stderr are tellable
apart, so `find`'s diagnostics stay out of the listing parser; and a
directory too big to buffer is described as one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
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
|
||
|
|
e70a40507c |
Stop granting an unscoped host-file read, and make a refused credential scrub recoverable
`core:default` was an alias for nine core plugins' default sets, and one of them — `core:image:default` — carries `allow-from-path`, whose handler is a bare `std::fs::read(path)` with no scope mechanism at all. Nothing imports `@tauri-apps/api/image`, so the plugin is dropped rather than scoped; there is nothing to scope it with. The capability file now enumerates what `app/src` actually invokes, which is `core:event`'s listen/unlisten and nothing else from core — every emit in this app originates in Rust. `core:menu`, `core:tray`, `core:window`, `core:path`, `core:resources` and the three dead `dialog:` grants go with it. `core:webview:allow-internal-toggle-devtools` stays because Tauri's own injected debug script calls it; both it and the command behind it are `cfg(any(debug_assertions, feature = "devtools"))`, so it is absent from a release bundle. Verified empirically: an unknown identifier fails the build, so every identifier kept is real and the regenerated `gen/schemas/capabilities.json` carries the opener scope verbatim rather than silently dropping it. `opener:allow-open-url` cannot be host-narrowed — the terminal opens links Claude printed inside the container — so what it does and does not buy is recorded instead, including the verified fact that each scope entry's `app` defaults to `Application::Default`, which matches only `with == None` and therefore refuses `openUrl(url, "/bin/sh")`. `clear_claude_token` deleted the keychain entry first and swept the snapshot images second. The sweep runs once and skips a project another operation holds, the deleted entry made `has_claude_token` false, and Revoke rendered only while a token was stored — so a project that happened to be starting during a revoke kept a live ~1-year OAuth token in its snapshot's `Config.Env` permanently, with Reset (which destroys both volumes) as the only remaining remedy. The sweep now runs first, so a crash mid-revoke leaves the app still saying "authenticated" with the same button still able to finish; a busy project is reported as `snapshots_skipped` rather than folded in with images that genuinely cannot be rewritten; and the panel keeps a retry visible independent of token status, plus offers the sweep outright when nothing is stored, because a snapshot committed by an older build carries the token either way. The retry is the same command — it is idempotent, and the images are the durable record. Also: `openai-compatible-api-key` was written but never deleted, so it outlived its project. The key list is now the single definition and an unlisted key is refused outright, so the writer cannot get ahead of the deleter again. `store_or_clear_project_secret` lands here unused on purpose: the editors send a blanked field as `null` and `store_secrets_for_project` skips `None`, so clearing a secret through the UI is impossible today. Its one call site is in `commands/project_commands.rs`, which belongs to another change in this round. No `devCsp` was added. `tauri dev` loads the main document straight from Vite, and Tauri only attaches a CSP to documents it serves itself — the dev server is proxied through `tauri://` only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))`. A `devCsp` here would be inert config that reads as protection. The reasoning, and the one place that could set one, are recorded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
5926a52ff6 |
Stop the terminal and Files panes refusing drops onto their own chrome
The z-order gate added last round asked `el.contains(elementFromPoint(x, y))` — "is the thing painted here mine?" — and was handed `TerminalView`'s inner xterm host while every overlay in that pane is a *sibling* of it. So any point under the pane's own chrome answered "not mine" and the drop was refused, with no message and no log line. The "▼ Following / ▽ Paused" toggle is rendered unconditionally at `absolute top-2 right-4`, and `ToastHost` is `fixed bottom-4 right-4` 24rem wide with error cards that never time out: two corners of the terminal, and one of the Files pane, that could not accept a file for as long as the app was running. It shipped green because jsdom has no `elementFromPoint`, so not one of the 81 drop tests entered that branch. The tests here install one. The question the gate asks is now "is a *blocking overlay* painted here?". Chrome the pane paints over itself is not one; a dialog backdrop is, and `ui/Modal` marks its own backdrop so the element `elementFromPoint` actually returns is the one carrying the marker. `classifyDrop` also separates "aimed at me and swallowed" from "not my drop", so the first gets a toast and a log line and the second stays silent. Three defects around it: - **A dialog now refuses only the points it covers.** `dropIsBlocked` is document-wide and `ui/Modal` portals to `document.body`, so any open dialog refused every drop in the window. The deeper half of that is that a dialog opened in project A really was still on screen after a tab switch — the pane hides itself with a `hidden` class, which a portal does not inherit — so `PaneVisibility` lets `App` tell a `Modal` its pane stepped aside, and a hidden one paints nothing, traps no focus, answers no Escape and blocks no drop while staying mounted with its state intact. - **`devicePixelRatio` is applied on Windows only.** Only wry's WebView2 backend hands over physical pixels; the macOS and GTK ones deliver logical points and `tauri-runtime-wry` does not rescale them. Halving those was survivable while the test was a bare rect and is a refused drop once z-order joins in. Read from the wry/tauri sources, not verified on a HiDPI Mac or GTK box. - **`isFileExistsError` can no longer be forged by a filename.** It matched `fileexists` anywhere in a normalised error, so uploading a host file called `file-exists.txt` turned *any* failure into a collision — and Replace re-invoked the upload with `overwrite: true`. The marker now has to stand alone in the backend's canonical form, or be a whole discriminant value. - **A refused compaction or cache-clear keeps its dialog.** `reclaim` reports refusals inside `Ok`, so "did it throw" read one as success: the dialog closed, the tick list was dropped, and the explanation appeared in the outcome panel several screens above the row that was clicked. The dialog now stays put and renders the backend's own sentence verbatim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
42ef1865cc |
Remove a stray .deb committed by an over-broad git add -A
An audit agent downloaded findutils to check GNU find's operand parsing;
|
||
|
|
4f6c012071 |
Make a rollback pin outliving its project visible and deletable
`survey_rollback_pins` walks images, not projects, and deliberately tolerates an absent project by falling back to the raw id as the display name. Two things then dropped it on the floor: `destroy` called `find_project` before the confirmation check, so it refused such a pin every time, and the per-project table joins destructive items to rows by project_id, where rows come only from projects in the store. The result was a multi-GB `pre-migration-*` image that the scan measured, the panel never rendered, and nothing could remove — in the one screen built to find exactly that. `destroy` takes the same early return `OrphanVolume` already takes, and still validates the tag: `latest` names the project's live snapshot, so the ownerless path must not be a way around that check. The UI grows a bucket for destructive items matching no row, rather than filtering them away. The typed gate already compared against the id via `project_name`; the dialog now says "project id" instead of asking for a project name that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
6b8d43414d | Merge branch 'fix/reconcile' into integration/round-1 | ||
|
|
1768240861 |
Unstick a project row whose lifecycle command was refused
Two more pieces of drift from the same merges, both invisible to `tsc`. **A refused Start/Stop/Reset strands the row.** `start`, `stop` and `rebuild` paint an optimistic "starting"/"stopping" so a click moves the row at once. That was safe while the only way these could fail was after the backend had begun changing things. `fix/sec`'s per-project lock ended that: all three now take the lock and are refused *before* any state changes, and `stop` could not fail this way at all before — it took no exclusion. So the optimistic paint has nothing to become, `isTransitioning` disables both Start and Stop, and the only thing that clears it is `reconcileProjectStatuses`, which runs once from `App.tsx` when Docker first appears. Clicking Stop during a compaction left the project unusable until the app was restarted. `withOptimisticStatus` re-reads the authoritative list when the command throws, and falls back to the status that was on screen if even that call fails — two failures in a row must not land on the one state there is no way out of. The error is rethrown unchanged, so the toast is unaffected. Five of the six new tests fail against the previous code; the sixth pins that the optimistic paint still happens on the way in. **Six secrets are typed as if they arrive, and they never do.** `git_token`, the four Bedrock credentials and `OpenAiCompatibleConfig .api_key` are `#[serde(skip_serializing)]` in Rust, so the key is absent from every project the backend returns — reading one gives `undefined`, not the `null` the type promised. Every current reader happens to use `?? ""`, so nothing is broken today; a single `=== null` would have been a branch that silently never ran. They are optional now, which makes that a compile error, and documented as write-only, which is what they are. 669 frontend tests pass, `tsc --noEmit` clean, `npm run build` green. Nothing under `src-tauri/` touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
7e1f8df1ff |
Reconcile the frontend with the round-1 backend contracts
Five backend branches merged and the TypeScript still compiled, because
none of this is a type error: a field that arrives `undefined`, a variant
nothing emits any more, a prompt whose loop never closes. Six things.
**Orphaned volumes are destructive now, not safe.** `ReclaimTarget::
OrphanVolume` is gone from Rust; the object is a `DestructiveTarget::
OrphanVolume { name, project_id }` confirmed against the *volume's* name,
there being no project to name. The TS union still listed it under
`ReclaimTarget`, and — worse — `DiskProjectTable` keys destructive items
off `project_id`, which an orphan's never matches. So the item existed in
the plan and appeared nowhere on screen. `DiskSettings` now splits the
plan's destructive list and gives orphans their own section with a
per-volume `TypedConfirmModal`. The copy says what a
`triple-c-claude-config-*` volume actually is — a Claude login
credential, every plugin and skill, every transcript that project had —
and keeps the sentence explaining that "no matching project" is a lookup
against the project list and is never inferred from a project being
stopped or having no image, which is the inference that once flagged two
live projects.
`TypedConfirmModal` grew a `subject` prop: asking a user for "the exact
project name" of a volume that has no project is asking for a string that
does not exist.
**Snapshot and Total reconcile.** `ProjectDiskRow.snapshot_attributed_bytes`
is the single figure `snapshot_attribution()` exists to produce. The
column rendered `snapshot_above_base_bytes` and fell back to `—` while
the Total was `size - shared` regardless — and in that branch `size -
shared` is the whole 4.7 GB base image, charged per project and then
added again as a base-image row. One field, one rule. The one branch
where the figure *is* the whole image says so rather than passing itself
off as a share.
**The overwrite loop closes.** Traced end to end: a `FILE_EXISTS:`
refusal raises the prompt, Replace re-invokes with `overwrite: true`,
Skip advances, "…all" answers the rest without asking, and picker and
host-drop both reach `uploadFileToContainer` through `uploadPaths`. Two
gaps: a second batch's `askOverwrite` overwrote the first's resolver,
leaving that batch awaiting an answer no dialog could produce; and the
backend's written refusals — a hidden host folder, a path outside the
write roots — were passed as a toast `detail`, which `ToastHost` renders
as collapsed monospace behind a "Details" button, so the only sentence
that explained anything was the part nobody saw. `readableRefusal`
promotes it to the headline when a batch failed the same way.
**The browser pane's sandbox is pinned.** `allow-same-origin` must stay
(the proxy's gate reads `Origin`/`Referer`, and an opaque origin sends
`null`); every top-navigation grant and `allow-popups-to-escape-sandbox`
must stay absent, and the test names the offending token rather than
printing a set diff.
**`@tauri-apps/plugin-store` is gone** from `package.json` — its
capability grants were removed as a host-file-write primitive and nothing
in `app/src` imports it. The lockfile was updated with
`--package-lock-only`, deliberately: `node_modules` is a symlink shared
with other worktrees and a real install would have pulled it out from
under them.
Nothing under `src-tauri/` is touched. 663 frontend tests pass (was 635),
`tsc --noEmit` clean, `npm run build` green, `cargo test` 446 unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
a76f2c0a17 |
Close the cross-stream gaps the parallel fix round left open
Three items each of which fell between two agents' file lists.
`scrub_secrets_from_snapshots` was the third unsynchronised writer of
`triple-c-snapshot-{id}:latest`, after a recreate's commit and a
compaction. It has the same read-modify-write shape — create a scratch
container from the snapshot, commit back over the same tag — and loses
the same race, which here means re-baking the very credential it exists
to remove. It now takes the project's claim, under a new
`ProjectOp::SecretScrub`, and reports a snapshot it had to skip rather
than rewriting it unsafely.
Its scratch container also now carries `triple-c.scrub=true`, so the
Disk panel's reclaim bucket discriminates by label and by the live claim
rather than by a clock. The 15-minute age gate stays as the backstop for
the cross-process case the claim cannot see.
The store plugin is unregistered and its dependency dropped. Its
capability grants were removed as a host-file-write primitive; the
registration without a grant was unreachable but dead.
Finally, container.rs's fold test was pinning a fold that no longer
exists — disk.rs now emits the JSON exec form. It asserts the stronger
property instead: the script a compaction runs is byte-for-byte the one
snapshot_scrub_script() produces, so the two files cannot drift silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
17f031a5d7 | Merge branch 'fix/disk' into integration/round-1 | ||
|
|
5fba7d6d35 | Merge branch 'fix/front' into integration/round-1 | ||
|
|
433afa5a49 | Merge branch 'fix/sec' into integration/round-1 | ||
|
|
fcea506dce | Merge branch 'fix/files' into integration/round-1 | ||
|
|
6abc7f27a4 |
Fix disk/migration defects and add a real per-project lock
The compaction panel's headline action had never worked, three reclaim paths could delete data with no confirmation and no grace period, and the app's only mutual-exclusion primitive was one-way. **A per-project lock (`project_lock.rs`).** `ACTIVE_MIGRATIONS` was the app's only exclusion and everything but migration merely *polled* it once at entry. Compaction, start/stop/recreate, Reset and destroy now **acquire** a `ProjectGuard` and hold it for the whole operation; `is_migrating` is a view onto the same registry. Closes the three verified interleavings where a compaction commits `flat(A)` over a `:latest` that a migration, a recreate or a Reset had already moved. In-process only — the two-instance case is documented in the module, not solved, and the daemon-wide reapers gained age gates to bound it. **H1: compaction never ran.** `fold_shell_script` joined the scrub script's lines with a space, so every build died on `syntax error: unexpected "do"`. Replaced with the JSON exec form, which carries any script verbatim; `sh -n` and a real end-to-end build now cover it (159.5 MB / 9 layers -> 33.7 MB / 1 layer, setuid and multi-line env preserved). **H2/H4:** `reclaim_migration_pins` and `survey_rollback_pins` apply `parse_rollback_tag` and `pin_is_reapable` like every other path, and stop double-counting an image with two pin tags. The 14-day grace period is re-anchored from the tag's timestamp (when the migration *started*) to a tombstone recording when the record went missing, with clock skew handled in both directions. **H3:** `migration_store::load` no longer renames a corrupt record aside — that destroyed the `has_record` signal both pin reapers depend on. `save` fsyncs the file and the directory, and corruption backups are timestamped. **H2b/M2:** a crashed compaction's `:compacting` tag and `triple-c-compact-*` container are reaped at startup; the stale-container sweep moved from the end of a compaction to the start, where its doc always claimed it was. **M5/M6:** orphan-volume deletion moved from a `Safety::Safe` tick to the destructive path with a typed volume name; `project_store_trust` reads the real `projects.json` so a second instance's project is not offered as an orphan. Numbers: `images_total_bytes` uses `df()`'s deduplicated `layers_size`; the Total column is derived from the same figure the Snapshot column shows; partial container/staging reclaims report their failure count; `human()` no longer prints "1000.0 KB"; `docker_cli` has a timeout; blocking `fs` calls moved to `spawn_blocking`. Also fixes `ProbeContainerGuard::remove_now`, which disarmed before the await and so did nothing on the cancellation path it exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
2b6501d8e5 |
Give the exit-status poll room now that it fails closed
`wait_for_exec_exit` returning `None` used to mean "call it 0"; it now fails the call, so a busy daemon that has not settled within ~1s would turn into a spurious "the rename failed". The loop exits on the first poll that reports finished, so a wider window costs nothing when things are normal. 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 |
||
|
|
092972fe92 |
security: close capability, CSP and auth-bridge holes
capabilities/default.json - Drop every `store:*` grant. `@tauri-apps/plugin-store` has no caller in `app/src`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData — `push` discards the base for an absolute path, so the grant was an arbitrary host read/write from the webview. - Replace `opener:default` with a scoped `opener:allow-open-url` (http/https only). That drops `reveal_item_in_dir`, which the plugin does not scope-check and nothing here calls, and the unused mailto:/tel: scope. - Record the unscopable `drag:allow-start-drag` residual risk in `description`. tauri.conf.json - Add `form-action 'none'`, `base-uri 'none'`, `object-src 'none'`. `form-action` has no `default-src` fallback, so an injected auto-submitting form was unblocked even though `script-src 'self'` blocks XSS. - Remove the dead `asset:` / `https://asset.localhost` img-src and `data:` font-src grants; `blob:` stays (the file viewer uses it). auth_bridge - The reserved-port set covered only this project's mappings and the two browser-view ranges. It now also covers the gateway, STT and web-terminal host ports (configured value and shipped default, read off the settings models) and every other project's published host port. A container binding container-loopback 4000 / 9876 / 7681 while those services were stopped had that port mirrored onto the host, unauthenticated, within one poll. - Gate the host listener on fetch metadata: refuse a request that is a cross-site sub-resource, allow navigations (the OAuth redirect) and anything without `Sec-Fetch-*`. Non-HTTP connections are classified from their first line and forwarded verbatim. Residual risk is spelled out in the module docs. - Bound the forwards: max concurrent connections per port, a first-byte deadline enforced before any `docker exec` is created, and an idle timeout. browser_view/mod.rs - `pick_viewer_port` reads procfs with `/usr/bin/cat`, not a bare `cat` the container can shim via its writable PATH entry. - Treat port choice as check-then-bind: walk to the next free candidate when the viewer does not come up, instead of failing the start. BrowserTab.tsx - Sandbox the viewer iframe. Container-controlled content could `top.location` the app's webview away. `allow-top-navigation*` and `allow-popups-to-escape-sandbox` are deliberately absent. HelpDialog.tsx - Escape the quote characters in the entity pass and escape captured attribute values. `href="$2"` with `$2` = `[^)]+` let remote GitHub markdown close the attribute and open another, in a document rendered with `dangerouslySetInnerHTML`. web_terminal/terminal.html - SRI hashes plus `crossorigin` on the three jsdelivr bundles and the stylesheet, and a CSP for the page — it is served 0.0.0.0 behind a permissive CORS layer and nothing else gives it one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
d6f065a2b6 |
Fix HIGH and MEDIUM frontend defects
Files pane
- F16: a drag-out released back inside the app no longer re-imports its own
staged copy over the container original. An in-flight flag (cleared from the
drag plugin's `onEvent` channel, with a watchdog) suppresses the drop and the
"Drop files into …" hint, and an exact staged-path filter is the second line
of defence — the `path|size|modified` cache could otherwise write a
minutes-old snapshot over a file an agent had since rewritten.
- F17: a slow upload/rename no longer yanks the user back to the directory the
operation started in. Every operation captures its target path and re-lists
only if the user is still there; failures go to the toast host either way.
- The grid keeps keyboard focus. Roving tabindex (one tab stop, not one per
row) plus focus restore after navigation, rename commit/cancel and Escape.
- Transient failures now surface in `ToastHost` (z-[60], persistent aria-live)
instead of a `role="alert"` 300 rows down a scroller or behind a modal
overlay. The inline error is kept only for the listing failure.
- `navigate` is sequenced by generation; "Save to host…" sets `busy`.
- Grid a11y: column headers, a text affordance for folder vs file, a live
region that is mounted empty and announces completion, Label-in-Name fixed.
- FileViewerModal: the blob URL is released only once its replacement exists;
the preview is a focusable, named, scrollable region.
Native drop routing
- New `lib/dropTarget.ts`: the hit test now refuses a drop while any
`[aria-modal="true"]` dialog or `[data-blocks-drop]` overlay is up, and
checks z-order where the environment can answer it. Shared by FilesTab and
TerminalView; App's shutdown overlay opts in.
Disk
- A partially failed reclaim says so in words ("… — 2 of 5 failed"), not by hue
alone.
- The scan/reclaim race is closed: every mutation retires an in-flight scan, so
a scan can no longer repaint a pre-reclaim report plus a clickable plan of
objects that are gone. Scan is disabled while working; the status is a live
region; a failed destructive action keeps its dialog open and reports there.
- The "unknown" layer count gets a screen-reader fallback; `--text-disabled`
no longer carries live information.
Terminal / OAuth
- After the toast is dismissed, a truncated heuristic guess can no longer fill
the slot that an exact OSC 8 or relay URL occupied — the detector remembers
every exact URL and drops any candidate that is a strict prefix of one.
- The prompt is reachable by keyboard: Ctrl+Shift+O jumps to the default
action, Escape dismisses, focus returns to the terminal, and auto-dismiss
holds off while focus is inside. It deliberately does not steal focus.
- UrlToast renders through `ui/Button` and `--shadow-overlay`.
Elsewhere
- AuthBridgeRow: a pushed `auth-bridge-changed` status always outranks an older
awaited toggle result.
- The last two ad-hoc byte formatters route through `lib/formatBytes`.
Contract for the backend agent: `upload_file_to_container` refusing to
overwrite must satisfy `isFileExistsError` in `src/lib/uploadErrors.ts` (marker
`FILE_EXISTS`) and accept an `overwrite` argument; the frontend turns that into
an `ui/Modal` Replace/Skip prompt rather than a raw error string.
Tests: 536 -> 627 passing. `npm run build` and `npx tsc --noEmit` green.
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 | ||
|
|
2b9bf56f25 | Merge branch 'feat/drag-out' into integration/round-1 | ||
|
|
611f67cca7 |
Fix what review found in the Disk section
Safety: - `destroy`'s rollback-pin arm took a tag over IPC and interpolated it straight into an image reference it then removed. `tag: "latest"` named the project's live snapshot, deleted under a dialog saying "rollback pin". It is the one destructive variant carrying a free-form string, so it now goes through `parse_rollback_tag`. - The compaction's scratch container was named `triple-c-scrub-*`, which is what the scrub reclaim bucket hunts and force-removes. A reclaim from a second window would have destroyed the container a running compaction was about to commit. It gets `triple-c-compact-*`, swept at the start of the next compaction rather than from a bucket anything else can fire. - Deleting a home or config volume only refused a *running* container, but a stopped one still pins its volumes — the resting state of every project ever started — so the user typed the project name and met a raw 409. The container is now removed first and `loses` says so. Correctness: - The compaction Dockerfile emitted no `LABEL`, so the flattened intermediate could never match the sweep's `dangling` + `triple-c.managed` filter that three cleanup paths rely on. Verified on Docker 29.7.2 that the label lands on the final stage, the build still yields one layer, and untagging the staging tag after the commit leaves the committed snapshot intact and startable. - `snapshot_commit_layers` silently meant something else when `triple-c.base-image-id` was absent — the normal case for a pre-label project — counting the base's own layers and letting a never-recreated project qualify for compaction. `base_lineage_known` now carries that, the column says "unknown", and the plan does not offer the rewrite. - `destroy` returned a `ReclaimResult` wearing a `ReclaimTarget` that named work it had not done (a home-volume deletion came back as `OrphanVolume`). Split into `target` / `destroyed`, exactly one set. - `formatBytes` ran `toFixed` after the divide loop, so 999,999 rendered as "1000.0 KB" — in the app's only byte formatter, in a panel full of near-boundary sizes. - `is_base_image_reference` split on the first colon, so a registry port ate the repo name. UI: - `snapshot_above_base_bytes: null` — deliberately unmeasurable — rendered as "0 B", the one guessed number in the table. - Layer count was flagged by colour alone; it now says "stacked". - The tick list survived a reclaim, so the same call could be re-fired at objects that no longer existed. The plan is dropped after any action and the panel says the totals predate it. - `setReport` landed before the plan call was awaited, so a plan failure rendered fresh totals above the previous scan's rows. - Both confirmation modals unmounted before awaiting, making the entire busy path dead code during multi-second work. - `buildx du` failures silently showed `docker system df`'s under-reported build-cache figure with no explanation. - Tooltip text reached no assistive tech, so two headers announced as "Help"; hardcoded input id; error-toned glyph in warning-toned panels; `sweepOrphanedSnapshots` and `clearOutcome` had no callers. - Four docstrings claimed things the code did not do, and two tests were named for behaviour they did not assert. Tests: 513 frontend (was 502), 370 Rust (was 365). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc |
||
|
|
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
|
||
|
|
0a022dfcf0 |
Let a project turn a globally-enabled Claude Code setting back off
The six boolean settings were plain `bool`s merged with
`if p.x { true } else { g.x }`, so a project could only ever add to the
global set. There was no project value that produced `false` — turning a
switch off at project level simply fell through to the global value and
the control did nothing.
Widen them to `Option<bool>`. `None` means "not set at this level":
inherit the global on a project, leave Claude Code's own default alone
globally. `Some(false)` is a deliberate off and wins outright.
The fingerprint now formats with `{:?}` rather than `{}` — `None` and
`Some(false)` mean different things, and conflating them would leave the
container un-recreated when a project switched from inherit to off.
The project editor grows a third "Global" state per switch; the global
editor has nothing to inherit from, so it stays a plain toggle and keeps
collapsing to null at the default. Its three existing tests passed
unchanged and caught a first attempt that rendered unset as off, which
would have told every user their session recap was disabled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
|
||
|
|
bb41275cea |
Keep the managed settings payload safe for un-migrated projects
The null-means-delete convention is only understood by the entrypoint.sh shipped alongside this code. An existing project recreates from its own snapshot image, which carries whatever entrypoint it was built with, and an older one merges with a plain `.[0] * .[1]` — so the literal nulls would land in the user's settings.json rather than clearing the keys. Verified against jq: that produces `"tui": null, "effortLevel": null, "viewMode": null, "awaySummaryEnabled": null`, risking the whole file being rejected and taking the user's own `model` and `statusLine` with it. Split the payload instead. CLAUDE_CODE_SETTINGS_JSON now carries only keys that have a value and is safe under either merge; the new CLAUDE_CODE_SETTINGS_CLEAR carries the key names to delete and is ignored by an entrypoint that predates it. Such a project keeps the old sticky behaviour until it is migrated or Reset, which is the pre-existing state rather than a regression. 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 | ||
|
|
dacc1157ec | Merge branch 'feat/disk-and-settings' 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
|
||
|
|
22d142c70d |
Shift+Enter newline, OAuth URL truncation, and the auth bridge toggle
Three fixes that all land on the same journey: sign in, paste a prompt, and have the terminal behave the way every other Claude Code host does. Shift+Enter inserts a newline ----------------------------- xterm.js does not consult `shiftKey` for Enter (`Keyboard.ts`, case 13), so Shift+Enter was byte-identical to Enter and submitted the prompt. Both terminals now send `\x1b\r` (ESC+CR) instead, which Claude Code parses as return+meta — the same bytes its own `/terminal-setup` writes into the VS Code, Cursor, Alacritty and Zed keymaps, so this is in-band rather than a guess. Not `\n`: Claude Code accepts it, but a shell would run the line, so the two session types would diverge. Bound in Claude sessions only for that reason. `entrypoint.sh` sets `shiftEnterKeyBindingInstalled` in `~/.claude.json` so the CLI stops printing its "run /terminal-setup" tip. Purely cosmetic — the decoding is unconditional either way. Alt+Enter has always done the same thing (xterm ESC-prefixes on altKey) and was simply never documented. It is now, along with the rest. OAuth login URL truncation -------------------------- Two producers wrote one toast slot, last-writer-wins. The OSC 7777 relay delivers the URL base64-encoded and therefore exact; ~300 ms later the screen-scraper's debounce fired and overwrote it with a truncated guess at the same link — a URL that parses, points at the right host, and authorises nothing. The user is the one who has to notice. Why the scraper truncated: `ANSI_RE` strips OSC sequences wholesale, including the OSC 8 hyperlink whose parameter carries the complete URL. Claude Code slices the *visible* text of that hyperlink to the terminal width while every emission carries the whole URL in its parameter. The backend already knew this (`commands/auth_token_commands.rs`); the frontend did not. - `urlDetector` now reads OSC 8 targets out of the raw buffer before stripping, filtered by a port of `usable_sign_in_link`, and tags every candidate with its provenance. - The prompt slot gained `supersedes`: better provenance always wins, worse never does, and between equals only a candidate that *extends* what is showing may replace it. That last rule is `extendsUrl`, factored out of `pickSignInUrl` rather than copied — same rule, same reason, one implementation. - `flatten` splits on a bare `\r` as well as on `\r?\n`, so a `\r`-repainted TUI frame no longer inflates a line past the width and suppresses a join that should have happened; and the width is now sampled at `feed()` rather than read at `scan()`, so a resize inside the 300 ms debounce cannot reassemble 80-column text against a 120-column rule. Also corrects the comment claiming `acquire_claude_token` enables the auth bridge. It deliberately does not, and the module comment in `auth_token_commands.rs` explains at length why not. The auth bridge toggle ---------------------- `setAuthBridgeEnabled` and `getAuthBridgeStatus` had zero call sites: the Rust was complete, the IPC wrapper shipped, and there was nowhere to click — so the docs told users to "enable the Auth Bridge" for a switch that did not exist. `AuthBridgeRow` is that switch, in Config → Runtime. It deliberately does not go through the tab's stopped-only save: the dedicated command exists so the bridge can be flipped while a login is hanging in a running container, which is the only moment anyone reaches for it. It also subscribes to `auth-bridge-changed`, which the poller has been emitting to nobody — so a host port the bridge could not take was a completely silent failure, indistinguishable from a login that hung. `tunnel.rs` promotes the best-effort `::1` bind failure from debug to a warning recorded on the port. Half-bound is the failure mode that looks like success: the status says bridged, and a client that resolves `localhost` to `::1` without falling back is still refused. Finally, for a recognised Anthropic sign-in URL the toast now leads with "In container" and demotes the host "Open". The callback listener is inside the container, so the container-side browser closes the loop with no host round trip and no auth bridge; the host button stays as the fallback. Ordinary URLs are unchanged. Tests: 402 frontend (was 359), 285 Rust (unchanged). 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 |
||
|
|
48d0c3249a |
Fix two bugs in last round's fixes, and stop --full hiding the Docker host
Round 3 found defects in code written an hour earlier. Both reproduced.
**The handshake poll accepted empty output as a completed handshake.**
`[ "$(… | awk '{print $2}')" != 0 ]` is *true* when `wg show` prints nothing —
which it does when the interface has no peer, and when the interface is gone
(that message goes to stderr). `until` suspends `set -e` and `pipefail`, so
nothing else caught it. The poll added last round to make "success without a
tunnel" impossible produced exactly that. Now requires a number greater than
zero, and waits 20s rather than 10 so a slow link is not rolled back needlessly.
**`down` still sat above the key registration.** Last round moved it below the
token and server-list fetches but not below `addKey`, which is the most
failure-prone of the three — one gateway, by CN, pinned certificate. So a
refused registration still tore down a working tunnel. It now runs after the
last fetch; the key is generated before but written after, since `down` deletes
it. SKILL.md said "after every network fetch has succeeded", which was false;
corrected.
**`up --full` made `host.docker.internal` unresolvable — and `status` said DNS
was fine.** That name is answered only by the resolver being replaced; it is not
in `/etc/hosts`. `gateway.rs` hands it to every container for the LiteLLM
gateway, and Ollama and custom endpoints default to it, so an agent running
`up --full` silently removed the project's model backend. The route was already
excluded; only the name was lost. Now resolved with the old resolver and pinned
into `/etc/hosts` before the swap, restored on teardown, and `status` probes it
— PIA answers public names happily, which is precisely why probing only
`api.anthropic.com` reported "ok". Documented as Trap 4.
**The rollback could abort halfway.** The trap's `{ … }` is not exempt from
`set -e`, and `down`'s `cat`/`tac`/`rm` had no `|| true` — so one failure left
the interface up with all traffic captured, after printing "rolling back".
`down` now runs under `set +e`, the trap tolerates its failure, and the
interface is deleted *first*, since that removes every route pointing at it.
**The account password had a real argv window.** curl does blank `-u`, but only
once running: sampling /proc/<pid>/cmdline caught the plaintext in 2 of 400
tries, between exec and the overwrite. Small, but it is the permanent password
and the token already had the fix. Moved onto the same stdin config — 0 of 400.
Review reported this as a 25-second exposure; that was a wrapper's argv, not
curl's.
entrypoint: the skill install stages into `$_dest.new` and swaps, so a failed
copy leaves the previous copy intact instead of a truncated SKILL.md and no
script, root-owned, on a persisted volume. Verified against a size-limited
filesystem.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
5b96ad4823 |
Stop a failed up from tearing down a working tunnel
The previous commit moved `down` to the top of `up` to fix a resolv.conf idempotency bug, and in doing so put it *before* every network fetch that can fail. Re-review caught it and I reproduced it: with a tunnel up, an `up` that fails on bad credentials left `pia0` gone and traffic silently back on the real address, while the error talked only about credentials. A privacy regression introduced by a correctness fix. `down` now runs after the token, server list and key registration have all succeeded — nothing above that line touches the network stack — and still clears the stale backup it was added for. Everything after it is covered by a rollback. Note this is an EXIT trap with a flag, not `trap ... ERR`: my first attempt used ERR and did not fire at all, because ERR is not inherited by shell functions without `set -E`, so a failure inside add_route missed it, and `die` exits explicitly, which is not an error. Verified by forcing a route collision — the tunnel is torn down and DNS is intact, where before the fix it was left half-configured with DNS dead. Also from the review: - **The private key lived on disk for the whole life of the tunnel.** `commit_container_snapshot` blanks env vars, never files, and nothing tears the tunnel down before a recreate or migrate — so the `down`-time cleanup never covered the path that put a key in a snapshot in the first place. It is now deleted the moment `wg set` has read it; the kernel keeps its own copy, verified by checking the interface still works afterwards. - **`up` claimed success without a handshake.** An unreachable peer still routes — into a black hole — so `up --full` could exit 0 having pointed all traffic and resolv.conf at a peer that never answered, with `status` printing "mode: full tunnel". Now polls for a handshake and rolls back if none arrives. - **`status` needed root and did not check.** `wg show` fails unprivileged and was swallowed, so an unprivileged run printed "no tunnel up" and then "mode: full tunnel" in the same breath. An agent reading the first line would re-run `up` — which, before the fix above, destroyed the tunnel it failed to see. - **The killswitch bullet was false.** It said `iptables` is not in the image; it is, so a killswitch is buildable. It stays unbuilt because it would cut Claude Code's own API traffic — an honest reason, unlike the previous one. - `install_feature_skill` rejects path-traversal names, not just blank ones — verified `../skills` would have deleted the whole skills directory including Mission Control's — and reports `mkdir`/`cp` failures instead of printing a success line regardless. - The usage text ended by printing `set -euo pipefail`, off by one line. - HOW-TO-USE claimed "the container says so on start". entrypoint prints to PID 1's stdout, which no terminal or UI surfaces — `docker logs` appears nowhere in the repo. Now points at the migration pre-flight, which does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
dcb13d23ea |
Fix what review found in the skill: five real defects
Adversarial review of #29 found bugs I confirmed by reproducing each one. **Every hand-written error message was unreachable.** `tok=$(curl ...)` is a plain assignment, so `set -e` acts on the command substitution before the following `|| die` can run. A wrong password produced exit 22 and no output at all — the most likely way this gets used wrongly, and the least explained. All four captures now go through a `run` helper that takes a *description* rather than echoing the command, because one of them carries the account password in `-u`. **`up` was not idempotent, and the second run destroyed DNS.** The resolv.conf backup was copied unconditionally, so `up --full` twice overwrote the good backup with PIA's own resolvers; the later `down` then "restored" those and left the container with no working DNS and no way back. `up` now runs `down` first. Verified: two `up --full` runs, then `down`, and the backup still holds the original 192.168.65.7. **An empty gateway produced total connectivity loss, reported as healthy.** `$gw` was never validated and `add_route` swallowed every failure to /dev/null. The two half-routes need no gateway and would succeed, so the tunnel captured everything while the exclusions keeping DNS and the Docker host reachable silently did not exist — and `status` still printed "full tunnel". Routes are now fatal on failure, and a via-less default (`$3` is the literal "eth0") is rejected. **The PIA session token was in the process arguments** — confirmed in `ps` and /proc/*/cmdline, a ~24h bearer credential for the account readable by anything in the container. It now goes to curl on stdin as a config. Verified: 60 polls across a full `up`, zero sightings. **The preflight diagnosed the wrong kernel module.** It checked /dev/net/tun and blamed the tun module, but kernel WireGuard is a netlink interface and does not use it — verified by creating one with NET_ADMIN and no tun device. The check is dropped (the container could not have started without the device anyway) and `ip link add` now reports the real dependency. Also: a full tunnel with no DNS servers from PIA used to warn and carry on, which is a tunnel leaking every lookup while reporting itself healthy — now fatal. `down` validates the backup before restoring it, so a truncated one cannot leave the container with no resolver at all. `wg.priv` is shredded on teardown and created under umask 077, because /run rides `docker commit` into the snapshot image. A mistyped `up --ful` is rejected instead of silently giving a test route. entrypoint: `install_feature_skill` gets `local`, a blank-name guard (the disabled branch would otherwise `rm -rf` the whole skills directory under a persisted volume), `-e`/`-L` so a leftover *file* at the destination is cleaned up, and a chown of the parent so `claude` can still add skills of their own when Mission Control is off. When the base image predates the skill it now says so instead of returning silently — and `/opt/triple-c-skills` joins FEATURE_PROBES so the migration pre-flight reports it. Docs corrected to match: neither half reaches an existing project without a migration. `vpn_env_var` extracted and tested, pinning the property the whole removal path rests on — that the variable is emitted as 0 rather than omitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7a8bbcbef7 |
Report which exit is which, instead of one ambiguous "public IP"
`status` probed https://1.1.1.1/cdn-cgi/trace and printed the answer as "public IP". In test mode 1.1.1.1 is the *only* address routed into the tunnel, so that line reported a PIA exit while every other packet left directly — a test tunnel reading exactly like a full one. Found on a live container: default route still via eth0, one 1.1.1.1/32 route through pia0, and the old status line claiming a PIA public IP. This is a plausible route to concluding the VPN is on when it is not, which is close to the confusion this skill exists to prevent. Status now names the mode and, in test mode, prints both exits with the real address called out. 1.0.0.1 serves the same trace endpoint as 1.1.1.1 and is never routed into the tunnel, so the direct exit can be probed without DNS. Verified against all four states: no tunnel, test mode on a live tunnel that was already up, full tunnel, and after teardown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3bd3caa101 |
Ship a pia-vpn skill with the VPN support toggle
The toggle grants CAP_NET_ADMIN and /dev/net/tun and stops there, which users reasonably read as "turn the VPN on" — the gap between the two is the reported bug that the default network does not route through a VPN. Close it by giving the container an agent-usable way to build the tunnel, rather than leaving each project to rediscover it. container/skills/ is baked to /opt/triple-c-skills and installed into ~/.claude/skills/ by entrypoint.sh from VPN_SUPPORT_ENABLED, mirroring how Mission Control installs its own. Staged under /opt because ~/.claude is a volume mount that would mask an image copy from first start. Three details that are not incidental: - The variable is sent as 0 rather than omitted when off, because ~/.claude persists: entrypoint has to be *told* to remove a skill left by an earlier run with the toggle on, and an absent variable cannot say that. A stale skill is worse than none, since it instructs an agent to use a capability the container no longer has. - It is reserved in RESERVED_ENV_EXACT alongside MISSION_CONTROL_ENABLED, or a custom env var of the same name could claim the skill without the capability behind it. Covered by a test. - The skill is re-copied on every start, rm -rf'd first, so fixes reach existing projects and files dropped from a later version do not linger. The skill itself carries the three things that are easy to get wrong: that a full tunnel captures the Docker resolver and takes DNS down with it, that an IP-literal health check cannot see a dead resolver, and that no tunnel survives a restart while /run state riding the snapshot makes it look as though one did. It also states what it deliberately does not do — no killswitch, no autostart — so an agent proposes those as decisions rather than improvising them. pia-wg.sh preflights CAP_NET_ADMIN by capability bit rather than letting the first `ip` call fail with a bare EPERM that points nowhere near the setting that needs changing. Credentials stay in a file (~/pia-creds, PIA_CREDS to override) rather than the environment, where docker inspect and every process in the container would see them. Tested: install/refresh/remove/no-op paths of install_feature_skill against the real function; preflight with and without the capability; and a full up --full / down round trip, confirming DNS via PIA's resolvers, api.anthropic.com reachable through the exit, and routes and resolv.conf restored on teardown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5dd1ab5217 |
Stop resting the iptables case on a kernel config I cannot verify
Build App (Preview) / compute-version (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build Container / build-container (pull_request) Successful in 29s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-windows (pull_request) Successful in 5m46s
Build App (Preview) / build-linux (pull_request) Successful in 5m59s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Round 3 argued the macOS rationale is stale: that Docker Desktop no longer builds from linuxkit/linuxkit and has enabled nft_fib_ipv4 since 4.35. I could not confirm or refute that from a Linux host — searching turned up no version matrix either way. But the decision does not depend on it, and the comment should not have implied it did. `xt_CONNMARK`, which the iptables path needs, was present in every kernel config examined. `nft_fib_ipv4`, which the nft path needs, was absent from the config read here and may be present in current Docker Desktop. That asymmetry is the actual argument: nftables' viability varies by Docker Desktop version in a way nobody here can pin down, iptables' requirement did not vary anywhere it was checked. If nft_fib_ipv4 is present this costs 1.6 MB and nothing else; if it is absent it is the difference between a working full tunnel and none. Rewritten to say that, and to say plainly what is verified versus assumed — this is the third round in which the previous round's central premise did not survive, and a confidently-worded paragraph is what the next round inherits. Also from review: - CLAUDE.md still said "`iptables` is deliberately absent", the opposite of what this PR now does, contradicting the Dockerfile and both other docs. - The Dockerfile referenced "the pia-vpn skill", which does not exist on this branch — the third forward reference of that kind, now gone. - "full tunnels work on native Linux, Mac and WSL2 6.6" was unconditional and contradicted ten lines later by the `DNS =` concession, which stops them on every platform. Reordered so the DNS hurdle is named as the first one. - The WSL2 gap was written as a permanent platform limitation. It is a stale install: `wsl --update` moves the host to a current kernel that has the symbol. That remedy was missing from the user-facing doc. - The migration probe label carried an internal comma, which `joinFeatures` renders into a comma-joined list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
92d64cf252 |
Ship iptables, not nftables — nftables forfeits macOS
Build App (Preview) / compute-version (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m36s
Build App (Preview) / build-linux (pull_request) Successful in 6m29s
Build App (Preview) / build-windows (pull_request) Successful in 6m59s
Build App (Preview) / prune-previews (pull_request) Successful in 13s
Build Container / build-container (pull_request) Successful in 11m10s
Re-review overturned the previous commit's package choice, and verifying it
proved the reviewer right.
`wg-quick` picks nft *unconditionally* when it is present (`type -p nft`, line
241), so installing nftables makes the iptables path unreachable. Its nft
ruleset then needs a third expression family the iptables path does not.
Isolating the rules on this host, the two connmark rules install fine and this
is what fails:
nft add rule ... fib saddr type != local drop
Error: Could not process rule: No such file or directory
That decides it, because of how the hosts differ. LinuxKit's kernel config —
Docker Desktop for Mac, identical on x86_64 and aarch64:
CONFIG_NETFILTER_XT_CONNMARK=y <- the iptables path works
# CONFIG_NFT_FIB_IPV4 is not set <- the nft path does not
So nftables would have broken the platform it was added to fix. With iptables,
full tunnels work on native Linux, Docker Desktop for Mac, and WSL2 from 6.6.
Costs 7,203 kB rather than 5,614 kB on amd64.
That also means the mechanism the previous commit documented was wrong: with
nftables installed `xt_CONNMARK` is never consulted, and the real blocker on
that path is `nft_fib_ipv4`. Rewritten around what actually fails.
A second failure neither round had found: `wireguard-tools` only *Suggests*
`openresolv | resolvconf`, so neither is installed, and every provider's stock
config has a `DNS =` line. That fails in `set_dns()` — before any routing — so
it takes split tunnels down too, contradicting what this PR previously claimed:
[#] resolvconf -a sp -m 0 -x
/usr/bin/wg-quick: line 32: resolvconf: command not found EXIT=127
Not fixed, deliberately: `openresolv` has no installation candidate on noble,
and `resolvconf` resolves only by pulling in systemd-resolved — a resolver
daemon and systemd units, into a container with no systemd. Documented instead.
Smaller corrections from the same review:
- the size caveat blamed ~209 kB of libelf1t64; for this package set the real
over-count is libelf1t64 + netbase. Restated, and arm64 now given against the
real base rather than left as a bare-ubuntu figure.
- the manual-install fallback omitted `iproute2`, so it left the user without
`ip` — the command the tunnel needs most.
- "Without it" had been orphaned from its antecedent by inserted paragraphs and
read as referring to configuring a tunnel.
- the migration probe said "VPN support", presenting VPN as a feature gained to
users who never enabled it. Now names the tools and the toggle.
- "What's Inside the Container" gains a row; the key-material-in-snapshot
hazard was in CLAUDE.md only, and is the one genuinely user-facing warning
here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
ab2c75d0b2 |
Ship a firewall backend, and correct three claims review disproved
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m46s
Build App (Preview) / build-linux (pull_request) Successful in 7m22s
Build App (Preview) / build-windows (pull_request) Successful in 7m43s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 11m20s
Review of #28 found the iptables exclusion was justified by a false premise, and I confirmed it: `wireguard-tools` declares `Recommends: nftables | iptables`, `--no-install-recommends` strips it, and `wg-quick`'s add_default() shells out to a firewall backend with no `type -p` guard. Measured on the image as this PR shipped it: [#] iptables-restore -n /usr/bin/wg-quick: line 32: iptables-restore: command not found wg-quick EXIT=127 That fires for `AllowedIPs = 0.0.0.0/0` — every stock full-tunnel config from every provider — not for a desktop client's killswitch as the comment claimed. Split tunnels are unaffected. Ship `nftables` rather than `iptables`: wg-quick prefers it (`type -p nft`, so with both installed iptables is dead weight), it is first in the package's own Recommends, and it is half the size. The review's proposed fix stopped there; it does not hold. Adding nftables does not make wg-quick work on this host, and neither does iptables: Warning: Extension CONNMARK revision 0 not supported, missing kernel module? `Table=auto` routes by fwmark and needs xt_CONNMARK from the *host* kernel. WSL2 has none and containers have no /lib/modules to load one from. So this fixes native Linux and Docker Desktop for Mac — which other WHP users are on — and cannot fix Docker Desktop for Windows, where the answer is to add routes with `ip route` directly. Documented rather than left to be rediscovered. Also from review: - "`ip` and `wg` are always present" was false. A project keeps the base image it was first built from, so this reaches new projects only. Reworded to match the wording already used for the Playwright libraries, and `/usr/bin/wg` added to FEATURE_PROBES so an existing project is *told* it is missing VPN tooling and prompted to migrate, rather than finding out via `wg: command not found`. - "no client is installed" contradicted shipping `wg` four lines earlier. The true claim is that no tunnel is configured or started. - The size figure measured against bare ubuntu:24.04, which over-counts by the ~209 kB of libelf1t64 the real base already has, and covered one arch. Now measured against the current base on amd64 and stated for arm64 too, per the standard CLAUDE.md sets for the Playwright layer. - `/run` persistence conflated two mechanisms: same-container files on a stop/start, `docker commit` on a recreation. Both stated, plus the corollary that key material written to /run ends up inside a snapshot image — observed, a `wg.priv` was already sitting in one. - The DNS bullet presented a Docker Desktop address as the general case. Now leads with the mechanism, notes 127.0.0.11 on a user-defined network is unaffected, and adds the two things the advice omitted: a resolver the tunnel can reach (or it leaks every query), and pinning the endpoint via the old gateway (or the tunnel routes through itself). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
00937745f7 |
Ship the tools the VPN toggle grants capability for
Build Container / build-container (pull_request) Successful in 11m28s
`vpn_support_enabled` hands a project CAP_NET_ADMIN and /dev/net/tun, and the image then contains no `ip` and no `wg` — a capability with nothing able to exercise it. Bake `iproute2` and `wireguard-tools` (~4.3 MB with deps). They belong in the image rather than a runtime install for the reason the Dockerfile already gives for the Playwright libraries: the writable layer is lost on base-image migration. A hand-installed `wg` works until an upgrade and then vanishes, which presents as a tunnel that will not come up rather than as a missing package. One project only had `ip` at all because MariaDB pulled in iproute2 as a transitive dependency. `iptables` stays out. Only a desktop client's killswitch wants it, and those clients need a GUI the container cannot provide. Also correct three things the docs left users to discover: - the toggle grants capability and routes nothing, which is being reported as the default network "not routing through the VPN automatically" - no tunnel survives a restart, and `/run` state riding the snapshot makes it look as though one did while traffic goes out the real address - a full tunnel captures the Docker resolver, which sits outside the container's subnet, and takes DNS down with it — Claude Code then reports a connection failure because it cannot resolve api.anthropic.com, and a health check aimed at an IP literal passes throughout Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2b35aa8c16 |
Explain a missing tun device where the failure actually happens
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-linux (pull_request) Successful in 5m30s
Build App (Preview) / build-windows (pull_request) Successful in 5m55s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Review caught that the device guard was wired to the wrong call. The
daemon does not resolve `--device` at create: verified against Docker
29.7, `docker create --device /dev/does-not-exist` succeeds and prints an
id, and runc only resolves the device — and validates sysctls — when it
builds the container. So on a host with no tun module the create returns
fine and `start` fails, which means the explanation never ran and the
user saw the raw daemon string naming a path they would go looking for on
the wrong machine. The unit tests fed the create-side string straight in,
so they confirmed a function no real failure could reach.
Move the guard onto `start_container`, covering create as well in case a
future daemon checks earlier. It no longer takes `vpn_support_enabled` —
`start_container` has a container id and no project, and nothing else in
Triple-C ever requests a device, so an error naming /dev/net/tun is
unambiguous on its own. The test now uses the daemon's verbatim message
via bollard's real Display format.
Also from review:
* Soften the security claim. Docker does not enable user-namespace
remapping by default, so this is a real CAP_NET_ADMIN in the initial
user namespace with only the network namespace confining it. It
cannot touch host interfaces, but "confers no authority outside the
container" was too strong: within its namespace it can set
promiscuous mode and add addresses, routes and NAT on the shared
docker0 segment, which puts sibling containers — the LiteLLM gateway
among them — within ARP-spoofing reach, and it can flush netfilter
rules sandbox mode may rely on. Said plainly in the code, CLAUDE.md
and HOW-TO-USE.
* Drop Tailscale from the list of clients needing this. Its
--tun=userspace-networking mode needs neither the capability nor the
device, and listing it invites granting NET_ADMIN for nothing.
* Say in the toggle's own hint that changing it recreates the
container, matching how every other recreation-triggering setting is
labelled. The tab's generic "stop the container first" chip does not
tell the user what is about to happen.
* Add RuntimeSection tests: saves on, saves off explicitly rather than
dropping the key, reflects state, is disabled while running, and
carries the recreation warning.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
65a3d4eb29 |
Let a project's container run a VPN client
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-linux (pull_request) Successful in 7m10s
Build App (Preview) / build-windows (pull_request) Successful in 6m33s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
A VPN client installed in a container today starts, runs, and then hangs
until its connection times out. Nothing reports an error: a default
container has no /dev/net/tun to open and no CAP_NET_ADMIN to add an
interface or a route with, and clients surface that as a generic timeout
rather than a permissions failure.
Add an opt-in per-project "VPN support" switch granting the three things
a tunnel needs. They are useless individually, which is why
vpn_host_config() defines the set in one place and the tests assert all
of it:
* CAP_NET_ADMIN — Docker's default bounding set has net_raw but not
net_admin, so a client can ping but never connect.
* /dev/net/tun — passed through from the host so the kernel's tun
module backs it, rather than mknod-ed inside.
* net.ipv4.conf.all.src_valid_mark — WireGuard's wg-quick sets this and
cannot from inside a container, /proc/sys being read-only, so its
handshakes are dropped by reverse-path filtering.
Off by default and deliberately opt-in: NET_ADMIN lets anything in the
container reconfigure that container's network stack. It is namespaced —
no authority over the host's interfaces or any other container.
Capabilities and devices are fixed when a container is created, so this
is container state and takes the label-and-compare treatment.
triple-c.vpn-support is written unconditionally, false included, for the
usual docker commit reason: a true stamped once would ride the snapshot
image into every future container and make the switch impossible to turn
back off. A missing label reads as false and off is byte-identical to
today, so no existing project is churned.
Requesting the device fails at creation when the host kernel has no tun
module, which would otherwise surface as a project that simply refuses to
start. explain_create_failure() rewrites that one error to name the
switch and the Docker-Desktop-VM-versus-your-machine distinction, and
leaves every other failure untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
3741e0fef5 |
Number releases by the highest one already published, not by drift
The Linux release upload failed with a bare "exitcode '1'" and no output. The cause was not the upload: compute-version handed it a version that had already been released three days earlier. The patch number was `git rev-list --count <highest tag>..HEAD` — how far HEAD has drifted from whichever tag sorts highest, which resets to zero every time a tag is cut. It is not a counter, and the published history is what the old formula returned at each point: v0.4.0 -> 3 commits -> v0.4.3 looked fine v0.4.3 -> 4 commits -> v0.4.4 fine by luck, 4 > 3 v0.4.4 -> 2 commits -> v0.4.2 went backwards v0.4.4 -> 6 commits -> v0.4.6 jumped, skipping .5 v0.4.6 -> 3 commits -> v0.4.3 already taken So the line published 0.4.0, 0.4.3, 0.4.4, 0.4.2, 0.4.6 in that order, never used 0.4.1 or 0.4.5, and then came back round to 0.4.3. The patch is now one past the highest already used. Suffixed tags count towards that: create-tag is skipped whenever a platform job fails, so a run can publish v0.4.7-mac and never create the plain v0.4.7, and reading only unsuffixed tags would hand the same number out twice. A commit that is already tagged reuses its own tag, so re-running a build does not mint a version. Reusing a number was doing real damage, not just failing. macOS and Windows delete-then-upload each asset, so they took the duplicate in their stride and rewrote v0.4.3-mac and v0.4.3-win — public since Aug 11 — with today's binaries. Linux is the only platform that failed, and failing was the correct outcome; its v0.4.3 assets are the only ones still original. Linux also gets the idempotent get-or-create the other two already had, plus `set -euo pipefail` and `-fsS`. Its `curl -s` with no `-f` is why a 409 produced no diagnostic at all: the HTTP error was swallowed, the id grep came back empty, and the step died without ever printing why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
f3cc1c4c17 |
Stop Playwright setup from deleting the package it just installed
Setting up the browser view failed on every container, and re-running it reproduced the same broken state, because the setup destroyed its own work. `install_packages` ran two `npm install --no-save` commands into /workspace, which has no package.json. With no manifest, npm treats the command line as the whole statement of what the tree should contain and prunes the rest, so installing `playwright` second removed the `@playwright/cli` installed first: "removed 3 packages", leaving an empty node_modules/@playwright/ behind playwright and playwright-core. That empty directory is exactly what the pane then reported as missing. The second install now names both specs; the first one is already present, so it costs nothing and is only there to stop npm pruning it. Two failures were waiting behind that one: Nothing in the tree ever configured the browser, so playwright-cli fell back to channel `chrome` — system Google Chrome — with the Chromium sandbox on. These containers forbid unprivileged user namespaces, so it aborted with "Failed to move to new namespace ... Operation not permitted"; on a base image without Google Chrome the same default failed as "Chromium distribution 'chrome' is not found". entrypoint.sh now seeds ~/.playwright/cli.config.json on every start, which is the only way to reach existing projects: ~/.playwright is inside the home volume, so an image copy would reach new projects only. The launch check passed for a configuration the viewer never uses. It launched bundled chromium with no channel, which resolves to chromium-headless-shell, while the viewer's config pins chrome-for-testing — the full chromium build, a separate download. A container could pass every check and still fail in the pane with 'Browser "chrome-for-testing" is not installed', which is what a stale chromium-1217 against a wanted chromium-1237 did. Chromium is now verified on both channels, the sandbox setting is stated rather than inherited from a default, and a failure names the channel. triple-c-playwright-heal repairs all of it on a container that is already broken, including the missing socat that makes the pane report "127.0.0.1 sent an invalid response" while the container side is perfectly healthy. It verifies by launching a browser rather than trusting the preceding steps — which is how the stale-revision case was found — and lives in /usr/local/bin so a fix to it can still reach an existing project. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
9027fa9ad4 |
Stop the scheduler handing Claude root's HOME
Build Container / build-container (pull_request) Successful in 1m11s
Every scheduled task failed with "Not logged in · Please run /login" while the container's OAuth credential sat there, valid, the whole time. The entrypoint snapshots the environment into ~/.claude/scheduler/.env so cron jobs get more than cron's minimal env. It runs as root, and HOME was in the capture list, so the file recorded HOME=/root. The task runner then sources that file with `set -a`, overwriting the HOME cron gave the job. `claude -p` looks for its credential under $HOME, finds no /root/.claude, and exits 1. Logging still worked — SCHEDULER_DIR is expanded before the sourcing — which is why this presents as a well-formed log of a task that never authenticated. Drop HOME from the captured set and write it explicitly instead; cron does still need one. Then restore HOME across the source in the task runner too: .env lives on the home volume, so every project created before this ships keeps a stale copy of it until its container restarts, and the runner is what has to survive that. 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>
|
||
|
|
9b2f4fe79f |
Give the env var its value box back, and stop labelling the secret
Build App (Preview) / compute-version (pull_request) Successful in 7s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m56s
Build App (Preview) / build-windows (pull_request) Successful in 5m33s
Build App (Preview) / build-linux (pull_request) Successful in 6m47s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
Two separate faults, both reachable from one screenshot of the Global Environment Variables editor. The value input was collapsed to a sliver, so a variable looked like it had lost its value. `inputClass` carries `w-full`, and the `w-2/5` on the key input did not beat it — class-attribute order is not what resolves that conflict, stylesheet order is. The key therefore asked for the whole row, and the value input, whose `flex-1` gives it a basis of 0 and only the leftover space, got almost nothing. Widths now live on wrapper divs, where nothing competes with them. The fingerprint that detects custom-env changes was a plaintext `KEY=VALUE` join, and it is written as the `triple-c.custom-env-fingerprint` label. Labels are readable by anything on the host via `docker inspect`, `docker commit` copies them onto the project's snapshot image, and the recreation check logs both sides on a mismatch — so an API token set as a custom variable was published to all three. It is hashed now, exactly as `triple-c.git-token-hash` already was. Empty stays empty, so "nothing configured" still reads as an empty label. Changing the fingerprint format means every project's label mismatches once: expect a single container recreation per project on next start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e9ec2f8e26 |
Bring the README back in step with the code, and give it a spine
Four feature commits landed after the last doc sweep without reaching the
README, and
|
||
|
|
4c962ebd9c |
Archive the marks the new icon replaces
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 5m29s
Build App (Preview) / build-linux (pull_request) Successful in 5m41s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Neither is referenced by the app or the build; branding/archive/README.md says what each one was and why it did not survive the sizes an app icon is actually drawn at. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d15faa923b |
Give the app a mark that survives being 16 pixels tall
The icon is now a container with its right wall opened, so the enclosure itself is the letter C, holding a >_ prompt: the two things the app is, in one closed shape. It carries no type, so nothing goes illegible when the shell draws it small, and it uses the app's own accent tokens rather than a saturated orange field that fights the chrome behind it. icon.ico contained a single 16x16 image, which Windows was upscaling into the taskbar and every other slot — the likely cause of the artefact in screenshot_for_fix/. It now carries 16, 24, 32, 48, 64, 128 and 256, each rendered from vector rather than downsampled from one bitmap, and the entries at 32 and below come from a separate optical source: at that size the cursor bar closes up against the chevron, so the small variant drops it, widens the mouth and thickens the strokes. A test asserts the .ico keeps its small sizes so this cannot regress silently. Also adds the icon.icns that macOS bundles have been building without, points the favicon at our own mark instead of the missing /vite.svg, and puts the SVG sources, the lockups and the regeneration script in branding/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ab747ce53d |
Say what "open in container" is doing, and land on the pane doing it
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-linux (pull_request) Successful in 5m33s
Build App (Preview) / build-windows (pull_request) Successful in 5m40s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Opening a page is a container probe, a browser launch, a page load and often a viewer start — several seconds during which the only feedback was the click itself. Worse from a terminal, where the result appears in a pane the user is not looking at. So: the backend emits progress on the existing `container-progress` channel at each step, the Browser tab renders that line whenever it is set — the progress belongs to the project, not to whoever pressed the button, which is what lets a terminal-initiated open report anywhere at all — and the terminal's "In container" now selects the project's Browser tab before starting, so the line has somewhere to appear. Selecting a sub-tab from outside needed a route: `ProjectHome` keeps it in local state, so `openProjectHomeTab` parks a request in the store and the pane consumes it once. Consumed once, so it cannot fight the user's own clicking afterwards. Preview releases now prune themselves to the newest KEEP_PREVIEWS (2), in a job that runs only if all three platforms published — a half-finished run must not evict a good older build. The cleanup workflow's manual sweep stays as the backstop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
85ea3956e8 |
Stop the drag from selecting the tab's text
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-windows (pull_request) Successful in 5m39s
Build App (Preview) / build-linux (pull_request) Successful in 5m56s
A pointer-driven drag is still a mouse drag as far as the browser is concerned, so moving a tab highlighted its label blue — something the OS drag image never did, and the last visible difference between this and a real drag. `select-none` on the tab. The rename field gets `select-text` back: `user-select` inherits, and selecting text is exactly what that field is for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5bd80a05bc |
One build per push: previews carry the PR check
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-windows (pull_request) Successful in 5m27s
Build App (Preview) / build-linux (pull_request) Successful in 5m36s
Every push to the PR started two workflows on the same commit. build-app.yml ran on pull_request and compiled all three platforms — then published nothing, because every publishing step in it is gated on `gitea.event_name == 'push'`. build-app-preview.yml compiled the same three and published them. Six OS builds per push, half of them unreachable. So the PR trigger moves to the preview workflow, which was already doing the identical compilation and has something to show for it. build-app.yml is now push-to-main and manual dispatch only: releases. Two things a pull_request event changes, handled rather than inherited: `gitea.sha` can be the merge ref — not the commit anyone is testing, and not something to hang a tag on — so the release's target comes from `git rev-parse HEAD` in the checkout; and `gitea.ref_name` is the PR number, so the release body uses `gitea.head_ref` when there is one. The cost is one prerelease per PR commit touching app/**, which the existing Cleanup Old Releases sweep already prunes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f239fa1c82 |
Fix three things found by actually using it
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m16s
Build App / build-linux (pull_request) Successful in 5m25s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
**The drag showed no tab.** Moving to pointer events lost the drag image the OS used to supply, leaving a dimmed source tab and a 2px line — which reads as "some setting changed", not "I am holding this tab". A copy of the tab now follows the cursor, carrying its glyph and its real label, grabbed at the offset it was picked up by so it sits where the tab was. **The URL relay opened a different URL than the one on screen.** Observed: `repo.anhonesthost.net/…/tag/preview-63f3c54` arrived as `repo.anhonsthost.nt/…/preview-63f3c54Butitprovesyournitpick…`. The detector deleted *every* line break to undo PTY hard-wrapping, but a terminal that wraps at a space emits the break **instead of** the space — so deleting breaks also deletes the separators, gluing the following paragraph onto the link and running the match past the host. Only breaks the terminal inserted may be deleted, and those are exactly the ones at the column width. The detector now takes a live column getter and rejoins a line only when it is exactly that wide; every other break becomes a space, which is also what stops a URL match. Lines *longer* than the width are left alone — the stream had no break there, so the one that follows is the application's own. One case stays ambiguous: a URL whose length is an exact multiple of the width is indistinguishable from one that was cut. That is pinned in a test as known behaviour rather than papered over — the candidate is shown in full and nothing opens without the user pressing Open. **"In container" opened a page nobody could see.** It bound the browser and stopped, leaving the user to find the Browser tab and press Start, with nothing saying so — and from a terminal, no pane on screen at all. Opening a page now starts the viewer if it isn't running, and the terminal's prompt raises the pop-out window, because that caller has nowhere else to put it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5b18ce804f |
Start the 0.4 line, and give previews the version they are previewing
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m34s
Build App / build-windows (pull_request) Successful in 5m17s
Build App / build-linux (pull_request) Successful in 5m23s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Two version problems, one of them mine. **Previews claimed x.y.0.** The preview workflow hard-coded the patch number, so every preview installer reported 0.3.0 whatever it contained, while the real build computes the patch from tags. It now runs the same computation, so a preview is labelled with the version the release it previews would carry. **A new minor line started at the wrong number.** `compute-version`'s fallback for "no tag matches this line yet" counted every commit in the repository — fine as a bootstrap, wrong the moment a minor version is bumped: the first 0.4 build would have been 0.4.234. A line nobody has tagged is a new line, and a new line starts at .0. With those fixed, VERSION moves to 0.4 — tab reordering, the browser pop-out, opening pages in the container's browser and the Playwright install fix are more than a patch bump. The next release is v0.4.0; today's HEAD would have been 0.3.90 on the old line. `app/package.json`, `package-lock.json`, `tauri.conf.json` and `Cargo.toml` follow to 0.4.0. CI patches all four per build, so they are the dev-time defaults rather than the source of truth — but a local `tauri dev` shows them, so they should not still say 0.3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
63f3c54b95 |
Publish preview builds as a prerelease instead of workflow artifacts
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m33s
Build App / build-windows (pull_request) Successful in 5m20s
Build App / build-linux (pull_request) Successful in 5m30s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Workflow artifacts do not work on this Gitea, in two different ways:
* upload-artifact@v4 cannot run at all. @actions/artifact v2's isGhes()
treats any GITHUB_SERVER_URL that is not github.com / *.ghe.com /
*.localhost as GitHub Enterprise Server and throws before making a
single request. act_runner sets it to this instance, so all three
platforms died with GHESNotSupportedError — after paying for the
whole Tauri build (run #265).
* @v3 uploads succeed and the files are downloadable by direct URL,
but Gitea does not *list* them: /api/v1/…/runs/<id>/artifacts returns
total_count 0 and the run page shows nothing (verified on run #267).
A build nobody can find is not a build.
So previews publish the way every other workflow here does: curl to the
releases API. One prerelease per preview, tagged `preview-<sha>`, with
all three platforms' bundles as assets — visible on the Releases page
with stable links.
The release is created in a job the three builds depend on rather than
get-or-created in each. They run concurrently, so per-job creation races
on one tag: the loser gets a 409, and the id parse then yields empty
while the step still reports success — the failure build-app.yml's
macOS job was hardened against after it happened for real. One creator
removes the race instead of handling it.
Asset upload keeps that hardening: delete-then-upload so a re-dispatch
replaces rather than 409s, --http1.1 and retries for the mid-stream
drops the macOS runner has produced (curl exit 92, exit 28), and an
explicit failure when a platform produced no bundles at all.
The `preview-` prefix is load-bearing: cleanup-releases.yml keeps recent
`v<x>.<y>.<z>` releases and separately deletes every release whose tag
does not start with `v[0-9]`, so previews are pruned by the cleanup
already in use and never crowd the real release list. sync-release.yml
is dispatch-only, so none of this reaches GitHub.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
f68d9c5788 |
Open a page in the container's browser, at a viewport you choose
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m14s
Build App / build-windows (pull_request) Successful in 5m56s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
The pane could only ever watch a browser something else had published. This opens one: a URL and a viewport, launched inside the container and bound so the pane picks it up. Two uses, one action — a sign-in page, where the callback listener is *in* the container and the loop closes with no host round trip and no auth bridge, and a dev server on container loopback, which is how you watch a UI Claude is building. Reachable from both places the question comes up: "Open a page…" in the Browser tab, and an "In container" button on the terminal's URL prompt. Verified first, because it decided the design: a second client cannot join a bound browser. `chromium.connect()` against the published endpoint times out in every URL form (`ws+unix://…`, with and without the trailing path) — that socket speaks the dashboard's own transport, not the public connect protocol. Whoever launches is therefore the only process that can drive, so the helper is resident and holds the handle, and live resize applies to pages we opened and never to `@playwright/mcp`'s. Those take `--viewport-size` / `PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch, which the docs now say. The viewport is the interesting half. Resizing the *window* does nothing to the page — the viewer is a CDP screencast, so a bigger window is the same pixels drawn larger, which is why pages have been looking like they were rendered small. `page.setViewportSize()` genuinely reflows: measured against a `@media (max-width: 900px)` rule, it fires at 800×600 and clears at 1440×900. Match-window mode pushes the pop-out's settled size into it, debounced by generation counter because a drag emits `Resized` continuously and each one costs a container exec. Control is a polled JSON file in /tmp: no port, no second listener, nothing added to the proxy's surface, and URLs travel as argv to `node` so no shell ever parses one. A re-open with a helper already up navigates instead of relaunching — otherwise the second page would throw away the session the first one just signed into. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bd72781482 |
Install one Playwright tree, and notice when a container has two
Setup installed `playwright@latest` and `@playwright/cli@latest` together. Verified on a real container, that produces a tree that looks right and is broken: `@playwright/cli@0.1.18` pins `playwright-core@1.63.0-alpha`, npm hoists it, and `playwright@latest` (1.62.1) nests its own `playwright-core@1.62.1` beside it. The two cores want different browser revisions. The browser step runs the *resolved* — hoisted — CLI, so it downloads chromium-1237. Every script Claude writes says `require("playwright")`, gets the nested 1.62.1, and dies with: Executable doesn't exist at …/chromium_headless_shell-1234/… while the pane reports a browser installed, because one is. This is deterministic, not bad luck: every container set up through the pane lands in it. So the viewer package is installed first, and the `playwright` version installed after it is the one that package pins — read from the manifest npm just wrote, falling back to `@latest` only if it cannot be read. One core, one browser revision, both halves agreeing. Re-running "Set up Playwright" repairs an already-split tree. Detection now asks the question directly rather than listing a cache: it asks each resolved copy for `chromium.executablePath()` and whether that file exists — the viewer's copy *and* the one `require("playwright")` returns, since those are routinely different. `needs_browser()` covers "installed but not launchable", and the pane names both halves instead of saying "install a browser" over a cache that visibly has one. An absent field is "the probe didn't answer", never "skewed": containers predating these fields must not be told their browsers are wrong. The Rust side gets that from Option; the TypeScript mirror needed `!= null`, which an existing test caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1207a21aae |
Pin the preview build's uploads to upload-artifact@v3
Build App / compute-version (pull_request) Successful in 5s
Build App / build-macos (pull_request) Successful in 2m32s
Build App / build-windows (pull_request) Successful in 5m20s
Build App / build-linux (pull_request) Successful in 5m32s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Run #265 — this workflow's first ever run — built the app on all three platforms and then lost every bundle at the upload step: GHESNotSupportedError: @actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+ are not currently supported on GHES. v4 bundles @actions/artifact v2, whose isGhes() treats any GITHUB_SERVER_URL that is not github.com, *.ghe.com or *.localhost as GitHub Enterprise Server and throws before making a single request. act_runner sets that variable to this Gitea instance, so v4 cannot work here on any runner or any OS — and it fails *after* the whole Tauri build has been paid for. v3 uses the v1 artifact API, which Gitea implements. Both options this workflow relies on, `if-no-files-found: error` and `retention-days`, exist in v3. The other workflows never hit this because they publish by curling the Gitea releases API instead. Noted at the top of the file, with the isGhes rule, so the pin is not "upgraded" back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a41d93ea46 |
Fix the review's findings: drag on pointer events, read the window back
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-linux (pull_request) Successful in 5m11s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Ten findings from the review of the previous commit, all applied. **The tab drag is now pointer events, not HTML5 drag-and-drop.** Two independent reasons, either one fatal. Tauri's `dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot just be turned off — `TerminalView` needs Tauri's native drag-drop event, which is the only one that carries dropped *file paths*. And an HTML5 drag carries a `DataTransfer`: released over any text field in the app, the default handler types `term:<uuid>` into it, and in Config that is then saved with the project. Pointer events have neither problem, and the drag is measured from the tabs on screen rather than from the event target, so the marker and the drop agree even over the marker itself. Escape abandons a drag; a press under 4px stays a click; the click that ends a drag does not select. **`Ctrl+Shift+←/→` no longer swallows word-wise selection.** It is bound on `document` in the capture phase, so in any input — the rename field, Config, Settings — it was taking the OS's extend-selection chord *and* silently reordering the strip. Guarded by `inTextField()`, which excludes xterm's helper textarea: that is an input-method shim, and the terminal is where the shortcut matters most. **The pop-out's state is read from the window, never remembered.** The pane is unmounted whenever another Project Home sub-tab is selected, so "Keep on top" came back Off over a window still floating on top. `get_browser_view_popout_state` returns both facts from the window itself, and the change event carries them. `poppedOut` is tri-state: until the answer arrives the iframe is not mounted, because guessing "not popped out" is what flashes a second viewer onto the browser. Also: `popout::close` and the off-status emit in the supervisor are behind the same epoch guard as the deregistration above them, so a supervisor whose teardown outlives a restart can no longer destroy the *new* session's window; `close()` returns its `destroy()` error instead of logging it and reporting success, since the pane restores its iframe on success; the drop marker is `pointer-events-none` and is placed before the first *visible* tab at or past the slot, so it neither refuses a drop nor vanishes when a `tabOrder` entry renders nothing; and the "Keep on top" Toggle's accessible name now matches its visible text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d73096c937 |
Reorder tabs by dragging, and pop the browser view into its own window
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Successful in 6m9s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Two things the UI couldn't do: rearrange the tab strip, and watch the browser while working somewhere else. **Drag to reorder.** `moveTab`/`moveActiveTab` on the store, HTML5 drag on the strip with a marker showing where the drop lands, `Ctrl+Shift+←/→` for the same thing without a mouse. Reordering deliberately does not select what it moves, so a drag aimed at a background tab doesn't yank the main area away from a terminal mid-run. A tab being renamed is not draggable — a draggable ancestor swallows the mouse-drag that selects text in its input. **Pop the browser view out.** `browser_view/popout.rs` opens the view's existing token-bearing loopback URL as a second OS window, with a "Keep on top" toggle so it can float above the app. Window-only: the viewer, the proxy and the container are untouched, so popping out and back interrupts nothing. Three things it rests on: - No capability lists that window, so it has no IPC surface — right for a page served out of a container, and it must stay that way. - The app CSP is irrelevant to it: `frame-src` constrains what the app's document may *embed*, and this is a top-level document. The port range and the token gate are what actually protect it, unchanged. - The window is owned by the session, so the supervisor's teardown closes it. A window onto a viewer that no longer exists is worse than none. The pane drops its iframe while popped out — two viewers can both *drive* the browser, and two cursors on one page is not a feature. `lib.rs`'s `on_window_event` is now guarded on `label() == "main"`. It fires for every window and its body stops every container and exits, so without the guard closing a pop-out would quit the app. 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 |
||
|
|
4fdfed7955 |
Bake the browser's runtime libraries into the base image
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 5m13s
Build Container / build-container (pull_request) Successful in 13m11s
Build App / build-linux (pull_request) Successful in 6m53s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
`npx playwright install chromium` downloaded ~150 MB of browser that then died with "error while loading shared libraries: libglib-2.0.so.0" — verified, not inferred, against the current image. The image shipped none of Chromium's shared libraries, which is why `apt install google-chrome-stable` looked like the cure: apt was quietly installing the same set as Chrome's own dependencies. Installing them at runtime instead converges on the worst possible state. The libraries land in the container's writable layer, so they are re-paid after every Reset and *lost* on base-image migration, which replays apt from a manifest. The browsers ride in ~/.cache/ms-playwright, inside the home volume, and survive both — leaving a 400 MB browser present with its libraries gone. So the libraries are baked and the browsers are not: each half now lives where it already persists. The layer runs `npx --yes playwright@latest install-deps chromium` rather than a hand-written apt list. Ubuntu 24.04's 64-bit-time_t transition renamed a swathe of these packages (libasound2t64, libatk1.0-0t64, libglib2.0-0t64, …) and a new Chromium dependency would drift straight back into the launch failure this exists to prevent; letting Playwright name its own dependencies is self-maintaining. It sits immediately after Node — npx is its only prerequisite — and well above the shim COPYs, so editing a shim does not re-run it. The `--dry-run` that follows is a build-time assertion, not decoration: on a platform Playwright has no list for, `install-deps` prints a warning and returns having installed **nothing, with exit status 0**. Without the assertion that ships a broken image behind a clean build log. Measured, on a build of this file with the layer applied over an otherwise identical image: +99 packages, +334 MiB unpacked and +119 MiB compressed (2950 → 3284 MiB, 759 → 878 MiB). Two thirds of that is not reachable by trimming — libgbm1, which Chromium needs, pulls mesa-libgallium, which pulls libllvm20. A chromium-only apt list measures 247 MiB against install-deps' 341 MiB; the ~94 MiB difference is xvfb and the CJK/emoji fonts, kept because the base ships no fonts at all and every page this feature exists to display would otherwise render as tofu. Verified on real builds, both architectures: a `--platform linux/arm64` build of this file installs the same 99 packages and passes the same assertion. On the new amd64 image, `playwright install chromium` with no `--with-deps` and no `install-deps` launches headless Chromium 151.0.7922.34 and loads a page; on the old image the identical script fails on libglib-2.0.so.0. `install.rs` no longer runs `install-deps` unconditionally — that would be a minutes-long apt run for nothing on a current image. It asks `install-deps --dry-run` first and skips the install when everything is present, saying which of the two happened on the progress stream. The check is Playwright's rather than a probe of our own for library names, so check and fix cannot disagree about what the dependency set is. Note that `--dry-run` exits 0 both when everything is installed and when Playwright has no list for the platform, so the verdict is read from its output. Containers on older images stay the normal case until people migrate, and they still work: on such an image the simulation cannot even resolve the package names (the index is cleaned in every base image), which reports as "couldn't tell" and installs — the right answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k |
||
|
|
a5bcc462a7 |
Browser view: find every Playwright, and set one up in two clicks
Build App / compute-version (pull_request) Successful in 14s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m18s
Build App / build-linux (pull_request) Successful in 6m42s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Detection missed the npx cache, so a Playwright installed through Claude
Code's MCP setup (`npx @playwright/mcp@latest`, which unpacks into
~/.npm/_npx/<hash>/node_modules and no node_modules at all) was invisible.
The probe now globs that cache alongside the existing roots and reports
every root it consulted.
It also read `has_bind` off whichever manifest resolved first. Verified
that npm does not hoist for global installs and that the `playwright`
wrapper ships no types/types.d.ts, so `npm i -g playwright` made the pane
call a current build "predates browser.bind()". The probe now hops from
the wrapper to its nested playwright-core.
The messages no longer offer `@playwright/mcp` as a way through setup: it
bundles a playwright-core that binds but never `@playwright/cli`, so that
route could not have worked. It is named only for what it does do.
New `install.rs` + two commands do the setup, streaming on the existing
`container-progress` event and re-probing on success:
* playwright + @playwright/cli into /workspace as `claude`, --no-save.
/workspace is not a bind mount (projects mount at
/workspace/{mount_name}), so nothing of the user's is touched, no sudo
is needed, and Node resolves it from scripts in the project.
* A browser, as its own action with the size stated first: apt libraries
as root, then the download, then a real headless launch to prove it
works. The base image ships none of Chromium's shared libraries, which
is why a download could succeed and the browser still not start.
Chromium and the Chrome channel are both offered — @playwright/mcp
asks for `chrome` specifically. A certificate failure is reported as a
container trust-store problem rather than a broken install.
Installing is always user-initiated; opening the tab only probes.
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 |
||
|
|
2c014fd752 |
CI: create the WOW64 junctions in the workflow instead of by hand
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m29s
Build App / build-windows (pull_request) Successful in 5m52s
Build App / build-linux (pull_request) Successful in 6m12s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
The Windows fix was the only part of it living outside git — two junctions created by hand on the build VM. Rebuild that VM, add a second Windows runner, or reset the SYSTEM profile and Windows builds break again with an error that points nowhere near the cause. Tauri downloads candle.exe, light.exe and makensis.exe, and all three are 32-bit. A runner running as SYSTEM has %LOCALAPPDATA% under C:\Windows\System32\config\systemprofile, and WOW64 redirection serves 32-bit processes reading System32 from SysWOW64, where those directories do not exist. The bundlers cannot see their own folder: candle exits 0x80131700, makensis reports "Unable to start child process, error 0x2", and Tauri surfaces neither — only "failed to run candle.exe". The job now junctions the SysWOW64 view onto the System32 originals when it detects a profile inside System32, and skips entirely otherwise, so a runner running as a normal user is unaffected. Idempotent, and written with goto rather than nested blocks to avoid the delayed-expansion trap that already bit the MSVC step. Verified rather than assumed: the hand-made junctions were deleted from the build VM before this was pushed, so this run has to recreate them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0aa8315514 |
CI: restore the MSI now that the 32-bit bundlers can resolve their paths
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 32s
Build App / build-macos (pull_request) Successful in 2m26s
Build App / build-windows (pull_request) Successful in 4m51s
Build App / build-linux (pull_request) Successful in 6m11s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Dropping the MSI did not help: makensis.exe is 32-bit like candle.exe
and failed the same way ("Unable to start child process, error 0x2").
The cause was WOW64 redirection sending 32-bit processes reading
C:\Windows\System32 to SysWOW64, where the toolset directory does not
exist.
The build VM now carries junctions from the SysWOW64 view of
systemprofile\AppData\Local\tauri and systemprofile\.cache to the
System32 originals. Verified on the runner: candle.exe reports WiX
3.14.1.8722 and makensis reports v3.11, both exiting 0 from the path
that previously failed.
Both targets build again, so the .msi comes back. Artifact collection
fails if either installer is missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
29fd7de909 |
CI: restore the MSI now that the 32-bit bundlers can resolve their paths
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m40s
Build App / build-macos (pull_request) Successful in 2m32s
Build App / build-windows (pull_request) Successful in 5m13s
Build App / build-linux (pull_request) Successful in 6m29s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Dropping the MSI did not help, because the problem was never WiX. makensis.exe is 32-bit exactly like candle.exe and light.exe, lives in the same SYSTEM-profile cache, and failed the same way — "Unable to start child process, error 0x2" instead of 0x80131700. The cause is WOW64 redirection: a 32-bit process reading C:\Windows\System32 is served C:\Windows\SysWOW64, where the toolset directory does not exist, so the bundlers cannot see their own folder. The build VM now carries two junctions from the SysWOW64 view of systemprofile\AppData\Local\tauri and systemprofile\.cache to the System32 originals. Verified afterwards on the runner: candle.exe reports "WiX Toolset Compiler version 3.14.1.8722" and exits 0, and makensis reports v3.11 and exits 0 — both from the same path that failed before. So both targets build again and the .msi asset comes back. Artifact collection fails if either installer is missing rather than tolerating an empty directory. This is a host-side patch for a runner running as SYSTEM. A runner running as a normal user has a LOCALAPPDATA outside System32 and needs none of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fdc161fd9c |
CI: build NSIS only on Windows, dropping the MSI target
Build App / compute-version (pull_request) Successful in 17s
Build Container / build-container (pull_request) Successful in 1m22s
Build App / build-macos (pull_request) Successful in 2m22s
Build App / build-windows (pull_request) Failing after 4m41s
Build App / build-linux (pull_request) Successful in 5m51s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
WiX's candle.exe/light.exe are 32-bit. On a SYSTEM-run runner Tauri caches WiX under C:\Windows\system32\config\systemprofile\..., and WOW64 redirection sends 32-bit processes to SysWOW64 where that directory does not exist, so candle exits 0x80131700. Tauri aborts the whole bundle on one target's failure, so the MSI was suppressing the NSIS installer too and Windows produced no artifact at all. NSIS is what the project already relies on for Windows upgrades. Drops the .NET 3.5 gate, which existed only for WiX; keeps the MSVC step, which is what makes the app link. Artifact collection now fails when no installer is produced rather than tolerating an empty directory. To restore the MSI, run the runner as a normal user and set --bundles msi,nsis. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
98a6c8fd56 |
CI: build NSIS only on Windows, dropping the MSI target
The MSI target needs WiX, whose candle.exe and light.exe are 32-bit. On a runner running as SYSTEM, Tauri caches the WiX toolset under %LOCALAPPDATA% = C:\Windows\system32\config\systemprofile\..., and WOW64 redirection points 32-bit processes at SysWOW64, where that directory does not exist. candle.exe cannot see its own folder, the CLR fails to start, and it exits 0x80131700 — surfaced only as "failed to run candle.exe". Proven by running the identical toolset, as the same SYSTEM identity, from C:\wixtest (exit 0) versus the systemprofile path (0x80131700). Because Tauri aborts the entire bundle when one target fails, the MSI was also suppressing the NSIS installer — so Windows produced no artifact at all. NSIS is what the project already relies on for Windows upgrades, so dropping MSI costs the .msi asset and nothing else. Removes the .NET 3.5 gate, which only existed for WiX. The MSVC step stays: that is what makes the app link, and it works. Artifact collection now fails when no NSIS installer is present instead of tolerating an empty directory with 2>nul, so a silent packaging regression cannot pass as a green build again. To restore the MSI later, run the runner as a normal user — whose LOCALAPPDATA sits outside System32 — and set --bundles msi,nsis. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
763af91042 |
Revert: LOCALAPPDATA override does not move Tauri's WiX cache
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 32s
Build App / build-macos (pull_request) Successful in 2m23s
Build App / build-windows (pull_request) Failing after 4m35s
Build App / build-linux (pull_request) Successful in 5m6s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Rust's `dirs` crate resolves LOCALAPPDATA on Windows through SHGetKnownFolderPath, which reads the process token rather than the environment, so the override changed nothing and 32-bit candle.exe still hit WOW64 redirection under the SYSTEM profile. Removing it rather than leaving a plausible-looking non-fix in the workflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c71e54a35f |
Revert: LOCALAPPDATA override does not move Tauri's WiX cache
It looked right and did nothing. Rust's `dirs` crate resolves LOCALAPPDATA on Windows through SHGetKnownFolderPath, which reads the process token rather than the environment, so Tauri still cached the WiX toolset under the SYSTEM profile and 32-bit candle.exe still hit WOW64 redirection. Removing it rather than leaving a plausible-looking non-fix in the workflow. The diagnosis in the previous commit stands; only the remedy was wrong. Running the runner as a normal user, whose token resolves LOCALAPPDATA outside System32, is the actual fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
03384409e7 |
CI: keep the WiX toolset out of System32 so 32-bit candle.exe can run
Build App / compute-version (pull_request) Successful in 13s
Build Container / build-container (pull_request) Successful in 44s
Build App / build-macos (pull_request) Successful in 2m24s
Build App / build-windows (pull_request) Failing after 4m39s
Build App / build-linux (pull_request) Successful in 6m44s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
WiX's candle.exe/light.exe are 32-bit. A SYSTEM-run runner has %LOCALAPPDATA% under C:\Windows\system32\config\systemprofile, where Tauri caches the WiX toolset — and WOW64 redirection sends 32-bit processes reading System32 to SysWOW64, which has no such directory. The CLR then fails to start with 0x80131700 and Tauri reports only "failed to run candle.exe". Verified: the same binary and identity exits 0 from C:\wixtest and 0x80131700 from the systemprofile path. Pointing LOCALAPPDATA outside System32 avoids redirection, needs no stored credential, and is a no-op for runners already running as a normal user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b41077e799 |
CI: keep the WiX toolset out of System32 so 32-bit candle.exe can run
build-windows compiled and linked fine but died at bundling with only "failed to run candle.exe". The real cause was neither .NET nor the runner identity, both of which I chased first and was wrong about. candle.exe and light.exe are 32-bit. A runner running as SYSTEM has %LOCALAPPDATA% = C:\Windows\system32\config\systemprofile\AppData\Local, which is where Tauri caches the WiX toolset. WOW64 redirection sends any 32-bit process reading C:\Windows\System32 to C:\Windows\SysWOW64 — and the WixTools directory exists only in the 64-bit view. So candle.exe could not see its own directory, the CLR failed to start, and the process exited 0x80131700, surfaced in the Application event log as ".NET Runtime version 4.0.30319.0 - This application could not be started." Proven rather than assumed: copying the identical toolset to C:\wixtest and running it as the same SYSTEM identity exits 0, while the systemprofile path exits 0x80131700. Test-Path confirms the WOW64 view of that directory does not exist. Pointing LOCALAPPDATA at a path outside System32 avoids redirection. This fixes it for any runner running as a service or as SYSTEM, without needing a stored user credential, and is a no-op where the runner already runs as a normal user. For the record, two earlier theories were wrong. .NET 3.5 was missing and is now installed from the ISO payload, but candle targets .NET 4.x (its config uses loadFromRemoteSources, a 4.0-only element) so that was never the blocker. Adding explicit supportedRuntime entries changed nothing. Both are documented here so the next person does not repeat them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c6bb7fdf1d |
CI: fix the MSVC exit-code check and verify .NET 3.5 before bundling
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m48s
Build App / build-windows (pull_request) Failing after 5m14s
Build App / build-linux (pull_request) Successful in 6m2s
Build App / build-macos (pull_request) Successful in 2m24s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
%VSEXIT% and %ERRORLEVEL% inside a parenthesised cmd block are substituted at parse time, not run time, so the installer's real exit code was never read. Uses delayed expansion now. Also checks for the .NET 3.5 runtime before building: WiX candle.exe needs it, and Tauri aborts the whole bundle when the MSI target fails, which silently suppresses the NSIS installer too. Fails early with the exact dism command rather than at bundle time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
704d3b8f79 |
CI: fix the MSVC exit-code check and verify .NET 3.5 before bundling
Two follow-ups to the provisioning step. The exit-code check never worked. %VSEXIT% and %ERRORLEVEL% inside a parenthesised cmd block are substituted when the block is PARSED, not when it runs, so the installer's real result was never read — the log printed "installer failed with " with an empty code, then continued anyway. It happened to be harmless because the install had in fact succeeded, but a genuine failure would have sailed past. Now uses delayed expansion. Added a .NET 3.5 check. WiX 3.x candle.exe is a .NET 2.0/3.5 application, and Tauri aborts the entire bundle when the MSI target fails — so a missing runtime silently costs the NSIS installer too, not just the MSI. Windows 11 ships NetFx3 as DisabledWithPayloadRemoved and Windows Update could not supply the payload on our runner even across a reboot; it needed /Source from a mounted ISO. Rather than guess, the job now fails early with the exact dism command. Verified on the runner: MSVC Build Tools 2022 installed, the Rust build completed in 3m59s and produced triple-c.exe, and NetFx3 is now Enabled with v2.0.50727 present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ca0f944712 |
CI: install MSVC build tools on Windows runners that lack them
Build App / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 30s
Build App / build-linux (pull_request) Successful in 5m24s
Build App / build-windows (pull_request) Failing after 15m29s
Build App / build-macos (pull_request) Successful in 2m24s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
build-windows failed on this PR with "linker `link.exe` not found", while build-linux and build-container passed — the code was fine, the runner environment was not. The job installs Rust and Node conditionally but assumed the MSVC C++ toolchain was hand-provisioned. A runner without it registers normally, advertises windows-latest, accepts the job, downloads the entire crate graph and only then fails at link time. That also means a bare runner coming online turns a job that would have queued for a capable machine into a failed build. Installs the VC++ workload when vswhere cannot find it, matching the existing conditional Rust and Node steps. rustc locates MSVC through vswhere and the registry rather than PATH, so no dev-shell activation is needed. Installer exit 3010 (success, reboot pending) is treated as success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |