5d16b5713dce1ea24952e54319ec714a77eee488
37
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c0e4c87cec |
Give the mouse back, retire the follow controls, update Claude per session
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-linux (pull_request) Successful in 5m8s
Build App (Preview) / build-windows (pull_request) Successful in 6m28s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
Build Container / build-container (pull_request) Failing after 14m49s
Three things the terminal was getting wrong. **A program that grabs the mouse and dies used to freeze the tab.** A TUI sets DECSET ?1000/?1002/?1003; if it exits without resetting them, xterm keeps routing clicks, drags and — under ?1003 — every pointer *move* to the PTY. Text selection dies and escape bytes flood the prompt. The only exit was closing the tab. `TerminalView` now reconciles a flag against `term.modes.mouseTrackingMode` in the `term.write()` callback — the mode only changes because the container printed a sequence, so one check per write catches every transition with no polling — and `Ctrl+Shift+X` or a status-bar button writes the resets back through `term.write`, never `sendInput`: the reset belongs to xterm's parser, and a still-live TUI told about it would just re-grab on its next repaint. The control is in the status bar deliberately. Mouse tracking is the *normal* state of htop, vim, lazygit and Claude Code, so a badge over the terminal would be on screen for the whole life of those programs and would swallow clicks aimed at their own top-right corner. `macOptionClickForcesSelection` is also on now: xterm's force-select is Shift everywhere except macOS, where it is Option and is gated behind that option, which defaults to false — so until now Mac users had no way to select text while a program held the mouse. **"Following" and "Jump to Current" are gone.** Claude Code draws on the alternate screen, which has no scrollback, so `viewportY` always equalled `baseY` and neither control could do anything. They did still work in bash tabs; xterm's native follow covers that, and the per-write `scrollToBottom()` went with them because it fought exactly that. What remains, on activate and after a refit, now samples `viewportY >= baseY` *before* the fit, so opening the Notes dock no longer yanks a reader to the tail. **`claude update` runs before every Claude session, not just at container start.** Containers here stop/start and often just keep running, so a long-lived one never re-checked. Both copies take the same flock: the entrypoint prints "container ready" only after its own update finishes, so opening a tab immediately would otherwise run two updaters against the same ~/.claude/bin, with `|| echo` hiding a half-written install one line before `exec claude` ran it. This turns the non-Bedrock path from a bare argv into a `bash -c` wrapper, so flags and session names are shell-interpolated now and must go through `shell_quote_arg`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0145mQi9NZiCDrznBUEEDE4n |
||
|
|
9fadfbc37a |
Make the AppImage updatable, and drop the deb and rpm
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m43s
Build App (Preview) / build-windows (pull_request) Successful in 4m51s
Build App (Preview) / build-linux (pull_request) Successful in 5m19s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
An AppImage manager can adopt the current build but never update it: the image carries no update information, which is the string that tells such a tool where to look for a newer one. It also carries no AppStream metadata, so a manager has nothing to show but a filename — appimagetool has been warning about that on every build — and linuxdeploy leaves `Categories=` empty, which files the app nowhere in a desktop menu. All three are fixed while the image is already unpacked for the wayland fix, so the cost is a few lines rather than a second pass. `unbundle-wayland-client.sh` is now `finalize-appimage.sh`, since it does more than unbundle. The update URL is a **fixed** `linux-latest` tag on the GitHub mirror, which is where updates are pulled from — deliberately not `releases/latest`. `latest` follows whichever release is newest, and the Gitea-to-GitHub backfill creates one GitHub release per Gitea tag, including the `-win` and `-mac` tags that carry no AppImage. A URL that can resolve to a release with no AppImage in it fails on users' machines and nowhere else. The output is named for that tag too, and that is not cosmetic: zsync records a *relative* filename which a client resolves against the .zsync URL it fetched, so a versioned name would send every client after the build it already has. Verified by reading the generated header — `Filename: Triple-C_x86_64 .AppImage` — and the image's own `.upd_info` section, which is where the tag actually lives. My first guard checked the .zsync for the tag and failed correctly, which is how that distinction got found rather than shipped. Range requests were confirmed against the mirror before building on them: 206 with a correct content-range, so updates are real deltas rather than an 85 MB re-download. The .deb and .rpm go. They are two more artifacts to build, publish and keep working for an audience already served by the one file that runs on every distribution, and neither could ever self-update — which is now the difference that matters. Older releases keep theirs. The Linux job passes `--bundles appimage` rather than changing `tauri.conf.json`, so macOS and Windows are untouched. Verified against the real 0.4.19 artifact: it repacks, the AppStream file and filled-in Categories land inside the image, the update string resolves to the fixed tag, and the wayland fallback still holds. Both publisher failure paths refuse rather than half-publishing — no token, and missing artifacts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm |
||
|
|
7a5c0c1f13 |
Retire the Arch package, document AppImage desktop integration
The `triple-c-bin` package was never on the AUR, so installing it meant
downloading a file and running `pacman -U` — the same gesture as making an
AppImage executable, for a second artifact to keep building. And being
`workflow_dispatch`-only it reached 1 release in 28 (only v0.4.16 has a
`.pkg.tar.zst`), while HOW-TO-USE.md told Arch and CachyOS users to download
it from every release. A distribution channel that is absent 27 times out of
28 is worse than not promising one.
`packaging/arch/` and `.gitea/workflows/publish-arch-package.yml` are
preserved whole on `hold/arch-packaging`, the same way the disk panel and
drag-out work were held rather than deleted. What would make an Arch package
worth having is an AUR account and its SSH key as a repo secret — both
one-time manual steps that never happened; the workflow's own header already
said as much about its AUR push step.
This also closes the gap that prompted the review: nothing validated the
PKGBUILD until someone manually dispatched the workflow, making it the only
packaging path with no CI coverage. Removing it removes the untested surface
rather than adding a job to test something nobody installs.
In its place, `scripts/install-appimage.sh` does what a package manager's
install hooks would. An AppImage carries a `.desktop` entry and icons inside
itself, but nothing on the host reads them, so it never appears in the app
launcher. The script extracts the bundled icons into the user's icon theme
and writes a launcher entry — no sudo, nothing outside `~/.local/share`, and
the AppImage itself is never copied or moved.
Two details it gets right on purpose:
* The `Exec` line is rewritten, not copied. The bundled entry says
`Exec=triple-c`, which resolves only inside the running AppImage's own
mount — a verbatim copy gives a launcher entry that starts nothing.
* Extraction uses `--appimage-extract`, which needs no FUSE, so the script
works on a machine where *running* the AppImage would first need
`fuse2` installed. That requirement is now documented too: Arch and
CachyOS do not ship FUSE 2 by default.
Verified against the real artifact — the AppImage from this repo's own
preview-3a49a67 release: 4 icon sizes install, `desktop-file-validate` passes
with no warnings, `--uninstall` leaves nothing behind, and shellcheck is
clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApLYH6ybHwQFkMCtKuHrrV
|
||
|
|
97e58db3c1 |
Close gateway-secret desync, TOCTOU, and undisclosed custom-image gaps
Secret Scan / scan (push) Successful in 6s
Build App (Preview) / compute-version (pull_request) Successful in 5s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m53s
Build App (Preview) / build-linux (pull_request) Successful in 7m5s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Round 4 review findings: - Disclose and warn on a custom Docker image the import would set (HIGH): it's the image every project container is created from, so an undisclosed change here was a sharper version of the redirected-base-URL problem round 3 already flagged for the model backends. - Recreate a running gateway container when an import restores a new secret with the shape unchanged (MEDIUM): reconcile_gateway's shape comparison can't see a secret-only change, so the container would otherwise keep serving old key material indefinitely. - Report keychain write failures back to the caller instead of only logging them (MEDIUM): apply_settings_import now returns SettingsImportOutcome with secret_restore_warnings so a partial restore can't read as unqualified success. - Pin a hash of the previewed file's ciphertext and refuse to apply if it changed on disk (MEDIUM): closes a TOCTOU between preview and apply. - Sanitize and cap every free-form string a preview surfaces, and move the warning boxes above the replace list in the UI (MEDIUM): an unbounded base URL or image name could otherwise push the security warnings below the scroll fold. - Validate the Docker socket path on import the same as the SSH key and CA cert paths (LOW): it was the one mounted host path validate_settings_update didn't cover. - Fix ExportedSecrets::is_empty() to treat whitespace-only as blank, like every other secret-presence check in this feature (LOW). - Authenticate the file header as AEAD associated data (LOW, defense in depth) and correct two doc comments that overstated the password not being cached. |
||
|
|
a606e3ab20 |
Validate settings imports before writing secrets; disclose base URLs
Secret Scan / scan (push) Successful in 14s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m43s
Build App (Preview) / build-windows (pull_request) Successful in 4m59s
Build App (Preview) / build-linux (pull_request) Successful in 7m29s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
A rejected import (bad env var name, disallowed host path) used to leave keychain secrets already overwritten while the settings themselves stayed unchanged. apply_settings_import now runs update_settings's validation (extracted into validate_settings_update) before any secret write. Also from this review round: sharpened two format-version tests that previously passed against the pre-fix code too, added a direct test for split_settings_and_secrets, warned on a dormant web terminal token even when the terminal import leaves it off, matched the password-length check to the frontend's unit of measure, zeroized the export plaintext buffer, and surfaced non-blank Ollama/llama.cpp/OpenAI-compatible/gateway base URLs in the import preview so a traffic redirect isn't silent. |
||
|
|
925e51e435 |
Fix a real credential-leak vector a review found, plus four smaller issues
Secret Scan / scan (push) Successful in 12s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m51s
Build App (Preview) / build-linux (pull_request) Successful in 5m12s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
The headline finding: WebTerminalSettings::access_token is a live bearer credential for a server that binds every interface, stored as a plain field on AppSettings — which this feature was exporting and importing wholesale as if it were as inert as a port number. A crafted export file could set web_terminal.enabled and access_token together, and importing it (with no more warning than any other setting change) would silently stand up a LAN-listening terminal server with an attacker-known token on the victim's next launch. Fixed by carving the token out into ExportedSecrets, same as the other three global secrets, with the same "only overwrite what the import actually has" treatment — except that has to be done by hand here, since this one lives inside the AppSettings blob that gets replaced wholesale rather than in the keychain. Added SettingsImportPreview:: enables_web_terminal so "this turns on a listening service" gets its own visible warning in the confirmation modal rather than hiding inside a generic "settings replaced" bullet list. Also fixed: - read_and_decrypt checked format_version only after attempting to parse the full payload, so a future version bump that isn't deserialize-compatible would fail on the shape mismatch before the version check ever ran — and serde's type-mismatch errors quote the offending value inline, which is a real leak path since the plaintext here can hold a live credential. Now probes just the version field first, and neither error path interpolates the underlying serde message into what the user sees. - apply_settings_import cleared the pending-import path before it could fail, so a rejected import (an invalid host path, anything update_settings validates) dead-ended the modal with no way back except cancelling and reopening the file picker. The path is now only cleared on success. - Secrets are restored before the settings replace runs, not after — replacing settings is what triggers reconcile_gateway, and restoring secrets afterward left a real window where a gateway recreation happened against the destination's stale keys. - The 8-character password minimum was frontend-only; export_settings now enforces it too, since that's the actual boundary a weak password has to cross. The derived key and decrypted plaintext are wrapped in zeroize::Zeroizing (already in the tree via aes-gcm). Added test coverage the review named as missing: format-version ordering, the generic-error-message guarantee, non_blank's blank-vs- absent handling, and the new web-terminal preview/warning behavior on both sides of the IPC boundary. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ |
||
|
|
722d9aeff1 |
Add password-encrypted settings export/import
Secret Scan / scan (push) Successful in 8s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 9s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m59s
Build App (Preview) / build-linux (pull_request) Successful in 6m29s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Closes #35. Exports the host environment — global AppSettings (already the non-secret shape persisted to settings.json) plus the global secrets that live in the OS keychain instead (the shared Claude Code OAuth login, the model gateway's provider API key and master key) — to one password-encrypted file, and restores it on another machine. Per-project settings, per-project secrets, and Docker volumes are deliberately out of scope; this is not a project backup. Designed with the user in issue #35's comments: global settings only, no docker volumes, the password is the lock/key, and the export is portable as one file. Crypto (storage/settings_crypto.rs): Argon2id derives a 256-bit key from the password (memory-hard, meaningfully resistant to GPU/ASIC brute-forcing in a way PBKDF2 at any reasonable iteration count is not), AES-256-GCM does the actual encryption. A wrong password fails GCM's authentication tag rather than producing silent garbage. Salt and nonce are random per export and stored in the clear in the file header — their job is uniqueness, not secrecy. The save/open dialogs are opened from Rust, matching the boundary file_commands.rs's pick_save_path/pick_files_to_upload already establish: a frontend-driven dialog handing Rust a host path is the exact shape of bug that produced this app's past criticals. preview_settings_import resolves the chosen import path itself and remembers it (AppState::pending_settings_import) so apply_settings_import re-reads the same file without a path crossing back over IPC. The password is re-entered rather than cached between preview and apply, so nothing here holds decrypted plaintext in memory for longer than one command's execution; the preview returned to the frontend carries counts and presence flags only, never a secret value. Import replaces settings wholesale (an import is "restore this environment"), but only writes secrets actually present in the file — an absent secret means "the source machine never had this configured," not "delete this on import." Added storage::secure::store_gateway_master_key and get_gateway_master_key (read-only, unlike get_or_create_gateway_master_key which mints one as a side effect) since neither existed and import needs to restore an exact captured value rather than mint a new random one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ |
||
|
|
68b73a9102 |
Refuse a commit that adds something shaped like a credential
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 5s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m34s
Build App (Preview) / prune-previews (pull_request) Canceled after 0s
Build App (Preview) / build-linux (pull_request) Canceled after 3m23s
Build App (Preview) / build-windows (pull_request) Canceled after 3m26s
The companion to the fixture removal. A site-admin token sat in a test file for
92 commits and fourteen days on a public mirror, past five audit rounds and two
independent reviews, because all of them read the code under change and this was
not under change. A grep would have caught it the first day.
`scripts/scan-secrets.sh` is that grep, in three rules:
* vendor-prefixed credentials — `ghp_`, `github_pat_`, `glpat-`, `xox*-`,
`sk-`, `AKIA`/`ASIA`, `ya29.`, `AIza`, `npm_`, `dckr_pat_`. Shape alone
identifies these, so there is no context to get wrong.
* `BEGIN … PRIVATE KEY` blocks.
* an opaque literal assigned to a secret-shaped name — the rule that would
have caught this one.
The third rule needs **both** halves, and that is what makes it usable rather
than another disabled check. Measured before writing it: an entropy-only rule
flags 317 literals in this tree, and name-proximity alone flags four, three of
which are `secure::get_project_secret(&id, "aws-secret-access-key")` — a
keychain *key name* sitting next to the word `secret`. Requiring the literal
itself to be hex or base64 with no word structure is what excludes those.
Validated rather than asserted:
* **0 false positives** across every tracked file.
* **Catches the real incident** — `--range 9b2f4fe~1..9b2f4fe` is refused.
* Twelve shaped cases pass and fail as intended, including a sha256 in an
`assert_eq!`, a git sha in a comment and the new dummy fixture, none of
which trip it.
* The hook was proved to block an actual `git commit`, not just to exist.
Two halves, because each covers the other's gap:
* `.githooks/pre-commit`, enabled per clone by `npm run hooks`. Git will not
let a repository set its own hooks path — cloning would then be enough to
run its code — so this is opt-in everywhere and `--no-verify` skips it.
* `Secret Scan`, which nobody can bypass. It carries **no `paths:` filter** on
purpose: the leak lived in `app/**` and `build.yml` only runs for
`container/**`, so a path-filtered scan would have missed the very thing it
exists for. It scans the whole tracked tree rather than a range, because a
wrong range fails *open* and the full pass takes 0.5s.
Also fixed while here: `core.hooksPath` in this clone pointed at
`/workspace/.git/hooks`, a directory that does not exist — so git hooks were
disabled outright and anything dropped in `.git/hooks` would have been ignored
in silence. A hook that never runs is worse than no hook, because the checklist
says it is there.
`--tracked` skips binaries. Feeding a blob to grep gets "binary file matches"
instead of the line, so a genuine finding inside one would arrive as a sentence
nobody can act on.
A line ending `pragma: allowlist secret` is skipped — wordy on purpose, so it
reads as a claim and leaves something greppable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
|
||
|
|
2c9482a67d |
Give the Files tab back its uploads and downloads
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 1m35s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 5m51s
Build App (Preview) / build-linux (pull_request) Successful in 6m50s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
`upload_file_to_container` and `download_container_file` existed on main before
any of this work started. "Ship the Files tab container-side only" removed them
and called it narrowing scope; from a user's side it was a regression they
upgraded into. This restores the feature.
The reason for the removal was real — four consecutive audits found their
criticals in host paths crossing IPC — so the feature comes back only in the
shape that removes the class rather than patching it a fifth time. The dialogs
are opened by **Rust** (`pick_save_path`, `pick_files_to_upload`), not by the
webview. A frontend `open()`/`save()` handing the backend a path string is
exactly what failed, and the backend cannot tell such a string from one a
compromised webview invented. Now the webview can ask for a picker and that is
the whole of its influence: it cannot name a host path as an input. That is the
shape the previous round's own notes named as the honest one if this ever
returned.
None of the machinery the audits condemned returns. No `link(2)` destination
reservation, no placeholder rollback, no collision marker: the OS save dialog
already asks about overwriting and Docker's extractor overwrites on upload the
way `cp` does, so there was nothing left for it to do. Download reuses the
sequence `download_container_backup` has been using unchanged — resolve, stream
into a partial file beside the destination, rename last — so a failed transfer
never touches the file that was already there. Upload reuses the terminal
drop's hardened uploader, with the container's uid/gid resolved once per
selection rather than once per file.
Against a container that is actively hostile rather than merely surprising:
* the read is `dd iflag=nonblock`, not `cat`. `[ -f ]` and the `open` after it
are two syscalls and the container owns the filesystem in between; a loop
swapping the file for a FIFO wins that race, and `cat` then blocks forever
with no writer and no timeout anywhere on the path — the `invoke` never
settles and a partial is left in the user's directory for good. Verified in
a real container that `cat` hangs, that `iflag=nonblock` returns, and that
it is byte-identical on a regular file.
* the read is bracketed by a second `[ -f ]`, because non-blocking turns that
hang into an empty file that would otherwise be renamed over the
destination and reported as a successful save.
* an *undeterminable* exit code is a failure. Backup catches this class with
its `total == 0` check, which download cannot have because an empty file is
a legitimate save; without a replacement, a project restarted mid-download
renames a truncated partial over the user's file and reports the byte count
as if it were whole.
* container stderr is capped. Every other reader of container output in the
tree is capped for this reason; the two streaming commands were the
exception, and stdout was bounded by disk while stderr was bounded by
nothing.
* the script's refusals are framed rather than used verbatim, so a directory
named to look like one of our own sentences cannot become the toast
headline through `readableRefusal`.
* the partial name is capped at NAME_MAX. A bundler's 230-character content
hash is a name that fits its directory and produces a partial name that
does not.
Also: a non-UTF-8 dialog path is refused by name rather than silently mangled
into a different path by U+FFFD substitution; both actions carry in-flight
state, so a second click cannot open a second dialog and a slow save is not
indistinguishable from a dead button; and the upload's completion message names
the directory, since the picker is modal and the user can browse elsewhere
while it is open.
Not restored: drag-and-drop, in either direction. `drag:allow-start-drag` stays
ungranted and `hold/disk-and-dragout` still holds that work.
Two bugs the new tests caught while being written: a double-click on "Save to
host…" opened the file viewer on top of the save dialog, and an N-file upload
made N redundant execs to re-ask `id -u`.
Docs that asserted this feature did not and must not exist are corrected —
CLAUDE.md, README, HOW-TO-USE, TECHNICAL and the capability threat model. The
"no host path crosses IPC" claim is deliberately narrowed to the inbound
direction: paths do still travel outward inside error text, canonical ones
included, and the reviewed record should not overstate.
600 frontend tests, 473 Rust, no new clippy warnings. Every new test was
mutation-checked; four that survived their first mutation were rewritten,
including two whose mutations turned out to be unfaithful and one that was
blind to a dismissal leaving a row stuck on "Saving…".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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 |
||
|
|
7a5823cb2b |
Inject the corporate CA certificate into containers
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Behind a TLS-terminating corporate proxy every HTTPS call inside a container fails — npm, pip, git, curl, the browser-view pane, and Claude Code's own API requests. There was no mechanism at all: installing the certificate by hand inside a container is lost on Reset and had to be repeated per project. A global CA path in AppSettings with a per-project override on Project, taking either a single certificate file or a directory. It is bind-mounted read-only at /tmp/.host-ca (mirroring /tmp/.host-ssh and /tmp/.host-aws) and applied by entrypoint.sh on every start, so it survives recreation, migration and Reset. Four things this gets right that are easy to get wrong: * update-ca-certificates globs *.crt case-sensitively, so a .pem that is merely copied in is ignored in silence. Certificates are renamed, by container_cert_name() in Rust and a mirrored few lines of shell. * The system store only serves curl/git/apt. Node — and so Claude Code itself — needs NODE_EXTRA_CA_CERTS, Python needs REQUESTS_CA_BUNDLE/SSL_CERT_FILE, and Chromium reads neither: it wants ~/.pki/nssdb, seeded with certutil (libnss3-tools, added to the image). * Those vars are set from Rust at creation, never exported by the entrypoint — a terminal is a docker exec and sees nothing the entrypoint exported. They are emitted empty when no CA is configured, since docker commit bakes env into the snapshot image. * triple-c.ca-fingerprint hashes the certificate bytes as well as the path, so a CA rotated in at the same location still forces a recreation. Verified end to end against a real container and a self-signed CA: curl, node, python and git all complete a TLS handshake against a server signed by it and all three fail in the same container without it; the env vars are visible from a docker exec session; the store is cleaned when the setting is cleared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k |
||
|
|
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
|
||
|
|
2de00b3c55 |
Fix review findings: secrets in snapshots, URL spoofing, migration data loss
Adversarial review of the branch produced findings across four areas. This addresses them, plus the Windows CI environment. Secrets. commit_container_snapshot baked the container's full env into the per-project snapshot image, so the shared OAuth token — and the AWS keys, git token and gateway master key — outlived revocation and were readable via docker inspect. Verified against Engine 29.6 that a commit body's config merges over the container's: keys cannot be dropped but can be overwritten, so all of them now commit as KEY=. clear_claude_token additionally rewrites images from earlier builds and reports honestly when a tag could not be rewritten. The recommendation to move the token out of env entirely was not taken, with reasoning: apiKeyHelper is a different auth method that outranks CLAUDE_CODE_OAUTH_TOKEN rather than a transport for it, and no file-based delivery exists. The durable exposure — the image — is what is closed here. Separately noted, not fixed: entrypoint.sh captures the token into the scheduler's .env inside the persisted volume. URL spoofing. Three call sites reached openUrl with container-controlled strings, one of which the review missed (the WebLinksAddon handler). The sign-in URL was scraped from container output with a longest-match tie-break and no userinfo check, so claude.ai@evil.tld rendered as "claude.ai…" in a truncating element. There is now one sanitizer in front of every sink — scheme allowlist, no userinfo, C0/C1 and quote rejection, host allowlist for the sign-in case, first-match — and the origin renders un-truncated. The toast is keyed so a changed URL remounts, closing a bait-and-switch where the user read one URL and clicked another. Migration. The rollback pin was best-effort: a tag failure was logged and the migration continued past remove_container, after which the final commit overwrote the only copy of the old system layer. It now aborts before anything destructive and reads the tag back. /var was destroyed while the ordinary recreate path preserves it — making the "safe" alternative to Reset more destructive than Reset's alternative; data-bearing subtrees are now detected and disclosed in the pre-flight rather than copied, since tarring a live database onto a different base's packages is a corruption risk. resume_migration now verifies the migration-state label instead of reporting success for a container that never swapped. dismiss actually resolves the record rather than leaving the feature permanently refusing to migrate. Start and Reset are guarded while a migration is live. Lifecycle. The gateway no longer publishes on 0.0.0.0 — bind address and advertised URL are derived together so they cannot drift. Disabling it now stops it. App exit runs teardown concurrently under a budget with a visible shutting-down state instead of blocking for minutes. Auto-starts retry when Docker is not up yet, and the polling-recovery path now reconciles, so interrupted migrations are still recovered. Auth-bridge forwards are capped, closing a container-driven fd exhaustion. Windows CI. build-windows failed on this branch with "linker link.exe not found". The runner had no MSVC build tools and the workflow assumed a hand-provisioned machine, so a bare runner registers, accepts jobs and fails at link time after downloading the whole crate graph. The job now installs the VC++ workload when vswhere cannot find it, matching how it already conditionally installs Rust and Node. 192 Rust tests, 274 frontend tests, both builds clean, zero warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d42b741337 |
Migrate a project onto a new base image without losing its volumes
Projects were pinned to the image they were first created from. Both create paths preferred triple-c-snapshot-<id>:latest whenever it existed, and container_needs_recreation compared the container's live image against the triple-c.image label — which create_container wrote from the same image it created from. A tautology that could never fire. The only escape was Reset, which calls remove_project_volumes and destroys the login, skills and transcripts. Measured consequences on this host: real projects are missing socat (so the auth bridge cannot tunnel) and bubblewrap (so sandbox mode does not work), plus Mission Control and triple-c-sso-refresh, and sit 61 packages behind the base including ca-certificates, openssl and curl. Detection. create_container now writes triple-c.base-image-id (the image ID, not RepoDigests, which local-built and custom images do not have) and triple-c.create-image. container_needs_recreation takes the expected create-image and compares against the latter, so the check means something. base-image-id is deliberately NOT compared: a base bump would otherwise silently recreate from the snapshot, consuming the "you should migrate" signal without migrating. Staleness is surfaced, never acted on automatically. Migration keeps the volumes. /home/claude and ~/.claude are volumes and the image's copy is seed-only — permanently masked after first mount — so the login, ~/.claude.json, skills, transcripts, scheduler tasks, SSH keys, cargo, uv, ruff and Claude Code itself re-attach untouched. Only root-level state is rebuilt: apt packages are replayed against the new base rather than copied, so no stale libc is dragged forward, and /usr/local, /opt and the non-bind-mounted parts of /workspace are copied verbatim with tar --skip-old-files so they can never clobber a newer base binary. docker diff is not used: on a snapshot-derived container it reports only changes since the last commit. Raw image-vs-image diffing is filtered through dpkg ownership because it otherwise lies — 8,677 raw path differences on a real project reduced to 2 genuinely user-authored files, both loose /workspace-root files. Crash safety. snapshot:latest keeps pointing at the old image until the final commit, so any crash before it self-heals on next start. Later crashes are caught by reconcile_project_statuses. The rollback pin is a docker tag: 0.057s and 0 bytes. Rollback restores the system layer only — volumes are never touched — and the UI says so rather than implying a time machine. Fixes an infinite recreation loop shipped with the MCP removal. docker commit propagates labels to the image, so a container created from a snapshot inherited its non-empty triple-c.mcp-fingerprint and the one-shot shim recreated it again on every start, forever. Lineage labels are now always written explicitly. Documents the second, separate bug this uncovered: Dockerfile changes under /home/claude never reach an existing project, migration or not, because the volume masks them. Anything that must stay upgradable belongs in /usr/local/bin or /opt, or must be seeded by entrypoint.sh. 145 Rust tests, 227 frontend tests, both builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cc5f691677 |
Add llama.cpp backend, model gateway, URL relay and browser view
Four features, plus a latent bug fix.
llama.cpp backend. Claude Code only ever speaks the Anthropic Messages
API — confirmed empirically by pointing it at a logging server, which
received POST /v1/messages?beta=true. llama-server implements that
natively (verified in its README, alongside --port default 8080), so
this is a plain base-URL backend with no translation shim, the same
shape as Ollama. Its --api-key defaults to none, so the auth token is a
placeholder Claude Code requires and llama-server ignores.
Model alias fix. ANTHROPIC_DEFAULT_HAIKU_MODEL is documented as "also
used for background functionality", and Triple-C set none of the alias
vars. So on every custom-endpoint backend, Claude Code resolved `haiku`
to an Anthropic model id and sent it to a local server that does not
have it — background features failed silently. All four
ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL vars are now pinned to
the backend's configured model, with an optional Haiku override, and
blanked for Anthropic and Bedrock so those keep Claude Code's defaults.
The deprecated ANTHROPIC_SMALL_FAST_MODEL is never emitted. Existing
Ollama and OpenAI-Compatible containers are recreated once so the new
env reaches them; the snapshot is preserved.
Model gateway. Optional LiteLLM sibling container, off by default,
mirroring stt.rs — this is what makes real OpenAI usable, since
api.openai.com has no /v1/messages. Pinned to v1.96.0 by tag and digest:
the 1.82.7/1.82.8 malware was PyPI-only and never affected the official
images, which is precisely why this builds FROM the image rather than
pip-installing, but 1.84.0 is still the floor for proxy CVEs (API-key
SQLi, Host-header auth bypass, MCP auth bypass). Binds 0.0.0.0 because
project containers consume it, and therefore always sets a master_key —
LiteLLM without one accepts any key. The provider key lives in the OS
keychain and is uploaded into a volume, never an image layer or label.
URL relay. A container-side xdg-open/BROWSER shim opens URLs in the
host's browser. Uses an OSC sequence to /dev/tty rather than a printed
sentinel, because the shim usually runs as a grandchild of a process
capturing its children's output. Degrades to printing the URL when no
terminal is attached, so scheduled tasks do not hang. Only http/https,
with control characters rejected before new URL() — which strips
newlines, so java\nscript: would otherwise parse as javascript:. Nothing
auto-opens; the user confirms. The web terminal shows a tap-to-open
banner instead, since that browser may be a phone across a tunnel.
Browser view. A Project Home tab that watches and takes over the browser
Claude drives with Playwright, using Playwright's own dashboard. Zero
image cost — Playwright stays user-installed. It does not reuse the auth
bridge's PortForward, which binds an unauthenticated port: correct for a
throwaway OAuth listener, wrong for mouse and keyboard control of a
browser in a passwordless-sudo container. Instead a token-gated loopback
proxy checks Host, then token or a forbidden-header origin signal,
before a byte reaches the container. Host ports are confined to
47820..=47827 so CSP frame-src can enumerate them rather than widening
to a wildcard, with a test asserting the two agree.
188 frontend tests, 107 Rust tests, both builds clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d95ba54a69 |
Add shared-auth-token UI and make cancelling actually cancel
UI for the shared Claude token: a Settings section showing token state with Authenticate and Revoke, an acquisition modal built on the shared Modal (sign-in link handed to the host browser via the opener plugin, plus the code input that answers `setup-token`'s stdin prompt — the flow cannot complete without it), and a per-project opt-out toggle shown only for the Anthropic backend. Cancellation: acquire_claude_token previously had only two exits, completion and a 15-minute timeout, and held the single-flight guard for the whole time. Closing the dialog therefore locked the user out of retrying for up to 15 minutes. Adds cancel_claude_token, backed by a oneshot claimed and released in lockstep with the input guard, selected on in the run loop so it wins the race and tears the exec down. The dialog's Cancel now calls it and closes either way. Also refreshes CLAUDE.md, which had drifted: it documented the deleted ProjectCard, and asserted that new IPC commands need permission grants in capabilities/default.json — they do not, that file covers plugin commands only. Adds the conventions that would otherwise bite: container_needs_recreation() is purely label-based and never diffs env, so container-affecting state needs its own label; and #[serde(default)] on a bool yields false regardless of intent. Corrects the claim that Reset preserves credentials. Reset calls remove_project_volumes, which deletes both the home and claude-config volumes, so it wipes ~/.claude, the OAuth token, installed skills and session transcripts. 84 frontend tests, 34 Rust tests, both builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d6ac3ae6c6 |
Add Claude Code settings infrastructure, TUI mode, session naming, and global defaults
Adds first-class support for Claude Code CLI features (2.1.71-2.1.110): - New ClaudeCodeSettings struct with per-project and global defaults for TUI mode, effort level, focus mode, thinking summaries, session recap, auto-scroll, env scrub, and 1-hour prompt caching - Settings injected as env vars (CLAUDE_CODE_NO_FLICKER, etc.) and ~/.claude/settings.json entries via entrypoint.sh merge block - New ClaudeCodeSettingsModal component for configuring settings - Session naming support (-n flag passed to claude CLI, shown in tabs) - Relaxed reserved prefix filter: CLAUDE_CODE_* env vars now allowed in custom env vars UI for power users - Global SSH key path, git name, and git email now used as fallbacks when per-project values are not set, with UI in SettingsPanel - Fingerprint-based change detection triggers container recreation when Claude Code settings change - Updated README, HOW-TO-USE, and CLAUDE.md documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
6369f7e0a8 |
Document web terminal feature across all docs
Adds web terminal documentation to README (architecture, key files), HOW-TO-USE (setup guide, usage, security tips), TECHNICAL (system diagram, communication flow, dependencies, project structure), and CLAUDE.md (backend structure). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
d7d7a83aec |
Rename LiteLLM backend to OpenAI Compatible
Build App / compute-version (push) Successful in 8s
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 4m0s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
Reflects that this backend works with any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.), not just LiteLLM. Includes serde aliases for backward compatibility with existing projects.json files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
38082059a5 |
Rename AuthMode to Backend, fix LiteLLM variant typo, add image update alerts, clean up Settings
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m14s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
- Fix serde deserialization error: TypeScript sent "lit_llm" but Rust expected "lite_llm" - Rename AuthMode enum to Backend across Rust and TypeScript (with serde alias for backward compat) - Add container image update checking via registry digest comparison - Improve Settings page: fix image address display spacing, remove per-project auth section - Update UI labels from "Auth" to "Backend" throughout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
93deab68a7 |
Add Ollama and LiteLLM backend support (v0.2.0)
Add two new auth modes for projects alongside Anthropic and Bedrock: - Ollama: connect to local or remote Ollama servers via ANTHROPIC_BASE_URL - LiteLLM: connect through a LiteLLM proxy gateway to 100+ model providers Both modes inject ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN env vars into the container, with optional model override via ANTHROPIC_MODEL. LiteLLM API keys are stored securely in the OS keychain. Config changes trigger automatic container recreation via fingerprinting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
4b56610ff5 |
Add CLAUDE.md and HOW-TO-USE.md documentation
CLAUDE.md provides guidance for Claude Code instances working in this repo (build commands, architecture overview, key conventions). HOW-TO-USE.md is a user-facing guide covering prerequisites, setup, all application features, and troubleshooting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |