Compare commits

...
42 Commits
Author SHA1 Message Date
jknapp dd48baac8a Merge pull request 'Add password-encrypted settings export/import' (#40) from feat/settings-export-import into main
Build App / compute-version (push) Successful in 5s
Secret Scan / scan (push) Successful in 6s
Build App / build-macos (push) Successful in 2m41s
Build App / build-windows (push) Successful in 4m50s
Build App / build-linux (push) Successful in 8m3s
Build App / create-tag (push) Successful in 21s
Build App / sync-to-github (push) Successful in 14s
2026-08-27 21:53:42 +00:00
jknapp e63318e04a Merge pull request 'Skip AUR for now, attach Arch package as a GitHub release asset' (#42) from fix/aur-render-expression-collision into main
Secret Scan / scan (push) Successful in 6s
Reviewed-on: #42
2026-08-27 21:50:54 +00:00
jknapp adf9e7d603 Merge branch 'main' into fix/aur-render-expression-collision
Secret Scan / scan (push) Successful in 5s
Secret Scan / scan (pull_request) Successful in 6s
2026-08-27 21:50:20 +00:00
shadow-test 3c8296843f Skip AUR for now — attach the built Arch package to the GitHub release
Secret Scan / scan (push) Successful in 5s
Secret Scan / scan (pull_request) Successful in 5s
Publishing to the AUR needs a maintainer AUR account and its SSH key
registered as a secret here, neither of which exists yet. Rather than
leave the workflow permanently failing at that last step, it now stops
short of AUR and instead uploads the built .pkg.tar.zst to the same
GitHub release it built from, as a plain downloadable asset (`pacman -U`
to install). The AUR-push step is still in this file's git history if
that setup happens later.

Renamed publish-aur-package.yml -> publish-arch-package.yml to match.
The render/validate steps are unchanged; new here is capturing the exact
built package filename from inside the build container (makepkg is the
only thing that actually knows it) and an upload step that follows the
same create-or-reuse-release, strip-upload_url, POST-octet-stream pattern
build-app.yml and backfill-releases.yml already use for GitHub assets,
plus a delete-existing-asset-first step so a re-dispatch for an
already-packaged version replaces rather than 422s.

Verified with a real Docker run end to end: rendered a real PKGBUILD,
built a real (synthetic) .deb through makepkg + namcap in an archlinux
container, confirmed the container exits 0, and confirmed the exact
package filename it captures (triple-c-bin-<version>-1-x86_64.pkg.tar.zst)
round-trips out via docker cp intact.
2026-08-27 14:48:39 -07:00
jknapp 7489516df3 Merge pull request 'Fix PKGBUILD render silently no-op'ing on every AUR publish run' (#41) from fix/aur-render-expression-collision into main
Secret Scan / scan (push) Successful in 9s
Reviewed-on: #41
2026-08-27 21:40:05 +00:00
shadow-test 6dcdeb89cb Fix PKGBUILD render silently no-op'ing on every AUR publish run
Secret Scan / scan (push) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
The "Render PKGBUILD" step's Python heredoc built its old_source match
string via an f-string, escaping literal braces as `${{pkgver}}` — which
put that exact four-character sequence directly in this workflow file's
own YAML text. Gitea Actions scans a run: block for `${{ ... }}` and tries
to evaluate whatever's inside as one of its own expressions before the
shell ever sees the script; "pkgver" isn't a valid expression context, so
every run has been failing that interpolation and emptying the step
instead of raising anything visible there. The next step's `makepkg` then
failed with "PKGBUILD does not exist" — the actual point of failure was
one step earlier and unrelated to AUR credentials.

Rebuilt the same match string with a "$" variable and plain concatenation
so the file's own text never contains the trigger sequence. Verified by
extracting the exact heredoc and running it standalone against the real
PKGBUILD template — renders identically to the intended output.
2026-08-27 14:29:27 -07:00
shadow-test 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.
2026-08-27 14:24:06 -07:00
shadow-test 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.
2026-08-27 13:13:48 -07:00
shadow-testandClaude Sonnet 5 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
2026-08-27 12:16:43 -07:00
shadow-testandClaude Sonnet 5 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
2026-08-27 11:57:16 -07:00
jknapp 81b1cfba09 Merge pull request 'Add a native Arch/CachyOS package via its own AUR publish workflow' (#39) from feat/arch-aur-package into main
Secret Scan / scan (push) Successful in 5s
2026-08-27 18:30:00 +00:00
jknapp ca6028bbb3 Merge pull request 'Work around WebKitGTK EGL crash on Wayland' (#38) from fix/wayland-webkit-egl-crash into main
Build App / compute-version (push) Successful in 3s
Secret Scan / scan (push) Successful in 3s
Build App / build-macos (push) Successful in 2m50s
Build App / build-windows (push) Successful in 4m46s
Build App / build-linux (push) Successful in 6m35s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
2026-08-27 18:29:51 +00:00
shadow-testandClaude Sonnet 5 b3d07bda09 Fix real workflow bugs a review found: dead bind mount, blind error gate
Secret Scan / scan (push) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
A review found the "Validate with makepkg and namcap" step's bind mount
(docker run -v "$PWD/rendered:/work") would very likely fail on Gitea's
own act_runner: a containerized job's $PWD isn't a path the daemon's host
can resolve, so the mount would silently attach an empty directory
instead of failing loudly — the same class of problem noted elsewhere for
this exact environment. Switched to docker create + docker cp (in and
back out) + docker start -a, the pattern already validated locally, which
works regardless of where the daemon actually lives.

Also found and fixed, most severe first:

- The namcap error gate (`grep -q "^[a-zA-Z0-9_-]*bin E:"`) only matched
  one of namcap's two line shapes for reporting an error
  ("triple-c-bin E: ...") and missed the other ("PKGBUILD
  (triple-c-bin) E: ...") entirely — confirmed by reproducing both against
  a real namcap run. The PKGBUILD-level half of the safety net was dead.
  Replaced with a plain `grep -q " E: "`, confirmed to match both real
  shapes (and a split-package variant) and nothing else.
- package()'s `ar x "Triple-C_${pkgver}_amd64.deb"` named the asset
  literally, defeating the whole point of the resolve step discovering
  the real filename from the release instead of assuming a pattern — a
  future Tauri bundler naming change would still break here with an
  opaque error. Changed to `ar x ./*_amd64.deb`, which `source=()` already
  guarantees matches exactly one file.
- `pacman -Sy` before installing packages is the canonical Arch partial-
  upgrade footgun; changed to `pacman -Syu --noconfirm --needed`.
- `${{ inputs.version }}` was interpolated directly into a shell step
  instead of routed through `env:`, unlike every other step in the file.
- `git push origin master` assumes the local branch name after cloning a
  brand-new (not-yet-created) AUR repo's empty state is `master`, which
  depends on the runner's own `init.defaultBranch` if the server sends no
  symref. `git push origin HEAD:master` is unambiguous either way.
- The private key was written with a plain redirect then chmod'd after,
  leaving a window where it's world-readable; now created at its final
  mode first via `install -m 600 /dev/null`. Added `-o IdentitiesOnly=yes`
  so a runner ssh-agent can't offer a different key first.
- Added GH_PAT auth to the api.github.com calls, matching every other
  workflow in this repo, to avoid the unauthenticated 60/hour rate limit.
- Fixed two comments: the `options` comment credited `!debug` for
  suppressing the empty debug-package directory, when it's actually
  `!strip` doing that (verified in a real build); and documented in the
  README that a hand-edit made directly in the AUR repo is silently
  reverted by the next dispatch, since every run renders fresh from this
  repo's template.

All of the above re-verified with the same real end-to-end methodology as
the original commit: real makepkg build, real namcap lint (clean), and
the exact updated docker create/cp/start sequence run against a live
container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 11:24:31 -07:00
shadow-testandClaude Sonnet 5 e025a7441a Add a native Arch/CachyOS package via its own AUR publish workflow
Secret Scan / scan (push) Successful in 27s
Secret Scan / scan (pull_request) Successful in 10s
Part of triple-c#34's third ask ("I would like to also have an
Arch/CachyOS native version as well"), addressed separately from the
Wayland crash fix (fix/wayland-webkit-egl-crash) since it's an unrelated
feature, not a bug.

packaging/arch/PKGBUILD is a "-bin" AUR package repackaging the same .deb
build-app.yml already produces — no Rust/Node toolchain needed to install
it, and the user gets exactly the binary the project ships and tests.
Verified end to end against a real release (v0.4.14) rather than going by
Tauri's generic docs: downloaded the actual .deb, ldd'd the actual binary
to ground-truth `depends` (dropped `pango` and `libayatana-appindicator`
from an earlier draft — the first is already pulled in transitively by
gtk3, the second was never linked at all since this app has no tray icon
or menu), and ran a real makepkg/namcap/pacman -U cycle. namcap caught a
real issue this way (missing license file under
/usr/share/licenses/triple-c-bin/), now fixed by fetching LICENSE
alongside the .deb.

.gitea/workflows/publish-aur-package.yml does the actual publishing:
given a version (or "latest"), it finds that release's real Linux asset
on GitHub, downloads it, computes real checksums, renders the PKGBUILD
template, validates the result with makepkg and namcap inside a real
Arch container, and pushes to AUR. workflow_dispatch only, deliberately —
the same reasoning that killed sync-release.yml in triple-c#32 (releases
are assembled by build-app.yml across three separate platform jobs, so
there's no single automatic event that fires only once the Linux .deb
this needs actually exists) applies here too.

Requires a repo secret this workflow cannot set up itself:
AUR_SSH_PRIVATE_KEY, from an AUR account that has already created (or
been given co-maintainer access to) triple-c-bin — both one-time manual
steps on aur.archlinux.org. Until that secret exists, the workflow fails
loudly at the push step rather than silently doing nothing. See
packaging/arch/README.md for the full maintenance flow.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 11:10:55 -07:00
shadow-testandClaude Sonnet 5 8f62949902 Correct two overclaims in the Wayland workaround's comment
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 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 4m48s
Build App (Preview) / build-linux (pull_request) Successful in 6m49s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Review found: "nothing this app's UI depends on" is backwards — the
terminal's @xterm/addon-webgl renderer is exactly the GPU compositing path
this setting disables, it just degrades gracefully (the addon's own
construction already handles WebGL being unavailable) rather than
crashing. And the "not simply Wayland vs X11" justification for going
unconditional doesn't hold up: WAYLAND_DISPLAY is exported into an
XWayland client's environment too, so gating on it would have caught that
case as well — the real reason to go unconditional is that there's no
reliable heuristic for the thing that actually matters (which
Mesa/driver/compositor combination is affected), not that the naive gate
misses XWayland specifically.

Also noted, not changed: the env var leaks to whatever the app spawns
afterwards (a cold-launched default browser via xdg-open), and the "=0
re-enables it" parenthetical isn't verified against WebKitGTK's own
source, so softened to say what's actually guaranteed (an already-set
value is left alone) rather than assume presence-vs-boolean parsing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 10:57:45 -07:00
shadow-testandClaude Sonnet 5 6354cb42b2 Work around WebKitGTK's EGL crash on Wayland (triple-c#34)
Secret Scan / scan (push) Successful in 5s
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 2m39s
Build App (Preview) / build-windows (pull_request) Successful in 4m45s
Build App (Preview) / build-linux (pull_request) Successful in 5m10s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
Reported on CachyOS/Arch with Wayland: the app aborts immediately with
"Could not create default EGL display: EGL_BAD_PARAMETER. Aborting."
printed straight to stderr by WebKitGTK's own C code, before Triple-C's
own logging even gets a chance to say anything useful about it.

This is WebKitGTK's DMA-BUF renderer (its default accelerated-compositing
path since 2.42) failing on some Mesa/driver/compositor combinations. Set
WEBKIT_DISABLE_DMABUF_RENDERER=1 unconditionally on Linux before the Tauri
builder runs, which is where GTK/WebKitGTK actually read it — there's no
reliable way to detect the affected combination ahead of time (reports of
this exact failure exist under XWayland too, not just pure Wayland
sessions), and WebKitGTK's fallback compositing path costs some rendering
performance this app's UI doesn't need. Left alone if a user has already
set the variable themselves.

Does not address the other two things filed under the same issue (links
not opening on the host, and a request for a native Arch/CachyOS package)
— those need more information / are a separate scope, respectively.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 10:45:24 -07:00
jknapp 9b55a12b32 Merge pull request 'Make preview versions monotonic and distinguishable from production' (#37) from fix/preview-version-numbering into main
Build App / compute-version (push) Successful in 4s
Secret Scan / scan (push) Successful in 3s
Build App / build-macos (push) Successful in 2m41s
Build App / build-windows (push) Successful in 4m50s
Build App / build-linux (push) Successful in 6m27s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
2026-08-27 17:41:38 +00:00
shadow-testandClaude Sonnet 5 049232099b Dedupe the preview-build predicate, fix two comment inaccuracies
Secret Scan / scan (push) Successful in 24s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m39s
Build App (Preview) / build-windows (pull_request) Successful in 4m44s
Build App (Preview) / build-linux (pull_request) Successful in 6m17s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Final review pass gave this a clean bill of health overall but named
three small things:

- get_app_version() and check_for_updates() each read
  option_env!("TRIPLE_C_BUILD_SUFFIX") independently with slightly
  different idioms — if one were ever edited alone, the About panel and
  the update check could silently disagree about whether this is a
  preview build. Extracted preview_build_suffix() as the single place
  that reads and classifies it.
- pick_update's doc comment described the unparseable-tag case as a
  `-preview.<sha>` suffix; the actual tag build-app-preview.yml creates is
  `preview-<sha>` (no version, no dot) — already correct in the
  neighboring GitHubRelease::prerelease comment, just not here.
- That same prerelease comment claimed defence against a preview release
  leaking through backfill-releases.yml, but a preview's tag already fails
  semver parsing on its own — this field's actual job is the case parsing
  can't catch: a normally-tagged release someone flags prerelease on
  Gitea (a hotfix candidate, an RC) that a backfill would otherwise mirror
  as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 10:34:56 -07:00
shadow-testandClaude Sonnet 5 945883bb9d Actually offer a preview the release it precedes, and fix two more gaps
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 2m39s
Build App (Preview) / build-windows (pull_request) Successful in 4m50s
Build App (Preview) / build-linux (pull_request) Successful in 7m25s
Build App (Preview) / prune-previews (pull_request) Successful in 6s
An Opus review of the previous commit found its headline claim didn't
hold: a preview and the release it precedes compute to the identical
numeric version by construction, but check_for_updates compared with a
strict `>` against the bare CARGO_PKG_VERSION (never the suffixed display
string), so `(0,4,13) > (0,4,13)` is false and the release was never
offered. Plain semver ordering doesn't make a `-preview.<sha>` suffix sort
below the same numeric release on its own here, since the comparison
never sees the suffix at all.

pick_update now takes is_preview_build, derived from whether
TRIPLE_C_BUILD_SUFFIX was baked in, and relaxes that one comparison to
`>=` — so "a release exists at my own number" reads as an update. A
production build still requires strictly newer.

Also: ported build-app.yml's `git tag --points-at HEAD` guard into the
preview version computation. Without it, workflow_dispatch (which this
workflow allows on main, not just PR builds) run on a commit a release
was already cut from would compute one past that release — reintroducing
"preview outranks production" through the manual-dispatch door. And
corrected two comments that claimed the prerelease filter was currently a
no-op: backfill-releases.yml mirrors every Gitea release to GitHub
unfiltered, prerelease flag included, so it's real defence-in-depth
against a dispatched backfill leaking a preview release, not a no-op.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 10:24:24 -07:00
shadow-testandClaude Sonnet 5 b71e15c2c0 Make preview versions monotonic and distinguishable from production
Secret Scan / scan (push) Successful in 6s
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 6m26s
Build App (Preview) / prune-previews (pull_request) Successful in 4s
build-app-preview.yml computed its patch number as
`git rev-list --count <latest tag>..HEAD` — the exact formula build-app.yml
itself documents as broken and replaced (#26): a distance from whichever
tag sorts highest, not a counter, so it resets to zero on every release and
previews went backwards (0.4.62 -> 0.4.0) the moment one landed. Ported the
same "one past the highest patch already used" computation build-app.yml
uses for real releases, reading the same tags (including -mac/-win
suffixes), so a preview built right before a release now computes the
exact number that release is about to take — semver already orders
`0.4.12-preview.<sha> < 0.4.12`, so a preview user is offered the release
the moment it ships instead of being silently pinned forever.

The installed preview's reported version was also indistinguishable from
production: the bundle's own version field strips the `-preview.<sha>`
suffix before touching tauri.conf.json/Cargo.toml/package.json, since the
Windows MSI's ProductVersion has no room for one. Rather than risk that
(unverifiable without an actual Windows build), preview builds now bake
the suffix into the binary separately via a TRIPLE_C_BUILD_SUFFIX
build-time env var, and get_app_version() appends it when present — a
production build sets nothing, so this is a no-op there.

Also: added `prerelease` to `GitHubRelease` and filter on it in
check_for_updates (currently a no-op against real data — nothing mirrored
to GitHub is ever prerelease:true — but the updater is no longer
structurally incapable of enforcing a channel split if one is ever made
explicit). And deleted sync-release.yml: workflow_dispatch-only, reading
gitea.event.release.* fields a manual dispatch never populates, so it
could never have actually run; build-app.yml's inline mirror already does
the same job.

Refactored check_for_updates' filtering into a pure, testable pick_update
helper (this file had no tests before), and added tests for it and the
new get_app_version suffix handling.

Fixes #32.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 10:11:35 -07:00
jknapp 06254db3d4 Merge pull request 'Report and retry Docker resources remove_project could not delete' (#36) from fix/remove-project-cleanup-reporting into main
Build App / compute-version (push) Successful in 5s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m40s
Build App / build-windows (push) Successful in 4m54s
Build App / build-linux (push) Successful in 5m35s
Build App / create-tag (push) Successful in 13s
Build App / sync-to-github (push) Successful in 13s
2026-08-27 16:59:57 +00:00
shadow-testandClaude Sonnet 5 61bdbc4a5b Close the crash-window gap and exec-session leak a third review found
Secret Scan / scan (push) Successful in 16s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m37s
Build App (Preview) / build-windows (pull_request) Successful in 4m52s
Build App (Preview) / build-linux (pull_request) Successful in 6m17s
Build App (Preview) / prune-previews (pull_request) Successful in 2s
A third Opus review pass confirmed round 2's fixes hold up, then found:

- The pending-cleanup record `remove_project` writes is fully durable
  (fsync'd); the projects_store.remove() that follows it is a plain
  fs::write with no fsync. A crash or power loss in that window — or that
  store write failing outright, beyond what the previous round's in-process
  rollback catches — leaves a record on disk naming a project
  projects.json still lists as present. The very next startup retry would
  then delete that project's container, snapshot image, and both volumes
  (including the one holding the OAuth credential and every session
  transcript) out from under a project the user still sees in the sidebar.
  retry_pending_cleanup_logged now takes the ProjectsStore and refuses to
  touch — clearing instead — any record whose project id still exists.
  Also stopped swallowing the round-2 rollback's own failure.
- Resolving the container through find_existing_container instead of
  project.container_id (round 2's stale-id fix) changed what drove
  close_sessions_for_container in remove_project and rebuild_project_
  container: sessions are now leaked when Docker is unreachable (nothing
  resolves, so nothing closes, and the project record is gone a moment
  later) and in the stale-id race itself (sessions were opened against the
  container that actually exists, not the id find_existing_container
  bypasses). Both functions now close sessions for the stored id
  unconditionally, and again for the resolved id if it differs.
- A pronoun-agreement bug in the no-retry removal toast ("remove them
  manually" for a single leftover) that was fixed one line above for verb
  agreement but not for the pronoun.

Also closed the test gaps the review named: the pending-cleanup
corrupt-record aside-move had no test, the Reset toast's leftover copy
was inline and untested (extracted to lib/resetOutcome.ts, mirroring
components/projects/home/removalReport.ts, with unit tests), and nothing
asserted rebuild()'s success path maps outcome.project into the list
rather than the whole outcome.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 09:47:45 -07:00
shadow-testandClaude Sonnet 5 439ef16f07 Fix two new bugs a second review found: stale container id, orphaned record
Secret Scan / scan (push) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 5s
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 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 4m59s
Build App (Preview) / build-linux (pull_request) Successful in 6m28s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
A second Opus review of commit 2 found it had introduced real problems of
its own rather than just polish gaps:

- remove_project's "None or stale" container-id fallback only handled
  None. A stale id (the documented start-failure race in
  start_project_container_locked, where the old container is removed and
  the new one's id isn't persisted until after start_container succeeds)
  still 404'd on removal — now treated as success by commit 1's own fix —
  while the real container survived to block every volume removal with a
  409 forever, with nothing in the pending-cleanup record ever naming it.
  Both remove_project and rebuild_project_container now resolve the
  container via find_existing_container() unconditionally, matching every
  other container-destroying path in the codebase, and remove_project
  fails closed (records a leftover rather than silently skipping) if
  Docker itself can't be reached to check.
- remove_project could leave a pending-cleanup record for a project still
  live in projects.json: if the store's own save failed after the record
  was written, startup housekeeping would delete that project's container
  and volumes out from under it on the next launch. The record is now
  rolled back when the store write fails.
- rebuild_project_container (Reset) only surfaced a leftover volume, not a
  leftover snapshot image — the more serious failure, since the next
  container is built from that image whenever it exists, silently
  reviving the exact system layer Reset was asked to discard.
  ProjectResetOutcome now carries leftover_image too, and the toast's
  "run docker volume rm" advice is corrected: the new container has
  already remounted the volume by the time the toast renders, so that
  command would just hit the same conflict Reset did.

Also from the same pass: reworded a couple of log/toast lines that still
asserted resources were "still present" when the daemon-unreachable case
covered by the same code path can't actually confirm that; fixed a
singular/verb mismatch in the leftover toast text; moved an unparseable
pending-cleanup record aside instead of re-warning about it forever; and
added a debug log when a record's recorded_at can't be parsed, so aging
never silently no-ops.

Pulled describeLeftovers/leftoverVerb out of ProjectHome.tsx into their
own module with unit tests, and added tests for the recorded_at staleness
check — the previous commit's equivalent logic had none.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 09:09:46 -07:00
shadow-testandClaude Sonnet 5 d8bb5ab262 Address review findings: durability, stale container ids, honest toasts
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 6s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m38s
Build App (Preview) / build-windows (pull_request) Successful in 6m18s
Build App (Preview) / build-linux (pull_request) Successful in 7m48s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
An Opus review of the previous commit found several real gaps:

- pending_cleanup::save used plain write-temp-then-rename, unlike
  migration_store's fsync'd write it claimed to mirror — a crash in that
  window left a truncated record that list() would skip forever, silently
  reproducing the exact bug this module exists to fix. Now matches
  migration_store's File::create/write_all/sync_all/rename/sync_dir shape,
  and the tests exercise the real save/list/clear functions against a temp
  dir instead of re-implementing their bodies inline.
- remove_project and rebuild_project_container only ever looked at
  project.container_id, unlike every other container-destroying path in the
  codebase, which falls back to find_existing_container for exactly this
  race (a crash between creating a container and persisting its id). A miss
  here left a container that then blocked every subsequent volume removal
  with a 409, forever. Both now resolve the same way the rest of the
  codebase does, and record the container by its deterministic name rather
  than its id so a retry still has something that resolves.
- remove_project's toast promised an automatic retry unconditionally, even
  when writing the pending-cleanup record itself failed (the one case
  where nothing will actually retry). ProjectRemovalReport now carries
  retry_scheduled, and the UI is honest about which case it's in.
- remove_volumes_by_name now retries once after a short delay on a 409,
  since Docker releasing a volume's mount reference right after its
  container is removed is not always instantaneous, and this is exactly
  the sequence remove_project runs.
- rebuild_project_container (Reset) returns ProjectResetOutcome so the UI
  can warn when Reset could not fully clear a project's volumes, instead
  of only logging it — the new container silently reuses old data
  otherwise, which is what Reset promises not to do.
- retry_pending_cleanup_logged escalates a record's log level after it has
  failed for a week, since recorded_at was otherwise write-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 08:36:27 -07:00
shadow-testandClaude Sonnet 5 4827170715 Report and retry Docker resources remove_project could not delete
Secret Scan / scan (push) Successful in 10s
Build App (Preview) / compute-version (pull_request) Successful in 7s
Secret Scan / scan (pull_request) Successful in 8s
Build App (Preview) / create-release (pull_request) Successful in 5s
Build App (Preview) / build-linux (pull_request) Successful in 6m5s
Build App (Preview) / build-macos (pull_request) Successful in 2m45s
Build App (Preview) / build-windows (pull_request) Successful in 5m42s
Build App (Preview) / prune-previews (pull_request) Successful in 3s
remove_project_volumes always returned Ok(()) regardless of what actually
happened, making the `if let Err(e)` guarding it at every call site dead
code. remove_project then dropped the project record unconditionally, so a
volume, image or container that failed to delete became permanently
unreachable — confirmed against a real orphaned volume pair found in the
wild (fixes #31).

remove_project_volumes/remove_snapshot_image/remove_container now report
what they could not remove (treating "already gone" as success rather than
a leftover), remove_project surfaces this to the user via a toast, and
before dropping the project record it writes a pending-cleanup record that
startup housekeeping retries automatically on the next launch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGjXq6fqtAFHdbhk4f3PfZ
2026-08-27 08:18:41 -07:00
jknapp 1a79852f65 Merge pull request 'Remove a live credential from a test fixture, and scan for the next one' (#33) from fix/test-fixture-secret into main
Build App / compute-version (push) Successful in 3s
Secret Scan / scan (push) Successful in 4s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 4m50s
Build App / build-linux (push) Successful in 6m46s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 11s
Reviewed-on: #33
2026-08-25 18:54:56 +00:00
shadow-testandClaude Opus 5 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
2026-08-25 11:51:04 -07:00
shadow-testandClaude Opus 5 d09e2a2743 Stop using a live credential as a test fixture
`the_custom_env_fingerprint_never_carries_the_value` asserted that the custom-env
fingerprint does not leak a secret — using the maintainer's real Gitea token as
the secret. It was committed on 2026-08-11 in 9b2f4fe, reached 92 commits, and
was readable for fourteen days in the public GitHub mirror at
`shadowdao/triple-c`, confirmed by fetching the raw file.

The token was a **site-admin** token (`is_admin: true`, user id 1) with admin
rights on every repository the account can see, not a repo-scoped one. It has
been revoked; the API now answers 401.

Release bundles were never affected — the literal is inside `#[cfg(test)]`, and
a search of the shipped 0.4.62 AppImage finds nothing.

The fixture is now an obviously fake string, and the test is unchanged
otherwise. Mutation-checked against the replacement: a fingerprint that returns
the raw value, one that returns the key name, and one that ignores its input are
all still caught, so nothing about the test's power depended on the value being
real — which was true the whole time.

History is deliberately **not** rewritten. The value was public for two weeks, so
rotation is the fix and the old value is now worthless; rewriting 92 commits of
published history would break every clone to hide something already seen.

Worth noting how this survived: five audit rounds and two independent reviews
all pointed at new code, and this sat in a test file that none of them had reason
to open. A high-entropy-literal check in CI would have caught it on the day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
2026-08-25 11:29:16 -07:00
jknapp 4371c9f03e Merge pull request 'Terminal newlines, OAuth callback, Claude Code settings, and the Files tab' (#30) from ship/core into main
Build App / compute-version (push) Successful in 5s
Build Container / build-container (push) Successful in 2m30s
Build App / build-macos (push) Successful in 2m37s
Build App / build-windows (push) Successful in 5m28s
Build App / build-linux (push) Successful in 7m49s
Build App / create-tag (push) Successful in 23s
Build App / sync-to-github (push) Successful in 11s
Five audit rounds of terminal, auth, settings and Files-tab work, plus the Files tab regaining its host transfers in a shape that opens the OS dialogs from Rust.

480 Rust tests, 602 frontend, no new clippy warnings.
2026-08-25 18:02:58 +00:00
shadow-testandClaude Opus 5 eead748222 Close what two reviews found in the Files tab transfers
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) Successful in 37s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / build-linux (pull_request) Successful in 5m11s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Two independent reviews of 2c9482a, one for correctness and one against the
threat model. Between them they found two ways to lose a file, one way for a
container to choose a Windows save destination, and a rule that made every
dotfile unsavable. Every finding below was demonstrated against a real
container before being fixed, and the fixes are demonstrated the same way.

## The download was accepting truncated reads and refusing whole ones

The `[ -f ]` bracket around the read was wrong in both directions.

It missed the case that loses data. Truncation *in place* — `> file`, log
rotation, `tar -x`, most build tools — leaves a regular file behind, so `dd`
stopped at the new EOF and exited 0. Measured: a 600 MB source truncated
mid-read delivered 34 MB, which was then renamed over the user's own earlier
copy and toasted as `Saved (34.1 MB)`. Truncate-to-zero did the same and needs
no adversary at all.

And it failed *good* downloads. `dd` already holds the fd, and neither `rm` nor
`mv` can touch an open one — the bytes are complete. But `rm` makes `[ -f ]`
false, so a finished 600 MB transfer of a file a bundler happened to unlink was
deleted, reporting "nothing was saved". The comment claiming the bracket caught
a "mix of two files" was simply wrong; an open fd cannot be a mix.

So the script now measures the file before reading it and puts the answer on
stderr, and Rust checks that at least that many bytes arrived. One rule,
subsuming everything the bracket was for. Verified against a real container:
truncation mid-read and the FIFO race are refused; deletion, rename-over, a
growing file, an empty file and an untouched 600 MB read all pass.

## On Windows the container, not the user, was naming the save destination

`suggested_save_name` split on `/` only, and it feeds the save dialog's
pre-filled name. Backslash is a legal Linux filename character and
`validate_container_path` has no reason to object — `..\..\Users\…` is one
POSIX segment. The Windows common file dialog parses its name box as a path on
Save, so a container-created file called
`..\..\..\Users\vic\AppData\Roaming\Microsoft\Word\STARTUP\x.dotm` put its own
bytes in an auto-loading Office directory on one un-read click. `resolve_host_path`
did not stop it: Word's `STARTUP` and Excel's `XLSTART` are not in the autorun
denylist, which this file's own docs already concede is "losing by
construction" and was never meant to be the boundary here.

The name is now sanitized of every separator, the drive colon and the rest of
what NTFS refuses, so it cannot be a path on any platform this ships to.

## No dotfile could be saved, and the app pre-filled the name that guaranteed it

The write policy judged the leaf for hiddenness, so `/workspace/.env` was
refused *after* the modal and the overwrite prompt — quoting the name the app
itself had suggested. `.gitignore`, `.dockerignore`, `.eslintrc.json`, `.nvmrc`:
all unsavable, while uploading them worked, so a dotfile could go in and never
come out.

`HostPathUse` gains a third mode. `WriteChosenName` drops the leaf check and
keeps every directory rule, and only `download_container_file` uses it — the
dialog is a real boundary for that caller and only that caller.
`download_container_backup` still takes its path over IPC as a string and keeps
the strict rule.

## Smaller, all found by the reviews

  * A download had no ceiling. `dd` resolves through the container's `PATH`,
    which its agent owns with passwordless sudo; a replacement writing forever
    was measured at ~6 GB/s, so one click on a file listed as 2 KB filled the
    host disk with no progress shown and no cancel. Bounded now by what the
    file measured, with slack that is absolute for small files and
    proportional for large ones, so an honest growing log is unaffected.
  * "Framed rather than verbatim" did not stop container text reaching the
    toast headline: `readableRefusal` matches with `includes`, and it has to,
    because the app's own refusals carry those markers mid-sentence. Anchoring
    would break them. The fix is at the injection point — container text is
    clipped to one 200-character line with control characters stripped — plus a
    `max-h-40` on the toast message, which the `detail` block always had and
    this half did not. 8 KB of prose in a `z-[60]` card pushed its own dismiss
    button off-screen.
  * `savingPath` was a scalar while the design deliberately allows concurrent
    saves. Starting a second freed the first's row mid-transfer, and whichever
    finished first cleared both; dismissing the second dialog was enough. It is
    a `Set` now.
  * `setUploading(false)` fired when the command settled, not when the refresh
    finished, so a second click landed mid-relisting.
  * `upload_files_to_container` checked the container directory before the
    picker and then used the unresolved path. A modal has no time limit. It
    re-checks after, which is what `docker::exec`'s doc comment already claimed.
  * `wait_for_exec_exit` flattened a missing exit code to `Some(0)`, which made
    the new `!= Some(0)` check unreachable by construction.
  * `normalize_host_path` did not collapse repeated separators, so the lexical
    system-root rule was silently absent for `C:\\Windows\…`. Not exploitable —
    the resolved pass catches it — but a documented layer that does nothing is
    a trap for the next caller.
  * README still carried the "no host path crosses IPC in either direction"
    claim the previous commit narrowed everywhere else, and HOW-TO-USE
    described the hidden rule without saying it applies to folders only.

480 Rust tests, 602 frontend, no new clippy warnings. Eleven mutations against
the new tests, all killed — two of the first round survived and were rewritten:
one because `split_whitespace` already handled the case I thought I was
testing, one because I had deleted a comment rather than the behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
2026-08-25 10:42:26 -07:00
shadow-testandClaude Opus 5 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
2026-08-25 10:00:21 -07:00
shadow-testandClaude Opus 5 88ffb4744a Cancel the keydown on Shift+Enter, or xterm submits anyway
Build App (Preview) / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 36s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m56s
Build App (Preview) / build-linux (pull_request) Successful in 5m8s
Build App (Preview) / build-windows (pull_request) Successful in 5m38s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
The handler returned `false` from xterm's custom key handler and a
comment claimed that was enough to stop the bare CR. It is not.
`_keyDown` returns the instant the handler says `false` — before it sets
`_keyDownHandled` and before it cancels the event — and `_keyPress` then
checks that same flag, finds it false, and emits a bare CR for Enter's
charCode 13.

So the headline feature of this branch did the wrong thing in a real
browser: Shift+Enter inserted the newline and then submitted the
half-written prompt, now with a stray blank line in it. Arguably worse
than before the fix. Reproduced in Chromium and confirmed against the
bundled xterm 5.5.0 source.

`preventDefault()` is what stops the browser firing keypress at all.
Applied at both call sites — the desktop terminal and the web terminal's
copy.

The test could not have caught this. jsdom never synthesizes the
follow-up keypress, so `expect(sent()).not.toContain("\r")` was asserting
a property the environment cannot falsify — a test named for a behaviour
it could not exercise. It now asserts `defaultPrevented`, which is the
mechanism that actually suppresses the keypress and which jsdom can
observe. Mutation-checked: removing the `preventDefault()` fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 20:47:01 -07:00
shadow-testandClaude Opus 5 016de8f641 Close the blockers from the fifth audit
Build App (Preview) / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 10m5s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 4m31s
Build App (Preview) / build-linux (pull_request) Successful in 5m21s
Build App (Preview) / build-windows (pull_request) Successful in 19m1s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Docs and disclosure. HOW-TO-USE.md's settings table still described the
pre-fix behaviour — and help_commands.rs fetches that file from GitHub
main at runtime, ahead of the embedded copy, so it would have reached
every user's Help dialog the moment this merged. The Config tab named
three settings that need a base-image update; there are four, and the
omitted one (Session recap) is the one that fails *without* the "won't
switch off" symptom the warning teaches. Both now also state the cost
nobody had written down: changing any of these recreates the container,
which commits a layer.

Two stale comments that told a reviewer the code was safe when it was
not. compute_claude_code_settings_fingerprint still claimed the
historical fingerprint is preserved so an upgrade cannot churn every
container — carried over from before the widening, false since the
format string changed. And capabilities/default.json, which is the
reviewed threat model of record, described a "Save to host…" action this
branch deletes.

Security and correctness. update_settings validated env vars and nothing
else, so the *global* default_ssh_key_path — the fallback for every
project without an override — took `/` and read-only bind-mounted the
host, which entrypoint.sh then copies into the home volume. classify_
mount_source ran canonicalize on the raw string, which resolves a
relative path against Triple-C's own cwd, so `.` and `..` were accepted
or refused depending on where the app was launched; the daemon then
refuses the mount and the project can never start. Its test passed only
because its examples did not exist under app/src-tauri.

bind_mount_exclusions still derived a path from every row while
project_path_mounts had learned to skip unmountable ones, so a legacy
row made /workspace/<name> ordinary container content that a migration
would then exclude from staging and destroy. The skip is also logged now
rather than silently dropping a folder.

The terminal's file-in path checked is_dir() but not file type, so a
dropped FIFO blocked forever with no timeout — and it is the only route
in now. The web terminal labelled sessions from a global set at request
time, so two quick opens swapped them; harmless until Shift+Enter became
type-dependent, at which point a mislabelled Claude session submitted a
half-written prompt. Opened now carries the type.

Every ~/.claude.json write goes through one atomic helper. The
awsAuthRefresh branches still truncated in place — the same corruption
the Shift+Enter block was fixed for twenty lines later, and its own
comment said so. Demonstrated: a failed write now leaves the original
byte-identical.

And the registration test I added yesterday could pass while the
property was false: an audit got five real unregistered commands past its
exact-string attribute match, and "exactly once" was in its name but not
its body. Mutation-checked against all six shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 18:45:11 -07:00
shadow-testandClaude Opus 5 4d1a5a2417 Remove list_sibling_containers, which nothing called
It listed every container on the daemon — including the user's unrelated
postgres, mysql and other work — and handed the summaries to the webview.
It had a registration, a command, a docker-layer helper, a typed frontend
wrapper and a `SiblingContainer` type, and zero call sites.

An audit named it as step one of an escalation chain: enumerate the
daemon's containers, then point `update_project`'s unvalidated
`container_id` at one and read its files through the file-command
surface. The second half of that chain is closed now, but a command that
exposes the user's unrelated containers and serves no feature is surface
with no upside.

Found by the registration test added in the previous commit, which is the
answer to "why test something the compiler already checks": the compiler
is perfectly happy with a command nobody calls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 17:15:38 -07:00
shadow-test a323047964 Merge branch 'r4/scrub' into ship/core 2026-08-23 17:12:34 -07:00
shadow-testandClaude Opus 5 11216c45e3 Assert every command is registered, and every registration exists
This is the shape of the bug behind the original OAuth-callback report:
`set_auth_bridge_enabled` existed, worked, and had a typed frontend
wrapper — with zero call sites. The switch the docs told users to flip
was wired to nothing, so every login callback was refused. Both halves
compiled, so nothing noticed.

The reverse direction is the sharper one: a command that is registered
but reachable from nowhere is still IPC surface a compromised webview
can call. `list_sibling_containers`, which returns every container on the
daemon including the user's unrelated work, sits in exactly that state.

Mutation-checked both ways: removing a registration fails the test,
restoring it passes. The first parser I wrote split the list on commas,
which glued each `// Docker` style comment to the command after it and
then dropped that command as a comment — silently, once per group, 17 in
total. Line-based now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 17:12:34 -07:00
shadow-testandClaude Opus 5 913aa85805 Stop the pre-commit scrub running with its guards silently absent
Two fail-open gaps, both latent on the shipped image and both live on an
ImageSource::Custom one.

`--one-file-system` is the only bound on the scrub's `rm -rf`, and it was
probed into `$rmopt` with `rm --one-file-system --help`. BusyBox answers
that with `unrecognized option` and exits 1, so on any busybox- or
toybox-derived image the variable was empty and the delete ran as root
across mount boundaries — reported as a completed `Reclaimed(n)`.
Measured in `busybox:latest` with a named volume one level below the
match at /tmp/claude-x/inner: emptied, `###TRIPLE-C-SCRUBBED 102423`. It
is a prerequisite now, probed by `rm --one-file-system -f -- ''` — the
code path that matters, with the one operand no filesystem can name — and
an image without it prints `###TRIPLE-C-SCRUB-UNAVAILABLE` and deletes
nothing. That costs Alpine and busybox images their scrub, which is the
cheaper half of the trade: declining costs disk, proceeding costs the
mount.

The second is that resetting `PATH` was never the whole of command
lookup. `bash` builds functions out of `BASH_FUNC_<name>%%` environment
variables, function lookup precedes `PATH` entirely, and `command -v`
reports a function as found — so a planted `stat` passed the prerequisite
probe and then answered the containment checks. Every in-shell answer is
itself importable: `unset`, `command`, and — the point that settles it —
`[`, `pwd` and `cd`, which are checks 0 to 2 rather than merely the
tools. So the script is no longer run by the shell that read the
environment. The exec's argv is a bootstrap using only reserved words,
parameter expansions and command words containing a `/` (which `bash`
refuses to import a function for), and it hands the script as `$1` to a
second `/bin/sh` started by `env -i`. Measured on `/bin/sh -> bash` with
`BASH_FUNC_stat%%` set on the container and a volume mounted at the
match: the old invocation emptied it and printed 306565, the bootstrap
left it intact and printed 65536. `/usr/bin/env` then `/bin/env`, because
H3's lesson about hardcoded coreutils locations applies to `env` too.

The comment claiming the `PATH` reset "defeats it just as completely as
spelling /usr/bin/stat out" is corrected, as is the one claiming a
sudo-written /usr/bin/stat does not survive a container restart — it
lands in the writable layer, which is exactly what the commit this runs
in front of captures.

Also here: a stored project path row with an empty host_path or
mount_name no longer becomes a mount. The first sends `field Source must
not be empty` back for the whole create, so the project cannot start at
all until the row is gone, and no amount of save-time validation reaches
a record already on disk; the second mounts over /workspace itself and
the daemon then creates the other rows' mount points inside the user's
real folder.

And in migration_commands, the deferred-reconcile claim is RAII rather
than a trailing statement — a panic inside `reconcile_migration_now`
stranded it for the rest of the process — and `await_release` looks
before it sleeps, so a project released a moment later no longer costs a
full twenty seconds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 17:09:47 -07:00
shadow-test e9902f0564 Merge branch 'r4/narrow' into ship/core 2026-08-23 17:08:04 -07:00
shadow-test 7488fc5b70 Merge branch 'r4/host' into ship/core 2026-08-23 17:08:04 -07:00
shadow-testandClaude Opus 5 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
2026-08-23 17:05:56 -07:00
shadow-testandClaude Opus 5 9472cb3c4c Resolve host paths before mounting them, and stop reading stored data as choice
Four fixes that share a shape: a value already on disk, or one spelled
around a check, being taken at face value.

`/..` bind-mounted the entire host filesystem read-write. `is_filesystem_root`
was purely lexical — trim trailing separators, refuse what was left only if it
was empty or a bare `C:` — and nothing in the file ever called `canonicalize`,
so `/..`, `/./`, `/home/..`, `/etc/../` and `C:\..` all passed. The daemon
resolves them: `docker run -v /..:/mnt/probe` mounts the host root, and the app
mounts read-*write* into a container whose agent has passwordless sudo. It is
the escalation `check_mount_name_stays_under_workspace` exists to close,
reached through the host-path half of the mount instead of the mount-name half.

`classify_mount_source` replaces it and asks the OS: `canonicalize` applies
`..`, follows symlinks, and resolves 8.3 aliases and UNC spellings on Windows.
A path that cannot be resolved — `projects.json` synced from another machine,
a folder not created yet — falls back to a lexical collapse rather than being
refused, because refusing would make such a project unsavable; the gap is
bounded, since what resolution adds is a property of paths that exist. A path
that names no location at all (`C:x`, a relative path) is refused rather than
guessed at. Same check now guards `ssh_key_path` and `ca_cert_path`, whose
read-only mounts were whole-host disclosure at /tmp/.host-ssh.

Custom env var names had no charset check anywhere, so `BASH_FUNC_stat%%` —
bash's wire format for an exported shell function, body in the value — reached
the container environment verbatim. Latent today because the image's /bin/sh is
dash, but the pre-commit scrub runs `/bin/sh -c` as root and nothing pins that.
Keys are now shell identifiers, on the project and the global list both, with
the same grandfathering the folder rows get: a stored key is admitted, a new or
edited one is not.

The blank workspace row was persisted. The comment said it was dropped on save;
the code computed the filtered list and then saved the unfiltered one, so
"+ Add folder" plus a blur stored `{"Target": "/workspace/", "Source": ""}` and
the project could never be started or recreated again. Every save in the
section now goes through one filter, and a blur that changed nothing saves
nothing.

Widening the five `ClaudeCodeSettings` booleans to `Option<bool>` reinterpreted
every stored record. They were plain `bool`s that always serialised, so every
project ever saved carries an explicit `"env_scrub": false` that nobody chose —
and under the new merge that `Some(false)` beats a global `Some(true)`, where
the old rule let the global win. Upgrading silently turned five settings off,
"strip credentials from subprocess environments" among them. Deserialisation
now goes through a shim that dates the record by the presence of the
pre-widening `enable_session_recap` key and reads its `false`s as unset. The
fields skip serialising when unset, so an older binary can still parse
`projects.json` after a downgrade — a `null` would fail to parse and take the
whole list down, since `ProjectsStore` parses all-or-nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 17:02:03 -07:00
shadow-testandClaude Opus 5 dd23a52b41 Fix four upgrade-path defects the coherence audit found
The ~/.claude.json write was `printf ... > "$CLAUDE_JSON"`, which
truncates before it writes. A write that fails part-way — a full home
volume, which is the exact condition half this release exists to prevent
— leaves the file unparseable, and it never self-heals: the next start's
jq fails on the corrupt file, MERGED is empty, and the guard skips the
write that would have repaired it. That file holds the OAuth account, so
the failure mode is a permanently lost login, in service of a cosmetic
flag that suppresses a tip. Demonstrated: old pattern loses the
credential, new tmp+rename leaves the original intact. The correct
pattern was already in triple-c-task-runner.

The web terminal scoped its xterm key handler to Claude sessions but not
its mobile input bar or its dedicated newline button, so both sent ESC+CR
into `bash -l`, where readline has no binding for it. Silent no-op, and
worse from a button that stays on screen looking live. Both now consult
the active session's type, and the button is disabled with a reason on a
shell tab.

The Config tab claimed "Off overrides a global On" without qualification.
True for the env-var-driven settings, false for TUI mode, Effort level
and Focus mode, whose off state is *removing* a key — an older base
image's entrypoint ignores the instruction to remove it. The copy now
says so and points at the base-image update.

HOW-TO-USE.md said there is no add-task form; AutomationTab renders a
"New task" button. That file is fetched from GitHub at runtime by
help_commands.rs, so the error was live in every user's Help dialog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 16:45:20 -07:00
79 changed files with 9636 additions and 3322 deletions
+83 -10
View File
@@ -43,7 +43,18 @@ name: Build App (Preview)
# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a
# release; the point of a preview is the build you are testing now.
#
# `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub.
# A preview release is not meant to reach GitHub. `build-app.yml`'s inline
# mirror never sees one (it only runs for its own `push`-triggered release),
# but `backfill-releases.yml` pulls every Gitea release unfiltered and would
# faithfully forward a preview's `prerelease: true` if it were ever dispatched
# while one existed — so `GitHubRelease::prerelease` in `update_commands.rs`
# is real defence, not a no-op, even though the `preview-<sha>` tag shape
# (never valid semver) already blocks it independently. (The previous
# mechanism here, `sync-release.yml`, was `workflow_dispatch`-only and read
# `gitea.event.release.*` fields that are only ever populated by a `release`
# trigger, so it could never have actually run; deleted rather than fixed,
# since build-app.yml's inline mirror already does what it was meant to do
# for real releases. See triple-c#32.)
env:
GITEA_URL: ${{ gitea.server_url }}
@@ -70,12 +81,23 @@ jobs:
outputs:
version: ${{ steps.version.outputs.VERSION }}
sha: ${{ steps.version.outputs.SHA }}
# Everything after the first `-` in VERSION (e.g. `preview.a1b2c3d`).
# The bundle version fields never see this — see "Set app version" in
# each build job — but it is baked into the binary as
# `TRIPLE_C_BUILD_SUFFIX` so `get_app_version()` can still report it.
# An installed preview otherwise reports the same bare number a
# production build would, indistinguishable in the About panel and to
# `check_for_updates`. See triple-c#32.
suffix: ${{ steps.version.outputs.SUFFIX }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch all tags
run: git fetch --tags
- name: Compute preview version
id: version
run: |
@@ -86,21 +108,60 @@ jobs:
# is testing and not something to hang a tag on.
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
# The patch number is computed exactly as build-app.yml does it, so a
# preview is labelled with the version the release it previews would
# carry. This used to be hard-coded `.0`, which made every preview
# installer claim to be x.y.0 no matter what it contained.
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
if [ -n "$LATEST_TAG" ]; then
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)"
# The patch number must be the same "one past the highest patch
# already used" build-app.yml computes for a real release — not a
# distance from the latest tag. It used to be
# `git rev-list --count <latest tag>..HEAD`, which build-app.yml's
# own history section documents as broken for exactly this reason:
# it resets to zero on every tag cut, so previews went *backwards*
# (0.4.62 -> 0.4.0) the moment a release landed, and nothing stopped
# a preview number from later colliding with a real release's.
#
# Reading the same `v${MAJOR_MINOR}.*` tags (including the `-mac`
# / `-win` suffixed ones a partially-published release can leave
# behind) means a preview built right before a release computes the
# exact number that release is about to take — e.g. `0.4.13` for
# both. That makes the two numerically *equal*, not "preview less
# than release" — plain semver ordering does not make a
# `-preview.<sha>` suffix sort lower on its own here, because
# `check_for_updates` compares against the bare, stripped
# `CARGO_PKG_VERSION`, never the suffixed display string. What
# closes the loop is `update_commands.rs`'s `is_preview_build`
# check, which relaxes that one comparison to `>=` specifically so
# "a release exists at my own number" reads as an update. See
# triple-c#32.
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
| sort -n | tail -1 || true)
# Mirrors build-app.yml's own `EXISTING` guard: this workflow is
# also `workflow_dispatch`-able on `main`, not just PR-triggered, so
# HEAD can be a commit a release was already cut from. Without this,
# dispatching a preview there would compute `HIGHEST + 1` — one past
# that release — and produce exactly the "preview outranks
# production" failure triple-c#32 was filed over, just reintroduced
# through the manual-dispatch door instead of the automatic one.
EXISTING=$(git tag --points-at HEAD \
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
| sort -n | tail -1 || true)
if [ -n "$EXISTING" ]; then
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — matching it"
PATCH="${EXISTING}"
elif [ -n "$HIGHEST" ]; then
echo "Highest patch already used on this line: ${HIGHEST}"
PATCH=$((HIGHEST + 1))
else
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
PATCH=0
fi
VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}"
SUFFIX="preview.${SHORT_SHA}"
VERSION="${MAJOR_MINOR}.${PATCH}-${SUFFIX}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT
echo "Computed preview version: ${VERSION}"
# One release, created once. The three build jobs run concurrently, so
@@ -249,6 +310,13 @@ jobs:
- name: Build Tauri app
working-directory: ./app
env:
# Baked into the binary via `option_env!` in `get_app_version()` —
# the bundle version above stays bare (WiX/MSI's ProductVersion has
# no room for a suffix), so this is the only place a preview build
# can still tell itself apart from a production one. See
# triple-c#32.
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build
@@ -361,6 +429,9 @@ jobs:
- name: Build Tauri app (universal)
working-directory: ./app
env:
# See the matching comment on the Linux job's "Build Tauri app" step.
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build --target universal-apple-darwin
@@ -489,6 +560,8 @@ jobs:
working-directory: ./app
env:
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
# See the matching comment on the Linux job's "Build Tauri app" step.
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo tauri build
+298
View File
@@ -0,0 +1,298 @@
name: Publish Arch Package
# Builds the `triple-c-bin` Arch package (packaging/arch/PKGBUILD) for a
# given release, or the latest one if none is given, and attaches the built
# .pkg.tar.zst to that release on GitHub as a downloadable asset. Manual
# dispatch only — deliberately not triggered by `release` or `push`, for the
# same reason sync-release.yml (removed in triple-c#32) never worked safely
# as an automatic trigger: this repo's releases are assembled by
# build-app.yml across three separate platform jobs, and there is no single
# automatic event that fires only once everything (including the Linux .deb
# this workflow needs) is actually uploaded. A human deciding "this release
# is ready, go package it" is the correct trigger, the same reasoning
# backfill-releases.yml already uses for its own manual-only GitHub sync.
#
# ## What this does and does not do
#
# It renders `packaging/arch/PKGBUILD` for one specific version (real
# download URL, real sha256sums — never guessed; see the resolve-asset step),
# validates it with `makepkg`/`namcap` in a real Arch container, and uploads
# the resulting `.pkg.tar.zst` to the GitHub release it was built from —
# installable by hand with `pacman -U`. It does NOT commit anything back to
# this repo — `packaging/arch/PKGBUILD` stays a hand-maintained template with
# a placeholder version, and the workflow never starts from or writes to it.
#
# ## Not published to the AUR (yet)
#
# This originally also pushed the rendered PKGBUILD to an AUR git repo, which
# needs a maintainer AUR account and its SSH key registered as a secret here
# — both manual, one-time steps neither this workflow nor anyone but a
# maintainer can do. Until that setup happens, a downloadable release asset
# gets the same package to users without it. The AUR push step is still in
# this file's git history (see the commit that added this comment) if that
# setup is ever done and it's worth reinstating.
on:
workflow_dispatch:
inputs:
version:
description: >-
Release version to package, without a leading "v" (e.g. "0.4.14").
Leave empty to use the latest published GitHub release.
required: false
env:
GITHUB_REPO: shadowdao/triple-c
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve version and find the Linux asset
id: resolve
env:
VERSION_INPUT: ${{ inputs.version }}
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
# Authenticated when the secret is available (it is, everywhere
# else in this repo's workflows) to avoid the unauthenticated
# 60-requests/hour-per-IP cap; still works without it, just at that
# lower limit, since this hits nothing but a public repo's public
# releases.
AUTH=()
[ -n "${GH_PAT}" ] && AUTH=(-H "Authorization: Bearer ${GH_PAT}")
if [ -z "${VERSION_INPUT}" ]; then
echo "No version given — resolving the latest GitHub release"
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest")
else
echo "Using requested version ${VERSION_INPUT}"
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${VERSION_INPUT}")
fi
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
VERSION="${TAG#v}"
echo "Resolved to ${TAG}"
# Discovered from the real release, not assumed: Tauri names the
# asset after `productName` verbatim ("Triple-C"), not the
# lowercase Cargo binary name, and asset naming is exactly the kind
# of thing that silently drifts if a future Tauri upgrade changes
# bundler defaults — a hardcoded pattern here would then 404
# forever until someone noticed. `head -1` guards against a release
# somehow carrying more than one matching asset, which would
# otherwise pass the emptiness check below and then break the
# download step with two URLs on one line.
DEB_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .browser_download_url' | head -1)
DEB_NAME=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .name' | head -1)
if [ -z "$DEB_URL" ] || [ "$DEB_URL" = "null" ]; then
echo "No *_amd64.deb asset found on release ${TAG}" >&2
exit 1
fi
echo "Found asset: ${DEB_NAME}"
# For attaching the built package to this same release later —
# every release object carries its own `upload_url` regardless of
# whether it was just created or (as here) already existed, and
# the `{?name,label}` URI-template suffix has to come off before
# this is usable as a plain URL to POST to.
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
UPLOAD_URL=$(echo "$RELEASE_JSON" | jq -r '.upload_url' | sed 's/{?name,label}//')
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
echo "deb_name=${DEB_NAME}" >> "$GITHUB_OUTPUT"
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
echo "upload_url=${UPLOAD_URL}" >> "$GITHUB_OUTPUT"
- name: Download the release asset and compute real checksums
id: checksums
env:
DEB_URL: ${{ steps.resolve.outputs.deb_url }}
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
TAG: ${{ steps.resolve.outputs.tag }}
run: |
set -euo pipefail
curl -fsSL -o "${DEB_NAME}" "${DEB_URL}"
curl -fsSL -o LICENSE "https://raw.githubusercontent.com/${GITHUB_REPO}/${TAG}/LICENSE"
echo "deb_sha256=$(sha256sum "${DEB_NAME}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
echo "license_sha256=$(sha256sum LICENSE | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
- name: Render PKGBUILD
id: render
env:
VERSION: ${{ steps.resolve.outputs.version }}
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
DEB_SHA256: ${{ steps.checksums.outputs.deb_sha256 }}
LICENSE_SHA256: ${{ steps.checksums.outputs.license_sha256 }}
run: |
set -euo pipefail
mkdir -p rendered
cp packaging/arch/PKGBUILD rendered/PKGBUILD
cd rendered
# Plain string replacement throughout, not sed — the source URL
# contains slashes and the repo name does too, and getting a sed
# delimiter choice AND its escaping right for that is exactly the
# kind of thing that looks correct, passes review, and breaks the
# next time someone touches it. `re.sub` with `count=1` and an
# exact `.format`-free literal match is boring and that's the
# point: every substitution below fails loudly (an assertion /
# the checks after) rather than silently no-op'ing if the
# template's shape ever drifts from what this expects.
#
# pkgrel resets to 1 for a new pkgver — a packaging-only fix to the
# same upstream version (a dependency bump, say) is what pkgrel is
# for, and this workflow always republishes the current PKGBUILD
# verbatim rather than incrementing anything, so 1 is always
# correct for what this workflow does. It is NOT correct for a
# dependency-only fix republished at the *same* pkgver: pkgrel
# would be forced back to 1, and no existing installation sees an
# upgrade. That case needs a manual pkgrel bump in the template
# before dispatching, which this workflow has no input for.
python3 - "$VERSION" "$DEB_NAME" "$DEB_SHA256" "$LICENSE_SHA256" "$GITHUB_REPO" <<'PY'
import re, sys
version, deb_name, deb_sha, license_sha, github_repo = sys.argv[1:6]
with open("PKGBUILD") as f:
text = f.read()
text, n = re.subn(r"(?m)^pkgver=.*$", f"pkgver={version}", text, count=1)
assert n == 1, "pkgver=... line not found"
text, n = re.subn(r"(?m)^pkgrel=.*$", "pkgrel=1", text, count=1)
assert n == 1, "pkgrel=... line not found"
# Built with a "$" variable and plain "+" concatenation rather than
# an f-string's double-brace escape for a literal brace: writing
# this as an f-string put a dollar sign directly against two open
# braces, right here in this workflow's own YAML text — and this
# runner's own expression templating scans a run: block for that
# exact two-character opening sequence and tries to evaluate
# whatever sits inside as one of ITS OWN expressions (a step
# output, a secret, ...) before the shell ever sees this script.
# "pkgver" isn't one of those, so that lookup failed and silently
# emptied this whole step rather than raising anything here.
# Spelling the dollar sign out of a variable instead means this
# file's own text never contains that trigger sequence.
DOLLAR = "$"
old_source = (
"source=(\"Triple-C_" + DOLLAR + "{pkgver}_amd64.deb::"
+ "https://github.com/" + github_repo + "/releases/download/v" + DOLLAR + "{pkgver}/"
+ "Triple-C_" + DOLLAR + "{pkgver}_amd64.deb\""
)
new_source = (
f'source=("{deb_name}::'
f'https://github.com/{github_repo}/releases/download/v{version}/{deb_name}"'
)
assert old_source in text, "source=() line does not match the expected template shape"
text = text.replace(old_source, new_source, 1)
old_sums = "sha256sums=('SKIP'\n 'SKIP')"
assert old_sums in text, "sha256sums=() placeholders not found"
text = text.replace(old_sums, f"sha256sums=('{deb_sha}'\n '{license_sha}')", 1)
with open("PKGBUILD", "w") as f:
f.write(text)
PY
grep -q "pkgver=${VERSION}$" PKGBUILD
! grep -q "SKIP" PKGBUILD
- name: Validate with makepkg and namcap
id: build
run: |
set -euo pipefail
# A bind mount (`docker run -v "$PWD/...":/work`) is the more
# obvious way to write this, and was the first draft — but on a
# containerized Gitea act_runner job, `$PWD` is a path inside this
# job's own container, which the daemon's host cannot resolve; the
# mount would silently attach an empty directory instead of failing
# loudly. `docker cp` moves real bytes across that boundary
# regardless of where the daemon actually lives, which is what
# makes this work under both a bind-mount-capable runner and a
# containerized one.
docker pull archlinux:latest
CID=$(docker create -w /work archlinux:latest bash -c '
set -euo pipefail
pacman -Syu --noconfirm --needed base-devel namcap sudo git openssh >/dev/null
useradd -m builder
chown -R builder:builder /work
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
sudo -u builder bash -c "cd /work && makepkg --printsrcinfo > .SRCINFO"
sudo -u builder bash -c "cd /work && makepkg -s --noconfirm"
# Named once here, inside the container, rather than guessed
# from options=(!strip !debug) plus pkgver/pkgrel/arch on the
# host after the fact — makepkg is the one place that actually
# knows its own output name, and `!debug` already guarantees
# this glob can only ever match the one real package (no
# -debug split package gets produced).
basename /work/*.pkg.tar.* > /work/.pkgfile
echo "--- namcap ---"
NAMCAP_OUT=$(sudo -u builder bash -c "cd /work && namcap PKGBUILD *.pkg.tar.*" || true)
echo "$NAMCAP_OUT"
# Matches "triple-c-bin E:", "PKGBUILD (triple-c-bin) E:" and any
# split-package variant ("triple-c-bin-debug E:") alike — namcap
# uses more than one line shape for its two rule families, and
# namcap itself exits 0 regardless of what it reports, so this
# grep is the only thing standing between an E: and a green job.
if echo "$NAMCAP_OUT" | grep -q " E: "; then
echo "namcap reported an error — see above" >&2
exit 1
fi
')
mkdir -p rendered
docker cp rendered/. "${CID}:/work"
# `docker start -a` streams output and its exit code is the
# container's own — the same failure this would have hit with a
# bind mount still fails the job the same way.
docker start -a "${CID}"
docker cp "${CID}:/work/.SRCINFO" rendered/.SRCINFO
docker cp "${CID}:/work/.pkgfile" rendered/.pkgfile
PKG_FILE=$(cat rendered/.pkgfile)
docker cp "${CID}:/work/${PKG_FILE}" "rendered/${PKG_FILE}"
docker rm -f "${CID}" >/dev/null
echo "pkg_file=${PKG_FILE}" >> "$GITHUB_OUTPUT"
- name: Attach the package to the GitHub release
env:
GH_PAT: ${{ secrets.GH_PAT }}
TAG: ${{ steps.resolve.outputs.tag }}
RELEASE_ID: ${{ steps.resolve.outputs.release_id }}
UPLOAD_URL: ${{ steps.resolve.outputs.upload_url }}
PKG_FILE: ${{ steps.build.outputs.pkg_file }}
run: |
set -euo pipefail
if [ -z "${GH_PAT}" ]; then
echo "GH_PAT is not set — this step needs it to attach a release asset." >&2
exit 1
fi
# A manual re-dispatch for a version that's already been packaged
# would otherwise hit GitHub's 422 "already_exists" here instead
# of just replacing the stale build with this one.
EXISTING_ID=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}/assets" \
| jq -r --arg name "$PKG_FILE" '.[] | select(.name == $name) | .id')
if [ -n "$EXISTING_ID" ]; then
echo "Replacing the existing ${PKG_FILE} (asset id ${EXISTING_ID}) already on ${TAG}"
curl -fsS -X DELETE -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/assets/${EXISTING_ID}"
fi
curl -fsS -X POST \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/octet-stream" \
--data-binary "@rendered/${PKG_FILE}" \
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${PKG_FILE}")" \
> /dev/null
echo "Attached ${PKG_FILE} to ${TAG}"
+32
View File
@@ -0,0 +1,32 @@
name: Secret Scan
# **No `paths:` filter, deliberately.** The credential this exists for lived in
# `app/src-tauri/src/docker/container.rs`, which `build.yml` would have skipped —
# that workflow only runs for `container/**`. A scan that can be avoided by
# touching the wrong directory is not a scan.
#
# This is the half of the check that nobody can bypass. The pre-commit hook in
# `.githooks/` is faster and friendlier, but it is opt-in per clone and
# `--no-verify` skips it; both are true of every git hook and neither is fixable
# from inside a repository.
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# The whole tracked tree, not just the diff. Scanning a range is cheaper
# but depends on getting the range right across pushes, force-pushes,
# merges and PR events — and a wrong range fails *open*. The full scan
# takes under half a second on this repository and cannot be evaded by
# arranging for the interesting commit to sit outside the window.
- name: Scan tracked files for credentials
run: sh scripts/scan-secrets.sh --tracked
-59
View File
@@ -1,59 +0,0 @@
name: Sync Release to GitHub
on:
workflow_dispatch:
jobs:
sync-release:
runs-on: ubuntu-latest
steps:
- name: Mirror release to GitHub
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITHUB_REPO: shadowdao/triple-c
RELEASE_TAG: ${{ gitea.event.release.tag_name }}
RELEASE_NAME: ${{ gitea.event.release.name }}
RELEASE_BODY: ${{ gitea.event.release.body }}
IS_PRERELEASE: ${{ gitea.event.release.prerelease }}
IS_DRAFT: ${{ gitea.event.release.draft }}
run: |
set -e
echo "==> Creating release $RELEASE_TAG on GitHub..."
RESPONSE=$(curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
https://api.github.com/repos/$GITHUB_REPO/releases \
-d "{
\"tag_name\": \"$RELEASE_TAG\",
\"name\": \"$RELEASE_NAME\",
\"body\": $(echo "$RELEASE_BODY" | jq -Rs .),
\"draft\": $IS_DRAFT,
\"prerelease\": $IS_PRERELEASE
}")
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
echo "Release created. Upload URL: $UPLOAD_URL"
echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do
ASSET_NAME=$(echo "$asset" | jq -r '.name')
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
echo "==> Downloading asset: $ASSET_NAME"
curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL"
echo "==> Uploading $ASSET_NAME to GitHub..."
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/octet-stream" \
--data-binary "@/tmp/$ASSET_NAME" \
"$UPLOAD_URL?name=$ENCODED_NAME"
echo " Uploaded: $ASSET_NAME"
done
echo "==> Release sync complete."
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Refuse a commit that adds something shaped like a live credential.
#
# Installed by pointing git at this directory:
#
# git config core.hooksPath .githooks
#
# which `npm run hooks` in app/ does for you. It is per-clone — git will not let
# a repository configure its own hooks path, for the obvious reason that cloning
# a repo would then be enough to run its code. So this is opt-in on every
# machine, `--no-verify` skips it, and neither of those is a flaw to fix here:
# the CI job in `.gitea/workflows/build.yml` is the half nobody can bypass. The
# hook exists to tell you in one second rather than in five minutes.
exec "$(git rev-parse --show-toplevel)/scripts/scan-secrets.sh" --staged
+195 -8
View File
@@ -79,14 +79,49 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
modals — see "UI conventions" below.
- **Files takes drops *in*, and that path does not use HTML5 drag.** Dropping into the pane
is Tauri's native `onDragDropEvent`, which is window-wide and therefore routed by a
hit-test of the physical-pixel payload position against the pane's rect ÷
`devicePixelRatio` — a hidden pane has a zero-size rect, which is what stops it and
`TerminalView`'s listener both firing. Keep `lib/dropTarget.ts` and both listeners.
- **Getting a file *out* is "Save to host…", and there is no other route.** OS drag-out —
`tauri-plugin-drag`, `stage_container_file_for_drag` and its host staging directory — was
removed from the ship branch and held back for separate hardening; it lives on
- **The Files pane's host transfers open their dialog from Rust, and that is the whole
design — do not move it back into the webview.** The tab browses, views (text and image),
renames and creates folders inside the container (`list_container_files`,
`read_container_file`, `rename_container_path`, `create_container_directory`), and it
copies single files in and out (`upload_files_to_container`, `download_container_file`).
The second pair call `pick_files_to_upload` / `pick_save_path`, which drive
`tauri-plugin-dialog` from the *backend*: the webview can ask for a picker and that is the
entirety of its influence — it cannot name a host path as an *input*. The claim stops
there and should not be widened: host paths still travel outward in error text, canonical
ones included. What is closed is the direction that produced the criticals.
That shape is not decoration. Four successive audits found that host filesystem paths
crossing IPC were where the criticals lived — a caller-named host destination for
container-controlled bytes, an arbitrary host source read into the container, a `link(2)`
upload reservation that succeeded against a directory and failed forever on any filesystem
without hard links. The feature was removed rather than fixed a fifth time, and it came
back only in the shape that removes the class: a frontend-driven dialog handing Rust a
string is the exact thing that failed, so re-introducing `open()`/`save()` in `FilesTab`
would undo the whole point while looking like a simplification.
None of the reservation machinery came back with it. There is no destination reservation,
no placeholder rollback and no collision marker — the OS save dialog already asks about
overwriting, and Docker's archive extractor overwrites on upload the way `cp` does.
- **Drag-and-drop is still not it.** There is no drop-into-the-Files-pane and no OS
drag-out; the buttons are the gesture. A file also gets *in* by being dropped on the
Terminal, and a whole tree comes *out* through "Back up container" — those two predate the
Files work and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent`
is Tauri's native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for
*whose* drop it is, a document-wide `dropIsBlocked` for whether the app should accept one
at all; keep both halves and keep `PaneVisibility`). Backup is
`file_commands::download_container_backup`.
- **`resolve_host_path` applies the full lexical predicate twice — as written, and again
after canonicalisation.** That includes the general hidden-component rule, which
deliberately over-catches: a path resolving through `node_modules/.pnpm`, `~/.cache` or
`~/.local/share` is refused. Do not narrow it back to a list of "credential" directories.
That was tried, and allow-by-omission let `~/.local/bin` (write there and you own the
user's next shell command), `~/.password-store`, browser profiles and `~/.pki/nssdb`
through a planted symlink with a perfectly visible name. Over-refusing is the cheaper
mistake. Note the cost is real and has grown: of the four callers, the Files pane's two
are routine, and their path comes from a dialog — so an over-catch refuses a destination a
person actually chose (`~/.config` is the common one). Accepted, and not a reason to
narrow the rule, because the terminal drop and `download_container_backup` still take
their host path over IPC and this predicate is their only boundary.
- **OS drag-out is not here.** `tauri-plugin-drag`, `stage_container_file_for_drag` and its
host staging directory were held back for separate hardening and live on
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command
without taking that work back whole: the plugin has no scope mechanism, so the grant lets
a compromised webview start a drag on *any* host path the user can read, and the staging
@@ -490,6 +525,158 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
`models/project.rs` for anything that should default to true.
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
## Secrets
**`scripts/scan-secrets.sh` refuses a commit that adds something shaped like a live
credential.** Enable the hook once per clone with `npm run hooks` (from `app/`), which sets
`core.hooksPath` to `.githooks`. A repository cannot configure its own hooks path — cloning it
would then be enough to run its code — so this is opt-in everywhere, and `--no-verify` skips it.
The `Secret Scan` workflow is the half nobody can bypass; it carries **no `paths:` filter**, on
purpose, because the incident that prompted all this lived in `app/**` and `build.yml` only runs
for `container/**`.
Three rules, and the second half of the third is what keeps it usable: vendor-prefixed tokens
(`ghp_`, `sk-`, `AKIA`, `xox`, …), `BEGIN … PRIVATE KEY` blocks, and an opaque literal assigned to
a secret-shaped name. That last one needs **both** halves — the identifier must read as a
credential *and* the whole literal must be hex or base64 with no word structure. Name-proximity
alone flags `secure::get_project_secret(&id, "aws-secret-access-key")`, which is a keychain key
name; the literal test is what excludes it. Measured against the tree: 0 false positives, and it
catches the real incident (`9b2f4fe`) when replayed.
A line ending `pragma: allowlist secret` is skipped. Make a fixture obviously fake before reaching
for it.
**Why this exists:** `the_custom_env_fingerprint_never_carries_the_value` used the maintainer's
real Gitea **site-admin** token as its fixture — a test about secrets not escaping, leaking one. It
survived 92 commits and fourteen days in the public GitHub mirror, past five audit rounds and two
independent reviews, because every one of them read the code under change and this sat in a test
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
## Settings export/import
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that
live in the OS keychain instead: the shared Claude Code OAuth login and the model gateway's two
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are
deliberately out of scope — this is not a project backup.
- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
credential for a server that binds every interface — exporting `AppSettings` wholesale would
have carried it along as if it were as inert as a port number, and importing it would have
applied `web_terminal.enabled` and the token together with no more warning than any other
setting, letting a crafted export silently stand up a LAN-listening terminal on the next launch.
`export_settings`/`apply_settings_import` carve this one field out into `ExportedSecrets`
instead, with the same "only overwrite what the import actually has" treatment as the other
three secrets — except "leave it alone" has to be done by hand in `apply_settings_import`, since
unlike the keychain secrets this one lives inside the `AppSettings` blob that gets replaced
wholesale. `SettingsImportPreview::enables_web_terminal` also exists because of this: `enabled`
and the token are independent fields, and "this turns on a listening service" must not hide
inside a generic "settings replaced" summary. Read this as the standing example of the class of
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
like config but is actually a live credential would have the same problem.
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
encryption. A wrong password fails GCM's authentication tag rather than producing silent
garbage. The salt and nonce are not secret and are written in the clear in the file's own
header — the salt's job is only to make two exports of the same password derive different keys,
and the nonce's only requirement is per-encryption uniqueness, which a fresh random draw on
every export already gives it.
- **The save/open dialogs are opened from Rust**, the same boundary `file_commands.rs`'s
`pick_save_path`/`pick_files_to_upload` draw and document at length: a frontend-driven dialog
handing Rust a host path string is the exact shape of bug that produced this app's past
criticals. `preview_settings_import` resolves the chosen path itself and remembers it
(`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without
a path ever crossing back over IPC. It also pins a hash of the file's ciphertext next to that
path, and `apply_settings_import` refuses to proceed if the file on disk no longer matches it —
otherwise confirming a preview would not actually be binding on what gets applied, which matters
given this feature's own threat model: a file shared between people may sit in a synced or
otherwise shared directory that changes between the two calls.
- **The decrypted payload is not cached between preview and apply — only the password is reused.**
The frontend holds the password in React state and passes it to both calls; nothing in Rust
holds decrypted plaintext — secrets included — in memory for longer than one command's
execution, so `apply_settings_import` always re-decrypts rather than reusing anything
`preview_settings_import` computed. `preview_settings_import` returns counts and presence flags
only (`SettingsImportPreview`), never a secret value, so it's safe to hand to the frontend and
render directly.
- **Import replaces settings wholesale, but only writes secrets actually present in the file.**
An import is "restore this environment," so the settings half is a full replace, not a
field-by-field merge. Secrets are different on purpose: an absent secret in the export means
"the source machine never had this configured," not "delete this on import" — a user who wants
to clear a secret already has dedicated UI for that (signing out of shared auth, clearing the
gateway key). Secrets are restored *before* the settings replace runs, not after — replacing
settings is what triggers `reconcile_gateway`, and restoring the other way round leaves a real
window where a gateway recreation happens against the destination's old keys.
- **A restored gateway secret nudges a running gateway container to recreate itself, even when
nothing about the gateway's *shape* changed.** `reconcile_gateway`'s `gateway_shape_changed` only
compares port/provider/base URL/models — deliberately, since that's what's rendered into the
container's config — so a secret-only change (same shape, new key) is invisible to it. Left
alone, a running container would keep serving the old key material indefinitely after an import
that restored a new one. `apply_settings_import` tracks whether either gateway secret was
actually written and, if the gateway is enabled and its container both exists and is running,
calls `docker::gateway::ensure_gateway_running` directly afterward — its own fingerprint already
includes the secret rotation id (`storage::secure::get_gateway_secret_version`), so it recreates
exactly when it should and no more.
- **A keychain write failing during import is reported back, not only logged.** Each of the three
`secure::store_*` calls collects its error into `SettingsImportOutcome::secret_restore_warnings`
in addition to logging it — an import that silently restores two of three secrets but not the
third must not read as unqualified success just because the settings half of the import (which
runs after, and is validated before any of this) went through. `apply_settings_import` returns
`SettingsImportOutcome { settings, secret_restore_warnings }` rather than bare `AppSettings` for
this reason; `ImportSettingsModal` shows any warnings alongside the "Settings imported" message.
- **The imported settings are validated *before* any secret is written, not just before the
settings replace.** `apply_settings_import` calls
`settings_commands::validate_settings_update(&current, &settings)` — the same checks
`update_settings` runs internally, pulled out into its own function specifically so this caller
can run them first — and only proceeds to the three keychain writes if that passes. A review
caught the earlier ordering: writing secrets first meant a rejected import (a bad env var name, a
disallowed host path) still left the keychain overwritten with the file's secrets while the
settings themselves stayed unchanged, a silently half-applied state the error message gave no
hint of.
- **`read_and_decrypt` checks `format_version` before attempting to parse the full payload, not
after.** A version bump that isn't deserialize-compatible is exactly the case that check exists
for, and parsing the full struct first would fail on the shape mismatch before the version check
ever ran. Neither error path interpolates what `serde_json` actually says into the message
shown to the user — its type-mismatch errors quote the offending value inline, and the plaintext
here can hold a live credential.
- **The 8-character password minimum is enforced in `export_settings` itself, not only in the
export modal.** The frontend minimum is a UX nudge; the Rust command is the actual boundary a
weak password has to cross, and Argon2id's memory-hardness buys little against an attacker who
can just try a short password directly. Measured with `.chars().count()` (Unicode scalar values)
rather than `.len()` (bytes), to stay as close as this pair of languages allows to the frontend's
`.length` check (UTF-16 code units) — the two only diverge on astral-plane characters. The
derived key and both plaintext buffers — the payload built for export, and whatever `decrypt`
recovers on import — are wrapped in `zeroize::Zeroizing` for the same reason every other secret
in this codebase gets handled carefully — cheap insurance (`zeroize` is already pulled in
transitively via `aes-gcm`) for material that exists only to hold or produce live credentials.
- **The preview also discloses non-blank custom base URLs** (`global_ollama`, `global_llamacpp`,
`global_openai_compatible`, `gateway.api_base`) so an import that would redirect model traffic to
a different server is visible in the confirmation dialog rather than discovered later — these are
endpoints, not secrets, so `SettingsImportPreview` carries and `describeImport` renders the actual
URL rather than just a presence flag. `describeImportWarnings` additionally calls out a web
terminal token that arrives with the terminal left *off*: `start_web_terminal` only mints a fresh
token when none is already set, so a planted token would otherwise activate silently the next
time someone turns the terminal on, with no import-time signal that it wasn't freshly generated.
- **The preview also discloses a custom Docker image, and warns on one every time — not just on
change.** `custom_image_name`/`image_source` weren't in scope for the base-URL disclosure above,
but a review pointed out they're a sharper version of the same problem: this is the image *every*
project container is created from (`models::container_config::resolve_image_name`), so a crafted
export pointing it at an attacker-controlled image is a path to running arbitrary code with
whatever a project's containers are allowed to reach, not merely a redirected API endpoint.
`describeImportWarnings` fires on `image_source == Custom` unconditionally rather than only when
it differs from the destination's current value, since re-importing the same risky configuration
is still worth surfacing every time a user confirms an import.
- **Every free-form string a preview surfaces is sanitized and length-capped before it's built.**
`SettingsImportPreview::from_payload`'s `sanitize_for_preview` strips control characters and caps
at 100 characters (`MAX_PREVIEW_STRING_LEN`) for every base URL and the custom image name — a
review noted that, unlike the count- and boolean-derived fields the preview started with, these
are verbatim strings from a not-yet-trusted decrypted payload rendered directly into the
confirmation dialog. Unbounded, a single pathological value (very long, or holding embedded
newlines) could push the security warnings above the scroll fold in the dialog that exists
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
as a second layer against the same failure mode.
## Testing
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
+79 -21
View File
@@ -228,7 +228,7 @@ buttons. Below that are six tabs:
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
| **Files** | Browse, download and upload files inside the container |
| **Files** | Browse, view and rename files inside the container, and move files between it and your own machine — see [Files](#files) |
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
### Sessions
@@ -351,7 +351,7 @@ it. The sidebar row carries only the two hover controls.
| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck |
| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab |
| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) |
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, download and upload files |
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container, upload files into it and save one back out |
| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) |
| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose |
| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first |
@@ -615,18 +615,27 @@ The **Claude Code settings** editor, also at the bottom of the Config tab, confi
| Setting | What It Does |
|---------|-------------|
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
| **Thinking Summaries** | Shows Claude's thinking process as summaries during responses |
| **Session Recap** | Provides context when returning to a session after being away |
| **Auto-Scroll Disabled** | Disables auto-scroll when in fullscreen TUI mode |
| **TUI Mode** | **Automatic** lets Claude Code choose; **Classic** pins the main-screen renderer; **Fullscreen** pins the flicker-free alt-screen one |
| **Effort Level** | Reasoning depth: **Low**, **Medium**, **High**, **Extra high** |
| **Focus Mode** | Summarises tool *calls* to one line each, showing the last prompt and the final response. **Needs the fullscreen renderer** — set TUI Mode to Fullscreen or this does nothing |
| **Thinking Summaries** | Shows Claude's thinking as summaries rather than a collapsed stub |
| **Session Recap** | A one-line recap when you return to the terminal after a few minutes away. **On by default** — the switch is how you turn it off |
| **Auto-Scroll** | Follows new output to the bottom in fullscreen rendering. On by default |
| **Env Scrub** | Strips credentials from subprocess environments for security |
| **Prompt Caching (1h)** | Enables 1-hour prompt cache TTL instead of the default 5 minutes |
| **Prompt Caching (1h)** | Requests a 1-hour prompt cache TTL instead of the default 5 minutes |
Per-project settings override global defaults set in Settings. If all settings are at their defaults, no configuration is injected.
Each switch has three states on a project: **Global** (follow Settings), **On**, and **Off**. Off is a
real choice — it overrides a global On, which a project could not previously do.
> These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect.
> These map to Claude Code environment variables and `~/.claude/settings.json` keys, and are applied
> when the container starts. Changing one stops and recreates the container.
>
> **Two caveats on an existing project.** Changing any of these recreates the container, and a
> recreation commits a new image layer — so flipping switches repeatedly costs disk. And
> **TUI Mode, Effort Level, Focus Mode and Session Recap cannot be returned to Global** until the
> project's base image is updated: those four are cleared by *removing* a key, and an older image's
> startup script ignores the instruction to remove it. Update the base image from the project's
> Overview tab first. The other switches work on any image.
### MCP Servers
@@ -1161,16 +1170,61 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
### Files
The **Files** tab of Project Home browses inside a running container. You can:
The **Files** tab of Project Home browses inside a running container, and moves files between it
and your own machine. You can:
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation
- **Save to host…** — copy any file out to a location you pick. This is the way to get a file out
of a container; there is one button per file entry, and the file viewer offers it too
- **Upload file** from your host into the current container directory — or **drop files straight
onto the pane** from your desktop, which uploads them into the directory on screen
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
between rows and Enter opens the selected one
- **View** a file — double-click it, or press Enter. Text files and images render in a read-only
viewer
- **Rename** an entry, from the row's Rename button or by pressing `F2`. A rename never moves a
file between folders
- **New folder** in the directory on screen
- **Upload…**, from the toolbar, to copy files from your machine into the directory on screen
- **Save to host…**, from a file's own row, to write that one file out to your machine
- **Refresh** the directory listing at any time
The listing shows file names, sizes, and modification dates.
The listing shows file names, sizes, and modification dates, and marks symbolic links.
#### Getting files in and out
**Upload…** opens a file dialog on your machine, and whatever you choose is copied into the
directory currently on screen. Uploaded files arrive owned by you inside the container, not by
root. You can pick several files in one dialog; each is handled on its own, so if a folder or an
over-sized file is among them, it is named in the message and the rest still arrive. Uploads are
capped at **256 MB per file** — for anything larger, mount the folder into the project instead and
skip the copying altogether.
**Save to host…** does the reverse, for one file: a save dialog opens, you choose where the file
goes, and it is written there. The button sits on the file's own row, and only on files. For a
whole directory, use **Back up container** in Project Home's **⋯** overflow menu, which writes a
`.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose — that
is still the right tool for a tree.
Dragging a file from your desktop and **dropping it onto the Terminal tab** works too, and is often
the quickest way in when you are already typing: the file is copied into the container and its path
is typed into the terminal for you, ready to hand to Claude Code. (The whole terminal pane is a
drop target, including its *Following* toggle.) The Files pane itself is not a drop target.
Both dialogs are opened by Triple-C itself rather than by the page you are looking at. The page
cannot name a place on your machine — it can only ask for a dialog — and nothing is read or written
until you pick somewhere in it. Closing a dialog without choosing is not an error: nothing happens,
and nothing is said about it.
Every one of these routes refuses a location whose path passes through a hidden *folder* — anything
with a component beginning with `.`, such as `~/.ssh`, `~/.cache` or `~/.local/share` — or a system
location, and it checks both the path as written and where it points after any symbolic links. That
rule catches more than it strictly needs to, so now and then it will refuse a place you genuinely
meant, `~/.config` among them. The refusal is a plain sentence saying so; choose a visible location
such as `~/Documents` or `~/Downloads`.
The *file's own name* is a different matter, and dotfiles are fine: `.env`, `.gitignore` and the
rest save normally, since you chose the name in the save dialog yourself. Only the folders on the
way are judged.
If you already keep the project in a folder mounted into the container, the simplest answer is
usually none of the above: edit the file on your host and it is already inside.
### Terminal Rendering
@@ -1216,10 +1270,14 @@ change. Remember that a headless run cannot answer a permission prompt, so in an
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
that was used.
### Creating Tasks (In the Container)
### Creating Tasks
There is no "add task" form in the app. Create tasks from a terminal in the container — either type
the commands yourself in a **Shell** session, or just ask Claude to do it.
The quickest route is the **New task** button on a project's **Automation** tab, which gives you a
form for the name, the schedule and the prompt.
You can also create tasks from a terminal in the container — type the commands yourself in a
**Shell** session, or just ask Claude to do it. That is the better route when you want Claude to
work out the schedule or the prompt for you, and it is what the rest of this section covers.
### Create a Recurring Task
+39 -6
View File
@@ -24,7 +24,7 @@ This file is the architectural tour: what each subsystem is and why it works the
- [Permission Modes](#permission-modes)
- [Containers](#containers) — lifecycle, base-image migration, mounts, CA certificates, sibling containers
- [Models and Authentication](#models-and-authentication) — backends, model aliases, gateway, shared token
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view, host file transfers
- [Inside a Project](#inside-a-project) — capability tiles, Mission Control, web terminal, speech-to-text
- [Key Files](#key-files) · [CSS / Styling Notes](#css--styling-notes) · [Container Image](#container-image)
@@ -105,7 +105,7 @@ configuration. Per-project configuration lives in the Config tab rather than in
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
| **Files** | Browse, download and upload files inside the container |
| **Files** | Browse, view, rename and create folders inside the container, upload host files into the directory on screen, and save one file back out to the host — see [Host File Transfers](#host-file-transfers). A whole tree still comes out through **Back up container** |
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
@@ -442,6 +442,39 @@ per project.
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
once Playwright is present — not a setup route.
### Host File Transfers
Four routes move files across the boundary: **Upload…** and the per-row **Save to host…** in the
Files tab, a file dropped onto the Terminal tab, and **Back up container**. All four share one path
policy in `commands/file_commands.rs`.
- **The OS dialogs are opened by Rust, not by the webview.** `upload_files_to_container` and
`download_container_file` drive `tauri-plugin-dialog` themselves and take nothing but a project
id and a container-side path; `FilesTab.tsx` imports no dialog plugin and `useFileManager`'s
`uploadFiles` takes no argument at all. The web UI can ask for a dialog, and that is the whole of
its influence over where a file comes from or goes — it cannot name a host path as an *input*.
This is a boundary rather than a convention: a dialog the page itself opens is only as trustworthy
as the page. Be precise about the limit, though — host paths still travel *outward* in error text,
canonical ones included, so this closes the inbound direction and not both.
- **The dialog's pre-filled name is sanitized, because a container authored it.** On Windows the
save dialog parses its name box as a path, and a container can name a file
`..\..\Users\you\…\Word\STARTUP\x.dotm` — one POSIX segment, so nothing upstream objects.
`suggested_save_name` replaces every separator and every character NTFS refuses, so the string
cannot be a path on any platform this ships to.
- **One policy for every host path.** A source or destination whose path passes through a hidden
folder (`~/.ssh`, `~/.cache`, `~/.local/share`, anything dot-prefixed) or a system location is
refused, and the check is applied both to the path as written and to what it resolves to after
symlinks. It over-catches deliberately, so it will occasionally refuse somewhere a person
genuinely meant — `~/.config`, say — and the refusal is a sentence naming the folder that tripped
it, not an errno.
- **Uploads are capped at 256 MB per file**; past that the answer is a mount, not a copy. One
dialog's selection is handled file by file, so a folder or an oversized file among the selection
is reported by name and does not stop the others. Uploaded files land owned by the container user,
not root. A cancelled dialog is silent — `Ok(None)`, not an error.
- **`download_container_file` is one file and files only** — no button on a folder row. A directory
is what `download_container_backup` is for. There is no drop target on the Files pane; the
Terminal tab keeps the one it has.
## Inside a Project
### Container Introspection (Capability Tiles)
@@ -513,7 +546,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) plus **Upload…** and per-row **Save to host…**; imports no dialog plugin — the dialogs are Rust's |
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
@@ -536,7 +569,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) and the host transfers (upload, save one file out); never handles a host path |
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
@@ -547,7 +580,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| File | Purpose |
|---|---|
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; one-shot execs and single-file tar building |
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
@@ -561,7 +594,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir), the host transfers `upload_files_to_container` and `download_container_file` — each opening its own OS dialog here in Rust — plus `download_container_backup`, and the hidden-folder path policy all of them share |
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
+15 -10
View File
@@ -412,13 +412,18 @@ triple-c/
├── .gitea/
│ └── workflows/
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
│ ├── build-app-preview.yml # Preview builds
│ ├── build.yml # Build container image (multi-arch)
│ ├── build-stt.yml # Build the STT image
│ ├── sync-release.yml # Mirror releases to GitHub
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
│ └── cleanup-releases.yml # Prune old releases
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline
│ ├── build-app-preview.yml # Preview builds
│ ├── build.yml # Build container image (multi-arch)
│ ├── build-stt.yml # Build the STT image
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
│ ├── cleanup-releases.yml # Prune old releases
│ └── publish-arch-package.yml # Build triple-c-bin, attach it to the GitHub release (packaging/arch/)
├── packaging/
│ └── arch/ # triple-c-bin Arch package — see packaging/arch/README.md
│ ├── PKGBUILD
│ └── README.md
└── app/ # Tauri v2 desktop application
├── package.json # React, xterm.js, zustand, tailwindcss
@@ -436,7 +441,7 @@ triple-c/
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
│ │ ├── useDocker.ts # Docker status, image build/pull
│ │ ├── useFileManager.ts # File browser operations
│ │ ├── useFileManager.ts # File browser operations + host transfers
│ │ ├── useInstallHelper.ts # Guided Docker installation
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
@@ -464,7 +469,7 @@ triple-c/
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
│ │ │ ├── ConfigTab.tsx # Config section host
│ │ │ ├── FilesTab.tsx # In-container file browser
│ │ │ ├── FilesTab.tsx # In-container file browser, upload / save to host
│ │ │ ├── CapabilityTiles.tsx # Read-only capability counts
│ │ │ ├── format.ts # Age / size / uptime formatting
│ │ │ └── config/ # WorkspaceSection, ModelSection,
@@ -504,7 +509,7 @@ triple-c/
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
│ ├── aws_commands.rs # AWS profile/region discovery
│ ├── docker_commands.rs # Docker status, image ops
│ ├── file_commands.rs # File browser (list/download/upload)
│ ├── file_commands.rs # File browser + host transfers (Rust-opened dialogs)
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
│ ├── install_helper_commands.rs # Guided Docker installation
+2 -1
View File
@@ -9,7 +9,8 @@
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"hooks": "git -C .. config core.hooksPath .githooks && echo \"pre-commit secret scan enabled\""
},
"dependencies": {
"@tauri-apps/api": "^2",
+144
View File
@@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -47,6 +82,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -280,6 +327,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -310,6 +363,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -569,6 +631,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -753,6 +826,15 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.20.11"
@@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -1550,6 +1633,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gio"
version = "0.18.4"
@@ -2114,6 +2207,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -2831,6 +2933,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.3"
@@ -2913,6 +3021,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -3194,6 +3313,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -5149,6 +5280,8 @@ dependencies = [
name = "triple-c"
version = "0.4.0"
dependencies = [
"aes-gcm",
"argon2",
"axum",
"base64 0.22.1",
"bollard",
@@ -5174,6 +5307,7 @@ dependencies = [
"tokio",
"tower-http",
"uuid",
"zeroize",
]
[[package]]
@@ -5287,6 +5421,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
+3
View File
@@ -36,6 +36,9 @@ tower-http = { version = "0.6", features = ["cors"] }
base64 = "0.22"
rand = "0.9"
local-ip-address = "0.6"
argon2 = "0.5"
aes-gcm = "0.10"
zeroize = "1"
[dev-dependencies]
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -37,20 +37,3 @@ pub async fn get_container_info(
docker::get_container_info(&project).await
}
#[tauri::command]
pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String> {
let containers = docker::list_sibling_containers().await?;
let result: Vec<serde_json::Value> = containers
.into_iter()
.map(|c| {
serde_json::json!({
"id": c.id,
"names": c.names,
"image": c.image,
"state": c.state,
"status": c.status,
})
})
.collect();
Ok(result)
}
File diff suppressed because it is too large Load Diff
+169 -30
View File
@@ -1071,21 +1071,54 @@ fn reconcile_retries() -> &'static std::sync::Mutex<std::collections::HashSet<St
RETRIES.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
}
/// One project's place in [`reconcile_retries`], handed back on drop.
///
/// RAII for the reason [`crate::project_lock::ProjectGuard`] sets out, and this
/// claim is the case that proves the rule: the release used to be a trailing
/// statement at the bottom of the spawned task in
/// [`defer_migration_reconcile`], sitting after an `.await` on
/// [`reconcile_migration_now`]. A panic in there — or the future simply being
/// dropped, which is what happens to every in-flight task at shutdown — skips
/// the statement, and nothing else ever removes an id from that set. The
/// project is then fenced off from *every* later deferral for the rest of the
/// process: each `reconcile_project_statuses` pass finds it held, fails to
/// claim, and returns, so the phase stays un-normalised, no resume or rollback
/// is offered, and the `:pre-migration-*` pin stays `Claimed`. That is the
/// session-long silence deferring was written to end, reintroduced one panic
/// later and lasting until the app is restarted.
///
/// Dropping this hands the claim straight back, so a caller that discards the
/// value has claimed nothing while reading as though it had; `#[must_use]`
/// makes that a compile warning rather than a second waiter on one record.
#[must_use = "the claim is handed back the moment this guard drops; bind it inside the waiting task, for the whole task"]
struct ReconcileRetryClaim {
project_id: String,
}
impl Drop for ReconcileRetryClaim {
fn drop(&mut self) {
// `into_inner` past poisoning, as in `project_lock`: the only thing
// ever done while holding this mutex is a single `HashSet` insert or
// remove, so a panic on another thread cannot have left it half
// written — and declining to release here would strand the project
// permanently, which is the exact failure the guard exists to stop.
reconcile_retries()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&self.project_id);
}
}
/// Claim the right to be the one deferred reconcile for `project_id`.
/// `false` means somebody else already is.
fn claim_reconcile_retry(project_id: &str) -> bool {
/// `None` means somebody else already is.
fn claim_reconcile_retry(project_id: &str) -> Option<ReconcileRetryClaim> {
reconcile_retries()
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(project_id.to_string())
}
/// Give the claim back, so a later `reconcile_project_statuses` can defer again.
fn release_reconcile_retry(project_id: &str) {
reconcile_retries()
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(project_id);
.then(|| ReconcileRetryClaim {
project_id: project_id.to_string(),
})
}
/// Come back to a project that was held when [`reconcile_migration`] reached it.
@@ -1108,13 +1141,19 @@ fn defer_migration_reconcile(project: &Project, app_handle: &tauri::AppHandle) {
if !migration_store::has_record(&project.id).unwrap_or(true) {
return;
}
if !claim_reconcile_retry(&project.id) {
let Some(claim) = claim_reconcile_retry(&project.id) else {
return;
}
};
let project = project.clone();
let app_handle = app_handle.clone();
tauri::async_runtime::spawn(async move {
// Moved in and bound for the whole body, rather than released by a
// statement at the bottom: everything below this line can panic or be
// dropped mid-await, and a claim that only comes back on the happy path
// is a claim that eventually does not come back at all. See
// [`ReconcileRetryClaim`].
let _claim = claim;
let released =
await_release(&project.id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await;
if released {
@@ -1133,28 +1172,42 @@ fn defer_migration_reconcile(project: &Project, app_handle: &tauri::AppHandle) {
RECONCILE_RETRY_INTERVAL.as_secs() as usize * RECONCILE_RETRY_ATTEMPTS / 60
);
}
release_reconcile_retry(&project.id);
});
}
/// Wait for `project_id` to stop being held, up to `attempts` looks
/// `interval` apart. `true` means it was released, `false` that the budget ran
/// out with it still held.
/// Wait for `project_id` to stop being held: `attempts` looks, the first
/// immediate and the rest `interval` apart. `true` means it was released,
/// `false` that the budget ran out with it still held.
///
/// Split out of [`defer_migration_reconcile`] so the waiting can be tested
/// against a real [`crate::project_lock`] guard on a paused clock — the part
/// that is easy to get wrong is "gives up while still holding the claim" and
/// "never looks again", neither of which is visible from the constants.
/// against a real [`crate::project_lock`] guard on a paused clock — the parts
/// that are easy to get wrong are "gives up while still holding the claim",
/// "never looks again", and the ordering of the look against the sleep, none of
/// which is visible from the constants.
async fn await_release(
project_id: &str,
interval: std::time::Duration,
attempts: usize,
) -> bool {
for _ in 0..attempts {
tokio::time::sleep(interval).await;
for attempt in 0..attempts {
// Look first, sleep second. Sleeping first charged every deferral a
// full interval before anyone read the map even once, and the common
// case is a holder that has already let go: `held()` is sampled in
// `reconcile_migration`, a task is spawned, and by the time it is first
// polled the Reset that was on its last step is frequently finished.
// That bought nothing and cost twenty seconds of a startup pass waiting
// on a lock nobody holds, in front of a check that is one `HashMap`
// lookup.
if crate::project_lock::held(project_id).is_none() {
return true;
}
// And no sleep after the final look: nothing reads the map again
// afterwards, so it is twenty seconds of delay in front of a `false`
// that has already been decided. The budget is still `attempts` looks,
// which is what the constants above are chosen against.
if attempt + 1 < attempts {
tokio::time::sleep(interval).await;
}
}
false
}
@@ -2217,6 +2270,34 @@ mod tests {
assert!(budget >= std::time::Duration::from_secs(15 * 60), "{:?}", budget);
}
/// MEDIUM: an unheld project is reconciled now, not in twenty seconds.
///
/// The wait slept before its first look, so a holder that let go between
/// `reconcile_migration` sampling `held()` and this task being polled — the
/// *common* case, since a deferral is only taken when something was on its
/// way out — still cost a full `RECONCILE_RETRY_INTERVAL` of a startup pass
/// waiting on a lock nobody held. On a paused clock the assertion is exact:
/// the fixed shape returns without the clock moving at all, the sleep-first
/// shape cannot return before it has advanced one interval.
#[tokio::test(start_paused = true)]
async fn an_unheld_project_is_seen_without_waiting_out_an_interval() {
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
assert!(
crate::project_lock::held(&id).is_none(),
"a fresh uuid is not held"
);
let before = tokio::time::Instant::now();
assert!(await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await);
let waited = tokio::time::Instant::now() - before;
assert_eq!(
waited,
std::time::Duration::ZERO,
"an already-released project cost {:?} before anyone looked",
waited
);
}
#[test]
fn only_one_deferred_reconcile_waits_per_project() {
// Every "Docker became available" walks every project, so without the
@@ -2224,14 +2305,72 @@ mod tests {
// per call — all of which then reconcile the same record in a row.
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
let other = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
assert!(claim_reconcile_retry(&id));
assert!(!claim_reconcile_retry(&id), "a second waiter was allowed in");
assert!(claim_reconcile_retry(&other), "the claim is not per-project");
release_reconcile_retry(&id);
assert!(claim_reconcile_retry(&id), "the claim was never handed back");
release_reconcile_retry(&id);
release_reconcile_retry(&other);
// Releasing something that was never claimed is not an error.
release_reconcile_retry(&id);
let first = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
assert!(
claim_reconcile_retry(&id).is_none(),
"a second waiter was allowed in"
);
let other_claim =
claim_reconcile_retry(&other).expect("the claim is not per-project");
drop(first);
// Bound rather than discarded: the guard releases on drop, so
// `claim_reconcile_retry(&id);` as a bare statement would test nothing
// — which is what `#[must_use]` is there to catch in real callers.
let retaken = claim_reconcile_retry(&id).expect("the claim was never handed back");
drop(retaken);
drop(other_claim);
// And the other project's claim was never the same claim.
drop(claim_reconcile_retry(&other).expect("released independently"));
}
/// MEDIUM: the claim survives the task that holds it dying badly.
///
/// The release used to be a trailing statement after
/// `reconcile_migration_now(...).await` at the bottom of the spawned task,
/// so a panic anywhere in that call — or the future being dropped at
/// shutdown — skipped it and left the id in the set with no task behind it.
/// Nothing removes it afterwards, so that project could never be deferred
/// again for the rest of the process: exactly the state deferring was added
/// to prevent, now permanent instead of one pass long. Fails against the
/// trailing-statement shape, which is the point.
#[tokio::test]
async fn a_panicking_deferred_reconcile_hands_its_claim_back() {
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
let claimed = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
// Spawned, not just called: the real claim is held across an await
// inside a `tauri::async_runtime::spawn`, and a task panic is caught by
// the runtime rather than unwinding the caller.
let task = {
let id = id.clone();
tokio::spawn(async move {
let _claim = claimed;
tokio::task::yield_now().await;
panic!("reconcile_migration_now blew up on '{}'", id);
})
};
assert!(task.await.is_err(), "the task was supposed to panic");
let after = claim_reconcile_retry(&id);
assert!(
after.is_some(),
"a panicking reconcile stranded the claim — this project can never be \
deferred again for the rest of the process"
);
drop(after);
// The other half of the same failure: a task that is simply dropped
// mid-flight, which is every in-flight task at shutdown.
let claimed = claim_reconcile_retry(&id).expect("released above");
let never_finishes = tokio::spawn(async move {
let _claim = claimed;
std::future::pending::<()>().await;
});
never_finishes.abort();
let _ = never_finishes.await;
assert!(
claim_reconcile_retry(&id).is_some(),
"a dropped task stranded the claim"
);
}
}
+1
View File
@@ -10,6 +10,7 @@ pub mod install_helper_commands;
pub mod migration_commands;
pub mod project_commands;
pub mod settings_commands;
pub mod settings_export_commands;
pub mod stt_commands;
pub mod terminal_commands;
pub mod update_commands;
+821 -47
View File
@@ -2,7 +2,7 @@ use tauri::{Emitter, State};
use crate::commands::aws_commands;
use crate::docker;
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus};
use crate::storage::secure;
use crate::AppState;
@@ -195,7 +195,9 @@ pub(crate) fn load_secrets_for_project(project: &mut Project) {
/// entire host filesystem read-write into a container whose agent has
/// passwordless sudo. Anything short of a filesystem root is the user choosing
/// a folder — the Browse button and the free-text field lead to the same place
/// — so only the roots themselves are refused.
/// — so only the roots themselves are refused, and *root* is answered by
/// resolving the path rather than by reading it: `/..` is spelled like a folder
/// and is the root. See [`classify_mount_source`].
///
/// This is the **whole** rule set, and it belongs to `add_project`, where every
/// row is new by definition. `update_project` runs
@@ -228,17 +230,31 @@ fn validate_one_path(p: &ProjectPath) -> Result<(), String> {
return Err(format!("Mount name '{}' contains invalid characters. Use alphanumeric, dash, underscore, or dot.", p.mount_name));
}
check_mount_name_stays_under_workspace(&p.mount_name)?;
if p.host_path.is_empty() {
// Trimmed: a host path of spaces is not a folder, and `classify_mount_source`
// is deliberately silent about a path with nothing in it — this is the
// message that names the mount it belongs to.
if p.host_path.trim().is_empty() {
return Err(format!(
"Folder mounted at '/workspace/{}' has no host path.",
p.mount_name
));
}
if is_filesystem_root(&p.host_path) {
return Err(format!(
"'{}' is a filesystem root. Choose the project folder itself — mounting the whole drive gives the container everything on it.",
p.host_path
));
match classify_mount_source(&p.host_path) {
None => {}
Some(UnmountableHostPath::FilesystemRoot { resolved }) => {
return Err(filesystem_root_message(
&p.host_path,
&resolved,
"using it as a project folder",
));
}
Some(UnmountableHostPath::NotAbsolute) => {
return Err(format!(
"'{}' is not a full path to a folder — where it lands is decided by wherever \
Triple-C is running from rather than by you. Give the whole path.",
p.host_path
));
}
}
Ok(())
}
@@ -264,6 +280,23 @@ fn check_mount_name_stays_under_workspace(mount_name: &str) -> Result<(), String
mount_name
));
}
// **An empty name is not refused here, and that is a live residual.** It
// makes the target `/workspace/`, which the daemon normalises to
// `/workspace` — so the host folder shadows the directory the other mounts
// land in, and Docker then creates their mount points *inside it*, on the
// host. It is not refused because it cannot be: an empty name is what a
// half-filled row holds, `legacy_rows` shows those are already in
// `projects.json`, and this function runs on grandfathered rows too, so
// refusing it would make every such project unsavable — the exact
// regression [`validate_project_paths_update`] exists to prevent.
//
// Introducing one is closed at both ends: `validate_one_path` refuses an
// empty name on any new or edited row, and `WorkspaceSection` no longer
// sends half-filled or blank ones. What remains is the *stored* row, and it
// belongs where the mount is built — `docker::create_container` should skip
// a row with no mount name or no host path, which is the same filter that
// stops a stored blank row failing the create with
// `field Source must not be empty`.
if !mount_name.is_empty() && mount_name.chars().all(|c| c == '.') {
return Err(format!(
"Mount name '{}' is not a folder name — it names the directory the mount would sit in.",
@@ -363,12 +396,13 @@ fn validate_project_paths_update(
/// `/tmp/.host-ssh` and `/tmp/.host-ca` — and neither had any check at all, so
/// `/` handed the whole host filesystem to the agent to read. Read-only, so
/// this is disclosure rather than the read-write hole a `/` project folder is,
/// but the fix is the same one line.
/// but it is the same check: [`classify_mount_source`], resolved rather than
/// spelled, so `/..` and `/home/..` are refused here too.
///
/// Same grandfathering as the folder list, for the same reason: a value already
/// stored is already mounted on every start, and refusing an unrelated save
/// does not unmount it. Only a *change* is held to the rule.
fn validate_mounted_host_path(
pub(crate) fn validate_mounted_host_path(
label: &str,
stored: Option<&str>,
incoming: Option<&str>,
@@ -379,29 +413,255 @@ fn validate_mounted_host_path(
if stored.map(str::trim) == Some(value) {
return Ok(());
}
if is_filesystem_root(value) {
return Err(format!(
"'{}' is a filesystem root, so setting it as {} would mount the whole drive into the \
container. Choose the folder itself.",
value, label
));
match classify_mount_source(value) {
None => {}
Some(UnmountableHostPath::FilesystemRoot { resolved }) => {
return Err(filesystem_root_message(
value,
&resolved,
&format!("setting it as {}", label),
));
}
Some(UnmountableHostPath::NotAbsolute) => {
return Err(format!(
"'{}' is not a full path, so it cannot be used as {}. Give the whole path.",
value, label
));
}
}
Ok(())
}
/// Whether a host path is the root of a filesystem, in any spelling the three
/// desktop platforms produce: `/`, a Windows drive root, or a bare UNC/share
/// prefix. Trailing separators are ignored, so `C:\\` and `C:/` are the same
/// answer.
fn is_filesystem_root(host_path: &str) -> bool {
let trimmed = host_path.trim_end_matches(['/', '\\']);
if trimmed.is_empty() {
// Nothing but separators: `/`, `\\`, `//`.
return true;
/// Why a host path may not be used as the source of a bind mount.
///
/// Two answers rather than a `bool` because they need different sentences, and
/// because "is a root" is no longer a question about how the path is *spelled*
/// — the refusal has to be able to say where the path actually landed.
#[derive(Debug, PartialEq)]
enum UnmountableHostPath {
/// The path is, or resolves to, the root of a filesystem. `resolved` is
/// what it lands on, which is the same string only when a root was typed
/// outright.
FilesystemRoot { resolved: String },
/// The path does not name a location at all. Where it lands is decided by
/// whatever directory Triple-C happens to be running from, so it can be a
/// root tomorrow and a folder today, and nothing here can judge it.
NotAbsolute,
}
/// Length of a `C:` drive prefix at the head of `path`, or 0.
///
/// Duplicated from `commands::file_commands::drive_prefix_len`, together with
/// [`is_windows_style_path`] and [`normalize_host_path`] below. Those are
/// private to that module and it is not this branch's file to change; if the
/// two copies are ever merged, that one is the original and carries the wider
/// test coverage.
fn drive_prefix_len(path: &str) -> usize {
let b = path.as_bytes();
if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
2
} else {
0
}
// `C:` — a drive with no path on it.
let bytes = trimmed.as_bytes();
bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
/// Whether `path` is written in Windows form, and so whether `\` separates its
/// components. On Linux a backslash is an ordinary filename character, which is
/// why this is a question rather than an unconditional substitution.
///
/// Copy of `file_commands::is_windows_style_path` — see [`drive_prefix_len`].
fn is_windows_style_path(path: &str) -> bool {
cfg!(windows) || path.starts_with("\\\\") || drive_prefix_len(path) > 0
}
/// `path` with its separators unified and any Win32 verbatim/device prefix
/// removed — the form every rule below is expressed against.
///
/// `\\?\C:\Windows` and `\\?\UNC\server\share` name the *same locations* as
/// `C:\Windows` and `\\server\share`; the prefix only turns off Win32 path
/// parsing. Stripping it is what stops four characters being a bypass — and it
/// has to run on our own output as well, because `std::fs::canonicalize` hands
/// back exactly that spelling on Windows.
///
/// Copy of `file_commands::normalize_host_path` — see [`drive_prefix_len`].
fn normalize_host_path(path: &str) -> String {
let mut s = if is_windows_style_path(path) {
path.replace('\\', "/")
} else {
path.to_string()
};
// Slicing by byte index is safe here only because a prefix matched
// case-insensitively as ASCII is ASCII, so its end is a char boundary.
for prefix in ["//?/unc/", "//./unc/"] {
if s.len() >= prefix.len()
&& s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
{
return format!("//{}", &s[prefix.len()..]);
}
}
for prefix in ["//?/", "//./"] {
if s.len() >= prefix.len()
&& s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
{
s = s[prefix.len()..].to_string();
break;
}
}
s
}
/// A normalised absolute path split into the root it hangs off and the part
/// below it, or `None` when it names no location at all.
///
/// The three roots the desktop platforms have: `/`, a drive (`C:/`), and a UNC
/// share (`//server/share` — the share *is* the root; `//server` alone names a
/// machine and nothing on it).
fn split_host_root(norm: &str) -> Option<(&str, &str)> {
if let Some(rest) = norm.strip_prefix("//") {
let mut parts = rest.splitn(3, '/');
let server = parts.next().unwrap_or("");
let share = parts.next().unwrap_or("");
if server.is_empty() || share.is_empty() {
// `//server`, `//server/`: no share, so nothing under it is named.
return Some((norm, ""));
}
let root_len = 2 + server.len() + 1 + share.len();
return Some((&norm[..root_len], &norm[root_len..]));
}
let drive = drive_prefix_len(norm);
if drive > 0 {
// `C:x` is drive-*relative* — it means "x under the current directory
// on C:", which is a location only the process's own state decides.
return match norm[drive..].strip_prefix('/') {
Some(tail) => Some((&norm[..drive + 1], tail)),
None if norm.len() == drive => Some((norm, "")),
None => None,
};
}
norm.strip_prefix('/').map(|tail| (&norm[..1], tail))
}
/// How many named components deep `tail` ends up, with `.` dropped and `..`
/// applied — clamped at the root, because `/..` is `/` and not an error.
fn depth_below_root(tail: &str) -> usize {
let mut depth = 0usize;
for segment in tail.split('/') {
match segment {
"" | "." => {}
".." => depth = depth.saturating_sub(1),
_ => depth += 1,
}
}
depth
}
/// Whether a host path can be handed to Docker as a bind-mount source, and if
/// not, why.
///
/// ## Resolved, not spelled
///
/// This used to be `is_filesystem_root`, and it was purely lexical: trim the
/// trailing separators, say yes to what was left over only if it was empty or a
/// bare `C:`. Nothing in this file called `canonicalize`, so `/..`, `/./`,
/// `/home/..`, `/etc/../` and `C:\..` all sailed through and were passed
/// verbatim to `docker::create_container`, which builds a
/// `Mount { source, read_only: Some(false) }` out of them. Verified against the
/// daemon: `-v /..:/mnt/probe` mounts the host root. That is the whole host
/// filesystem, read-write, in a container whose agent has passwordless sudo —
/// the same escalation `check_mount_name_stays_under_workspace` exists to
/// close, reached through the host-path half of the mount instead of the
/// mount-name half.
///
/// So the answer comes from the OS where the OS can give one: `canonicalize`
/// applies `..`, follows every symlink in the path, and on Windows returns the
/// long name for an 8.3 alias and the verbatim spelling of a UNC share — all
/// things a string comparison cannot see.
///
/// ## When the path cannot be resolved
///
/// `canonicalize` fails on a path that does not exist *here*, which is an
/// ordinary state rather than an attack: `projects.json` syncs between machines
/// and names `C:\Users\jo\code` on a box that has never had a `C:`, and a
/// folder can be created after the project is. Refusing outright would make
/// every such project unsavable, which is the exact failure
/// [`validate_project_paths_update`] exists to avoid — so an unresolvable path
/// falls back to the lexical answer, with `.` and `..` collapsed by
/// [`depth_below_root`] rather than ignored.
///
/// That is a weaker guarantee, not a wrong one, and the gap is bounded: what
/// resolution adds over the lexical rule is symlinks and 8.3 aliases, and both
/// of those are properties of a path that *exists* — precisely the case where
/// `canonicalize` answers. What is left is a path that does not exist at save
/// time and is a symlink to the root by the time the container starts, i.e. the
/// user doing it to themselves after being asked.
///
/// Blocking on the filesystem here is deliberate: this runs on a save, once per
/// row, and is a single `realpath` walk.
fn classify_mount_source(host_path: &str) -> Option<UnmountableHostPath> {
let raw = host_path.trim();
if raw.is_empty() {
// Emptiness is somebody else's error message — see
// `validate_one_path`, which names the mount it belongs to.
return None;
}
// Nothing but separators, in either spelling: `/`, `//`, `\`, `\\`. Taken
// first because a lone `\` is a *relative* name on Linux, and reporting a
// Windows root as "not a full path" would be answering a question the user
// did not ask.
if raw.chars().all(|c| c == '/' || c == '\\') {
return Some(UnmountableHostPath::FilesystemRoot {
resolved: raw.to_string(),
});
}
// Absoluteness is judged on what the user typed, **before** resolution.
//
// `canonicalize` resolves a relative path against Triple-C's own working
// directory, so it hands back an absolute path and the `NotAbsolute` branch
// below never fires — it was reachable only when canonicalize *failed*,
// i.e. only for relative paths that happened not to exist. That made the
// verdict depend on where the app was launched from: `.` and `..` were
// accepted from the repo, refused from `/`. The daemon then refuses the
// mount outright (`invalid mount path: '..' mount path must be absolute`),
// so the project saved cleanly and could never start again — the bricking
// mode `project_path_mounts`'s filter exists to prevent, reached through
// the host-path half of the row instead of the mount-name half.
if split_host_root(&normalize_host_path(raw)).is_none() {
return Some(UnmountableHostPath::NotAbsolute);
}
let canonical = std::fs::canonicalize(raw)
.ok()
.map(|p| p.to_string_lossy().into_owned());
let judged = canonical.as_deref().unwrap_or(raw);
let norm = normalize_host_path(judged);
let Some((root, tail)) = split_host_root(&norm) else {
return Some(UnmountableHostPath::NotAbsolute);
};
if depth_below_root(tail) == 0 {
return Some(UnmountableHostPath::FilesystemRoot {
resolved: root.to_string(),
});
}
None
}
/// The refusal for a host path that lands on a filesystem root, naming the
/// resolved location as well as what was typed when those differ. `/..` reads
/// as a folder; `/..` *is* `/`, and a message that only quoted it back would
/// leave the user with nothing to act on.
fn filesystem_root_message(typed: &str, resolved: &str, use_for: &str) -> String {
let where_it_lands = if typed.trim() == resolved {
format!("'{}' is a filesystem root", typed)
} else {
format!("'{}' resolves to '{}', which is a filesystem root", typed, resolved)
};
format!(
"{}, so {} would mount the whole drive into the container. Choose the folder itself.",
where_it_lands, use_for
)
}
#[tauri::command]
@@ -436,7 +696,7 @@ pub async fn add_project(
pub async fn remove_project(
project_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
) -> Result<ProjectRemovalReport, String> {
// **H-2: the only writer of these three categories that held nothing.**
// This purges migration artifacts, removes `triple-c-snapshot-{id}` and
// both named volumes — and a compaction resolves that same tag when its
@@ -462,12 +722,76 @@ pub async fn remove_project(
// holding an entire snapshot image that nothing will ever reference again.
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
// Stop and remove container if it exists
if let Some(ref project) = state.projects_store.get(&project_id) {
if let Some(ref container_id) = project.container_id {
state.exec_manager.close_sessions_for_container(container_id).await;
// Stop and remove container if it exists. Everything named in `report`
// below is what will be unreachable the moment this function drops the
// project record — see [`ProjectRemovalReport`] and
// `storage::pending_cleanup`, which is what makes it reachable anyway.
let mut report = ProjectRemovalReport::default();
let existing_project = state.projects_store.get(&project_id);
if let Some(ref project) = existing_project {
// Resolved via `find_existing_container` unconditionally rather than
// trusting `project.container_id` — that field can be *stale*, not
// just absent: `start_project_container_locked`'s recreate path
// removes the old container, creates a new one, and does not persist
// the new id until after `start_container` succeeds, so a start
// failure in between (a missing `/dev/net/tun`, an image that exits
// immediately) leaves the stored id pointing at a container that no
// longer exists while a live one sits under the same deterministic
// name. Removing by a stale id then 404s — success as far as Docker
// is concerned — while the real container survives to block every
// subsequent volume removal with a 409, with nothing in the report
// ever naming it. `find_existing_container` is what every other
// destroyer of a project's container already resolves through
// (`start_project_container`, migration's recreate paths) for this
// exact reason.
//
// A `Docker unreachable` error here is treated as "assume a
// container is still there" rather than "assume none is", matching
// `remove_volumes_by_name`'s fail-closed handling of the same
// situation — the alternative silently drops the one resource most
// likely to block everything else if it does exist.
//
// Exec sessions are closed for `project.container_id` unconditionally,
// before the lookup above and regardless of whether it succeeds —
// that is host-side state with no Docker dependency, so it must not
// wait on a daemon that might not answer. Resolving through
// `find_existing_container` instead of using it directly would leave
// these open in exactly the two cases this whole change exists to
// handle: Docker unreachable (no id resolved, no way to ever close
// them again once the project record is gone) and the stale-id race
// (sessions were opened against the container that actually exists,
// which is what gets resolved below, not the stored id).
if let Some(ref stored_id) = project.container_id {
state.exec_manager.close_sessions_for_container(stored_id).await;
}
let container_ref = match docker::find_existing_container(project).await {
Ok(found) => found,
Err(e) => {
log::warn!(
"Could not check for an existing container for project {}: {}",
project_id, e
);
report.container = Some(project.container_name());
None
}
};
if let Some(ref container_id) = container_ref {
if project.container_id.as_deref() != Some(container_id.as_str()) {
state.exec_manager.close_sessions_for_container(container_id).await;
}
let _ = docker::stop_container(container_id).await;
let _ = docker::remove_container(container_id).await;
if let Err(e) = docker::remove_container(container_id).await {
log::warn!(
"Failed to remove container {} for project {}: {}",
container_id, project_id, e
);
// Recorded by name, not id: the name is the stable handle a
// later retry can still resolve (Docker's remove-container
// call accepts either), and it is what `container_ref` above
// falls back to finding in the first place.
report.container = Some(project.container_name());
}
}
// Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
@@ -478,10 +802,9 @@ pub async fn remove_project(
// Clean up the snapshot image + volumes
if let Err(e) = docker::remove_snapshot_image(project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
report.image = Some(docker::get_snapshot_image_name(project));
}
if let Err(e) = docker::remove_project_volumes(project).await {
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
}
report.volumes = docker::remove_project_volumes(project).await;
}
// Clean up keychain secrets for this project
@@ -489,7 +812,216 @@ pub async fn remove_project(
log::warn!("Failed to delete keychain secrets for project {}: {}", project_id, e);
}
state.projects_store.remove(&project_id)
if !report.is_clean() {
let record = crate::storage::pending_cleanup::PendingCleanup {
project_id: project_id.clone(),
project_name: existing_project.map(|p| p.name).unwrap_or_default(),
container_id: report.container.clone(),
image: report.image.clone(),
volumes: report.volumes.clone(),
recorded_at: chrono::Utc::now().to_rfc3339(),
};
match crate::storage::pending_cleanup::save(&record) {
Ok(()) => {
report.retry_scheduled = true;
log::warn!(
"Project {} removed; could not confirm these Docker resources were removed: \
{:?} recorded for automatic retry on next launch",
project_id, report
);
}
Err(e) => {
report.retry_scheduled = false;
log::error!(
"Project {} removed; could not confirm these Docker resources were removed \
({:?}), and the pending-cleanup record could not be written ({}) nothing \
will retry removing them",
project_id, report, e
);
}
}
}
// The pending-cleanup record above must not outlive the project record it
// describes: if the store's own write fails (full disk, permissions) the
// project is still on disk and will reload on the next launch, but the
// record would tell startup housekeeping to delete its container and
// volumes out from under it. Roll the record back rather than leaving
// that mismatch for the retry to discover the hard way.
//
// This is a second, narrower line of defence, not the only one — a crash
// between the `save` above and the `remove` below leaves exactly the same
// mismatch with no error for either side to catch, which is why
// `retry_pending_cleanup_logged` also refuses to act on a record whose
// project is still listed in `projects.json`. Belt and suspenders: a
// caught failure here is handled immediately rather than waiting for the
// next launch to notice.
if let Err(e) = state.projects_store.remove(&project_id) {
if !report.is_clean() {
if let Err(clear_err) = crate::storage::pending_cleanup::clear(&project_id) {
log::error!(
"Project {} was not removed ({}), and its pending-cleanup record could not \
be rolled back either ({}) it will name this still-live project until \
startup housekeeping's own guard clears it",
project_id, e, clear_err
);
}
}
return Err(e);
}
Ok(report)
}
/// Retry every pending-cleanup record left behind by a [`remove_project`]
/// that could not finish. Run once at startup alongside the other reapers
/// (see `lib.rs`'s "Startup disk housekeeping" block) — never on a timer and
/// never blocking anything, since a locked volume or an in-use image can sit
/// unresolved for an arbitrary amount of time and the daemon may not even be
/// up yet.
///
/// Not a `#[tauri::command]`: nothing in the UI surfaces this list yet
/// (deliberately — see `SnapshotSweepReport`'s doc comment for the same
/// reasoning), so there is no IPC contract to keep. A record that still has
/// leftovers after this is written back so the next run does not lose track
/// of what changed; one that is now empty is deleted.
///
/// Takes the `ProjectsStore` so it can refuse to touch a project that is
/// still live: `remove_project` writes a pending-cleanup record durably
/// (fsync'd) *before* it asks the store to drop the project, and that
/// store write is a plain `fs::write` with no fsync of its own. A crash or
/// power loss in the gap between the two — or the store write failing
/// outright, on top of the round-2 fix that only rolls the record back when
/// that failure is caught in-process — can leave a record on disk pointing
/// at a project `projects.json` still lists. Without this check, the very
/// first retry after such a crash deletes that project's container,
/// snapshot image and *both volumes, including the one holding the OAuth
/// credential and every session transcript*, out from under a project the
/// user still sees in the sidebar. A record whose project still exists is
/// therefore always stale — cleared without touching Docker, not retried.
pub async fn retry_pending_cleanup_logged(projects_store: &crate::storage::projects_store::ProjectsStore) {
let records = crate::storage::pending_cleanup::list();
if records.is_empty() {
return;
}
let mut cleaned = 0usize;
let mut still_pending = 0usize;
for mut record in records {
if projects_store.get(&record.project_id).is_some() {
log::warn!(
"Pending cleanup record for project {} ({}) names a project that still exists — \
clearing the record without touching Docker rather than risk deleting a live \
project's resources",
record.project_id, record.project_name
);
if let Err(e) = crate::storage::pending_cleanup::clear(&record.project_id) {
log::error!(
"Could not clear the stale pending-cleanup record for still-live project {} \
({}): {}",
record.project_id, record.project_name, e
);
}
continue;
}
if let Some(container_id) = record.container_id.take() {
match docker::remove_container(&container_id).await {
Ok(()) => {}
Err(e) => {
log::warn!(
"Pending cleanup: still could not remove container {} for project {} \
({}): {}",
container_id, record.project_id, record.project_name, e
);
record.container_id = Some(container_id);
}
}
}
if let Some(image) = record.image.take() {
match docker::remove_image_by_name(&image).await {
Ok(()) => {}
Err(e) => {
log::warn!(
"Pending cleanup: still could not remove image {} for project {} ({}): {}",
image, record.project_id, record.project_name, e
);
record.image = Some(image);
}
}
}
if !record.volumes.is_empty() {
record.volumes = docker::remove_volumes_by_name(&record.volumes).await;
}
if record.is_empty() {
if let Err(e) = crate::storage::pending_cleanup::clear(&record.project_id) {
log::warn!(
"Pending cleanup for project {} ({}) finished but the record could not be \
deleted: {}",
record.project_id, record.project_name, e
);
}
cleaned += 1;
} else {
still_pending += 1;
// `recorded_at` is otherwise write-only — nothing read it back,
// which is exactly the shape `storage::migration_store` calls out
// as a bug in its own history ("nothing ever removed them"). A
// record that has failed every retry for a week is no longer
// routine: escalate the log level so it is not indistinguishable
// from one seen for the first time.
match pending_cleanup_is_stale(&record.recorded_at, chrono::Utc::now()) {
Some(true) => {
log::error!(
"Pending cleanup for project {} ({}) has not succeeded in over {} \
days: {:?} this may need a manual `docker volume rm` / \
`docker rmi` / `docker rm`",
record.project_id, record.project_name, PENDING_CLEANUP_STALE_AFTER_DAYS, record
);
}
Some(false) => {}
// Silent otherwise would mean a record with a corrupted
// timestamp never escalates and nothing says why.
None => log::debug!(
"Pending cleanup record for project {} ({}) has an unreadable recorded_at \
({:?}) its age cannot be tracked",
record.project_id, record.project_name, record.recorded_at
),
}
if let Err(e) = crate::storage::pending_cleanup::save(&record) {
log::warn!(
"Could not update pending cleanup record for project {} ({}): {}",
record.project_id, record.project_name, e
);
}
}
}
log::info!(
"Pending cleanup retry: {} project(s) fully cleaned up, {} still have leftovers",
cleaned, still_pending
);
}
/// After this many days of a pending-cleanup record failing every retry,
/// `retry_pending_cleanup_logged` escalates its log line from `warn` to
/// `error` — see the comment at its call site.
const PENDING_CLEANUP_STALE_AFTER_DAYS: i64 = 7;
/// Whether a pending-cleanup record's `recorded_at` is older than
/// [`PENDING_CLEANUP_STALE_AFTER_DAYS`], measured against `now`. `None` means
/// the timestamp could not be parsed at all — a corrupted or (hypothetically)
/// hand-edited record — which callers must not silently treat as "not stale"
/// without saying why. `now` is a parameter rather than read internally so
/// this is testable without a live clock.
fn pending_cleanup_is_stale(recorded_at: &str, now: chrono::DateTime<chrono::Utc>) -> Option<bool> {
let recorded = chrono::DateTime::parse_from_rfc3339(recorded_at)
.ok()?
.with_timezone(&chrono::Utc);
Some(now.signed_duration_since(recorded) > chrono::Duration::days(PENDING_CLEANUP_STALE_AFTER_DAYS))
}
#[tauri::command]
@@ -510,7 +1042,7 @@ pub async fn update_project(
// Fields this command does not get to write, whoever is calling it.
//
// `container_id` is the one that matters: it is the handle the whole file
// command surface resolves against, `list_sibling_containers` hands the
// command surface resolves against, `list_sibling_containers` used to hand the
// webview the ids of every other container on the daemon, and a project
// save is not the place a container is adopted. It is assigned by
// `start_project_container` through `projects_store::set_container_id` and
@@ -551,6 +1083,12 @@ pub async fn update_project(
project.ca_cert_path.as_deref(),
)?;
// Custom env var names had no charset check anywhere, so a key like
// `BASH_FUNC_stat%%` reached the container environment verbatim. Same
// grandfathering as the folder list, for the same reason — see
// [`crate::models::validate_env_vars_update`].
crate::models::validate_env_vars_update(&stored.custom_env_vars, &project.custom_env_vars)?;
project.container_id = stored.container_id;
project.status = stored.status;
project.created_at = stored.created_at;
@@ -960,7 +1498,7 @@ pub async fn rebuild_project_container(
project_id: String,
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Project, String> {
) -> Result<ProjectResetOutcome, String> {
// Reset deletes both volumes and the snapshot image. Doing that while a
// migration is mid-flight pulls the ground out from under it and leaves an
// orphan migration record pointing at images that no longer exist — and
@@ -987,25 +1525,58 @@ pub async fn rebuild_project_container(
// `start_project_container` below re-arms it against the new one.
state.auth_bridge.stop(&project_id).await;
// Remove existing container
if let Some(ref container_id) = project.container_id {
state.exec_manager.close_sessions_for_container(container_id).await;
// Remove existing container. Resolved via `find_existing_container`
// unconditionally, not `project.container_id` — see the long comment in
// `remove_project` for why that field can be stale, not just absent. A
// container this misses blocks the volume removal immediately below with
// a 409, and Reset silently keeping the old volumes is exactly the bug
// this whole change is closing. Unlike `remove_project`'s best-effort
// handling of the same lookup failing, `?` here aborts Reset outright:
// every step after this one needs Docker too, so there is no useful
// partial progress to make without it.
// Closed for the stored id unconditionally, then again for the resolved
// one if it differs — see the matching comment in `remove_project` for
// why the stale-id race can leave sessions open under either identity.
if let Some(ref stored_id) = project.container_id {
state.exec_manager.close_sessions_for_container(stored_id).await;
}
let container_ref = docker::find_existing_container(&project).await?;
if let Some(ref container_id) = container_ref {
if project.container_id.as_deref() != Some(container_id.as_str()) {
state.exec_manager.close_sessions_for_container(container_id).await;
}
let _ = docker::stop_container(container_id).await;
docker::remove_container(container_id).await?;
state.projects_store.set_container_id(&project_id, None)?;
}
// Remove snapshot image + volumes so Reset creates from the clean base image
// Remove snapshot image + volumes so Reset creates from the clean base
// image. Both leftovers are surfaced, not just logged — an image that
// survives is the more serious of the two, since
// `start_project_container_locked` below builds from
// `triple-c-snapshot-{id}:latest` whenever it exists, so a leftover image
// means Reset silently rebuilds the exact system layer it promised to
// discard. No pending-cleanup record for either: unlike `remove_project`,
// Reset keeps the project record, so a later Reset attempt can retry
// these itself rather than needing startup housekeeping to do it.
let mut leftover_image = None;
if let Err(e) = docker::remove_snapshot_image(&project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
leftover_image = Some(docker::get_snapshot_image_name(&project));
}
if let Err(e) = docker::remove_project_volumes(&project).await {
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
let leftover_volumes = docker::remove_project_volumes(&project).await;
if leftover_image.is_some() || !leftover_volumes.is_empty() {
log::warn!(
"Reset for project {} could not fully clean up — image: {:?}, volumes: {:?} — the \
new container may be built from, or reuse, old contents instead of starting clean",
project_id, leftover_image, leftover_volumes
);
}
// Start fresh. The locked variant, because `_guard` above is this project's
// claim and the public command would be refused by it.
start_project_container_locked(project_id, app_handle, state).await
let project = start_project_container_locked(project_id, app_handle, state).await?;
Ok(ProjectResetOutcome { project, leftover_image, leftover_volumes })
}
/// Reconcile project statuses against actual Docker container state.
@@ -1113,6 +1684,54 @@ fn default_docker_socket() -> String {
mod tests {
use super::*;
// ── Pending-cleanup aging ────────────────────────────────────────────
#[test]
fn a_record_younger_than_the_threshold_is_not_stale() {
let now = "2026-08-25T00:00:00Z".parse().unwrap();
let recorded_at = "2026-08-19T00:00:00Z"; // 6 days before `now`
assert_eq!(pending_cleanup_is_stale(recorded_at, now), Some(false));
}
#[test]
fn a_record_exactly_at_the_threshold_is_not_yet_stale() {
let now = "2026-08-25T00:00:00Z".parse().unwrap();
let recorded_at = "2026-08-18T00:00:00Z"; // exactly 7 days before `now`
assert_eq!(
pending_cleanup_is_stale(recorded_at, now),
Some(false),
"the boundary itself must not already read as stale"
);
}
#[test]
fn a_record_older_than_the_threshold_is_stale() {
let now = "2026-08-25T00:00:00Z".parse().unwrap();
let recorded_at = "2026-08-17T00:00:00Z"; // 8 days before `now`
assert_eq!(pending_cleanup_is_stale(recorded_at, now), Some(true));
}
/// A clock that ran fast when the record was written leaves a timestamp
/// in the future. This must read as "not stale" rather than underflow or
/// panic — `signed_duration_since` returns a negative `Duration` here,
/// which compares less than any positive threshold correctly.
#[test]
fn a_timestamp_in_the_future_is_not_stale() {
let now = "2026-08-25T00:00:00Z".parse().unwrap();
let recorded_at = "2026-08-26T00:00:00Z"; // one day after `now`
assert_eq!(pending_cleanup_is_stale(recorded_at, now), Some(false));
}
/// A corrupted or hand-edited `recorded_at` must not silently read as
/// "not stale" through some default — callers need to be able to tell
/// "definitely not stale" apart from "cannot tell".
#[test]
fn an_unparseable_recorded_at_reports_unknown_rather_than_not_stale() {
let now = "2026-08-25T00:00:00Z".parse().unwrap();
assert_eq!(pending_cleanup_is_stale("not a timestamp", now), None);
assert_eq!(pending_cleanup_is_stale("", now), None);
}
fn path(host: &str, mount: &str) -> ProjectPath {
ProjectPath {
host_path: host.to_string(),
@@ -1159,6 +1778,161 @@ mod tests {
assert!(validate_project_paths(&[path("C:\\Users\\u\\project", "project")]).is_ok());
}
/// Every spelling of a root that is not *spelled* like one.
///
/// The predicate this replaces trimmed trailing separators and compared
/// what was left, so `/..` — which the daemon mounts as the host root,
/// verified with `docker run -v /..:/mnt/probe` — was indistinguishable
/// from a project folder called `..`. No test in the repo contained a `.`
/// or a `..` in a host path, which is why it shipped.
#[test]
fn a_host_path_that_resolves_to_a_root_is_refused() {
let escapes = [
"/..",
"/../",
"/./",
"/.",
"/home/..",
"/etc/../",
"/tmp/../..",
// Deliberately not present on any machine, so this is the
// unresolvable path taking the lexical route.
"/no-such-dir-here/../..",
"C:\\..",
"C:\\Users\\..",
"c:/foo/..",
// Win32 verbatim spelling of a drive root.
"\\\\?\\C:\\",
"\\\\?\\C:\\..",
// A UNC share root is the root of everything on that share, which
// the old predicate accepted despite its doc comment claiming
// otherwise.
"\\\\server\\share",
"//server/share/",
];
for escape in escapes {
assert!(
validate_project_paths(&[path(escape, "everything")]).is_err(),
"host path '{}' was accepted as a project folder, which bind-mounts a whole \
filesystem read-write into a container with passwordless sudo",
escape
);
// The same value must not be reachable through the editor either.
assert!(
validate_project_paths_update(&[], &[path(escape, "everything")]).is_err(),
"host path '{}' was accepted through update_project",
escape
);
// And the two read-only mounts are the same check.
assert!(
validate_mounted_host_path("the SSH key folder", None, Some(escape)).is_err(),
"'{}' was accepted as an SSH key path, which read-only bind-mounts a whole \
filesystem at /tmp/.host-ssh",
escape
);
}
}
/// A drive-relative path (`C:x`, no separator) means "x under whatever the
/// current directory on C: happens to be" — a location decided by the
/// process rather than by the user, so it may be the drive root.
#[test]
fn a_relative_path_is_refused_however_it_resolves_from_here() {
// The previous test for this passed by coincidence: its four examples
// did not exist under `app/src-tauri`, so `canonicalize` failed and the
// `NotAbsolute` branch fired for the wrong reason. Creating a directory
// named `project` there flipped it red.
//
// These are paths that *do* exist relative to wherever the test runs,
// so they exercise the branch that used to be unreachable. Judged on
// the typed string, the answer is the same from any working directory —
// which is the property that matters, because the daemon refuses a
// relative mount source and the project would save fine and then never
// start.
for existing in [".", "..", "src", "./src"] {
assert!(
matches!(
classify_mount_source(existing),
Some(UnmountableHostPath::NotAbsolute)
),
"{} is relative and must be refused regardless of cwd",
existing
);
}
// And the fix must not have made an absolute path unreachable.
assert!(
classify_mount_source("/usr").is_none(),
"an ordinary absolute folder must still be accepted"
);
}
#[test]
fn a_path_that_names_no_location_is_refused_rather_than_guessed_at() {
for relative in ["C:x", "C:Users\\jo", "relative/path", "./project"] {
assert!(
validate_project_paths(&[path(relative, "project")]).is_err(),
"'{}' was accepted, though where it lands depends on Triple-C's own \
working directory",
relative
);
}
}
/// The dots that are *not* an escape have to keep working — a folder can
/// legitimately be reached through `.` or a `..` that goes back down again,
/// and the Browse button produces paths on machines this list is not
/// running on.
#[test]
fn an_ordinary_folder_is_still_accepted_however_it_is_spelled() {
for ok in [
"/home/u/./project",
"/home/u/x/../project",
"/home/u/..project",
"/home/u/project/..hidden",
"C:\\Users\\u\\x\\..\\project",
"\\\\server\\share\\project",
"\\\\?\\C:\\Users\\u\\project",
] {
assert!(
validate_project_paths(&[path(ok, "project")]).is_ok(),
"host path '{}' should be usable",
ok
);
}
}
/// The half of this that only resolution can answer.
///
/// A lexical check sees a two-component path under `/tmp` and stops. The
/// container is what plants the link — `/proc/self/mountinfo` inside a
/// Triple-C container spells the host's project paths out verbatim — so the
/// symlink is reachable, and the mount that follows it is read-write.
#[cfg(unix)]
#[test]
fn a_symlink_to_the_root_is_refused_because_resolution_is_what_answers() {
let dir = std::env::temp_dir().join(format!(
"triple-c-root-link-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let link = dir.join("innocent");
std::os::unix::fs::symlink("/", &link).unwrap();
let verdict = validate_project_paths(&[path(&link.to_string_lossy(), "project")]);
std::fs::remove_file(&link).ok();
std::fs::remove_dir(&dir).ok();
assert!(
verdict.is_err(),
"a symlink to / was accepted as a project folder; only canonicalisation can see it"
);
}
#[test]
fn duplicate_and_half_filled_rows_are_refused_but_the_blank_row_is_not() {
assert!(validate_project_paths(&[
@@ -10,12 +10,72 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, Str
Ok(state.settings_store.get())
}
/// Everything `update_settings` refuses a save over, run against the store's
/// *current* value and the incoming one.
///
/// Pulled out so a caller that does other, harder-to-undo work alongside a
/// settings save — `settings_export_commands::apply_settings_import`
/// restores three keychain secrets in the same command — can run this
/// *first* and bail before touching anything, rather than discovering the
/// rejection only when `update_settings` itself runs partway through.
pub fn validate_settings_update(
before: &AppSettings,
incoming: &AppSettings,
) -> Result<(), String> {
// The global half of the same rule the project half gets in
// `update_project`: a global custom env var is merged into every project's
// container environment, so an unchecked name here reaches all of them.
crate::models::validate_env_vars_update(
&before.global_custom_env_vars,
&incoming.global_custom_env_vars,
)?;
// The same for the two host paths this struct owns. `update_project`
// validated its per-project overrides and this side validated nothing,
// which left the wider hole of the two: `default_ssh_key_path` is the
// fallback for **every** project without an override
// (`container.rs`'s `create_container`), so `/` here read-only bind-mounts
// the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh`
// then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the
// home volume this release exists to bound.
//
// Grandfathered the same way project paths are: a value carried over
// unchanged still saves, so a store written before this check cannot lock
// the user out of their own settings.
crate::commands::project_commands::validate_mounted_host_path(
"SSH key path",
before.default_ssh_key_path.as_deref(),
incoming.default_ssh_key_path.as_deref(),
)?;
crate::commands::project_commands::validate_mounted_host_path(
"CA certificate path",
before.ca_cert_path.as_deref(),
incoming.ca_cert_path.as_deref(),
)?;
// Third host path this struct owns, same reasoning: any project with
// `allow_docker_access` bind-mounts this path in as the Docker socket
// (`project_commands.rs`'s container creation), so an unchecked value
// here is a read-write bind mount of whatever it names into every such
// project's container.
crate::commands::project_commands::validate_mounted_host_path(
"Docker socket path",
before.docker_socket_path.as_deref(),
incoming.docker_socket_path.as_deref(),
)?;
Ok(())
}
#[tauri::command]
pub async fn update_settings(
settings: AppSettings,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
let before = state.settings_store.get();
validate_settings_update(&before, &settings)?;
let saved = state.settings_store.update(settings)?;
// Persisting a setting is not the same as applying it. The gateway is the
@@ -90,7 +150,10 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
GatewayAction::StopIfRunning => {
log::info!("Model gateway disabled in settings — stopping the container");
if let Err(e) = docker::gateway::stop_gateway_container().await {
log::error!("Failed to stop the model gateway after it was disabled: {}", e);
log::error!(
"Failed to stop the model gateway after it was disabled: {}",
e
);
}
}
GatewayAction::RestartIfRunning => {
@@ -106,10 +169,7 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
}
#[tauri::command]
pub async fn pull_image(
image_name: String,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
use tauri::Emitter;
docker::pull_image(&image_name, move |msg| {
let _ = app_handle.emit("image-pull-progress", msg);
@@ -302,7 +362,10 @@ mod tests {
let before = enabled_gateway();
let mut after = before.clone();
after.enabled = false;
assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
assert_eq!(
gateway_action(&before, &after),
GatewayAction::StopIfRunning
);
// Still true when it was already off — a stray running container is
// still a container that shouldn't be up.
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
@@ -0,0 +1,654 @@
//! Settings export/import — see triple-c#35.
//!
//! Exports the *host* environment (global `AppSettings` plus the global
//! secrets kept in the OS keychain: the shared Claude Code OAuth login and
//! the model gateway's two keys), encrypted with a user-chosen password —
//! see `storage::settings_crypto` for the actual cryptography. Deliberately
//! out of scope: per-project settings, per-project secrets, and anything
//! living in a project's Docker volumes.
//!
//! **The save/open dialogs are opened from Rust**, the same pattern
//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already
//! establish and document at length: a frontend-driven dialog handing Rust a
//! host path string is the exact shape of bug that produced this app's past
//! criticals, so the boundary here is drawn the same place. The frontend can
//! ask for a picker; it cannot name a host path as an *input*. `preview_
//! settings_import` resolves the chosen path itself and remembers it
//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads
//! the same file without the path ever crossing back over IPC.
//!
//! The *decrypted payload* is not cached between preview and apply — the
//! password the frontend passes to each call is what it already held for
//! the first, not a fresh secret extracted from the user, but nothing here
//! keeps the plaintext itself — export/import secrets included — around for
//! longer than one command's execution; `apply_settings_import` re-decrypts
//! the file rather than reusing anything `preview_settings_import` computed.
//!
//! **This is new attack surface**: a settings export is a file one person
//! can hand another and ask them to import, together with a password, and
//! `apply_settings_import` applies whatever `AppSettings` it decrypts to
//! wholesale — see the module doc on `models::settings_export` for the
//! `web_terminal.access_token` carve-out a review of this feature found,
//! and treat that as the standing example of the class of thing to keep
//! checking for here, not a one-off fixed bug.
#[cfg(test)]
use std::path::Path;
use std::path::PathBuf;
use sha2::{Digest, Sha256};
use tauri::State;
use tauri_plugin_dialog::DialogExt;
use zeroize::Zeroizing;
use crate::models::{
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
};
use crate::storage::{secure, settings_crypto};
use crate::AppState;
/// What `preview_settings_import` pins so `apply_settings_import` can tell
/// whether the file it's about to re-read is the same one the user actually
/// saw a preview of. Confirming a preview is only meaningful if it's binding
/// on what gets applied — without this, a file replaced on disk between the
/// two calls (this app's own stated threat model is a file shared between
/// people, which may sit in a synced or shared directory) would decrypt and
/// apply silently different content than what the confirmation dialog showed.
#[derive(Debug, Clone)]
pub struct PendingSettingsImport {
path: PathBuf,
ciphertext_hash: [u8; 32],
}
fn hash_ciphertext(data: &[u8]) -> [u8; 32] {
Sha256::digest(data).into()
}
const FILE_EXTENSION: &str = "triplec";
/// Enforced here, not only in the export modal: the frontend's minimum is a
/// UX nudge, but `export_settings` is the actual boundary a weak password
/// has to cross, and Argon2id's memory-hardness buys little against an
/// attacker who can just try a three-character password directly.
const MIN_PASSWORD_LEN: usize = 8;
fn suggested_export_name() -> String {
// Timestamped so exporting more than once doesn't silently overwrite an
// earlier file just because the save dialog defaults to the same name.
format!(
"triple-c-settings-{}.{}",
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
FILE_EXTENSION
)
}
async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
let (tx, rx) = tokio::sync::oneshot::channel();
window
.dialog()
.file()
.set_parent(window)
.set_title("Export Triple-C settings")
.set_file_name(suggested)
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
.save_file(move |picked| {
let _ = tx.send(picked);
});
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
}
async fn pick_import_open_path(window: &tauri::Window) -> Option<PathBuf> {
let (tx, rx) = tokio::sync::oneshot::channel();
window
.dialog()
.file()
.set_parent(window)
.set_title("Import Triple-C settings")
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
.pick_file(move |picked| {
let _ = tx.send(picked);
});
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
}
/// Gather the current global secrets, and hand back the `AppSettings` to
/// export with the web-terminal token blanked out of it — see the module
/// doc comment on `models::settings_export` for why that field cannot
/// travel through `settings` like the rest of this struct.
///
/// A missing keychain secret reads as `None` — a keychain read failure is
/// treated as "nothing to export" for that one entry rather than aborting
/// the whole export, matching how the rest of this app degrades a keychain
/// error to "absent" (`has_claude_oauth_token`, `has_gateway_api_key`)
/// rather than surfacing it as a hard failure.
fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSecrets) {
let mut settings = current;
let web_terminal_access_token = settings.web_terminal.access_token.take();
let secrets = ExportedSecrets {
claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(),
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
web_terminal_access_token,
};
(settings, secrets)
}
/// Export the current global settings and secrets to a password-encrypted
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
/// deliberately distinguishable from one so the frontend shows nothing
/// rather than a "failed" toast for a plain cancel.
#[tauri::command]
pub async fn export_settings(
password: String,
window: tauri::Window,
state: State<'_, AppState>,
) -> Result<bool, String> {
// `.chars().count()` — Unicode scalar values, not bytes — to stay as
// close as this pair of languages allows to the frontend's `.length`
// check (UTF-16 code units); the two only diverge on astral-plane
// characters, which no reasonable password touches.
if password.chars().count() < MIN_PASSWORD_LEN {
return Err(format!(
"Use a password of at least {} characters.",
MIN_PASSWORD_LEN
));
}
let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
return Ok(false);
};
let (settings, secrets) = split_settings_and_secrets(state.settings_store.get());
if secrets.is_empty() {
log::info!("Exporting settings with no global secrets configured on this machine");
}
let payload = SettingsExportPayload {
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
exported_at: chrono::Utc::now().to_rfc3339(),
app_version: env!("CARGO_PKG_VERSION").to_string(),
settings,
secrets,
};
let plaintext = Zeroizing::new(
serde_json::to_vec(&payload)
.map_err(|e| format!("Failed to prepare settings for export: {}", e))?,
);
let encrypted = settings_crypto::encrypt(&plaintext, &password)?;
std::fs::write(&dest, &encrypted).map_err(|e| format!("Failed to write export file: {}", e))?;
Ok(true)
}
/// Open a file picker, decrypt the chosen file with `password`, and return a
/// preview (counts and presence flags only — never a secret value) for a
/// confirmation UI. `Ok(None)` means the picker was dismissed.
///
/// Remembers the resolved path *and a hash of the file's ciphertext* in
/// `AppState::pending_settings_import` for `apply_settings_import` to check
/// against — does **not** remember the decrypted payload itself, so the
/// password must be supplied again to actually apply it — seeing the preview
/// is not the same as committing to it. The hash exists so it also can't be
/// swapped out from under that commitment: `apply_settings_import` refuses to
/// proceed if the file on disk no longer matches what was just previewed.
#[tauri::command]
pub async fn preview_settings_import(
password: String,
window: tauri::Window,
state: State<'_, AppState>,
) -> Result<Option<SettingsImportPreview>, String> {
if password.is_empty() {
return Err("A password is required to open a settings export.".to_string());
}
let Some(path) = pick_import_open_path(&window).await else {
return Ok(None);
};
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
let preview = SettingsImportPreview::from_payload(&payload);
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
path,
ciphertext_hash: hash_ciphertext(&encrypted),
});
Ok(Some(preview))
}
/// Apply the import a prior `preview_settings_import` call resolved a path
/// for. Fails if no preview is pending — this is not a general "decrypt and
/// apply this file" entry point, deliberately: seeing the preview first is
/// required, not just encouraged, since it is the only place a user is told
/// what an import is about to touch before it touches it. That requirement
/// is only real if the file can't change out from under it, so this also
/// refuses to proceed if the file's ciphertext no longer matches the hash
/// `preview_settings_import` pinned — a file replaced on disk between the
/// two calls (this feature's own threat model is a file shared between
/// people, which may sit in a synced or shared directory) must not be able
/// to apply silently different content than what the confirmation dialog
/// showed.
///
/// Global settings are replaced wholesale — an import is "restore this
/// environment," not a field-by-field merge. Global secrets are handled
/// differently and on purpose: **only secrets actually present in the
/// import are written**; a secret the export doesn't have is left alone on
/// this machine rather than cleared, because an absent secret in the export
/// means "the source machine never had this configured," not "delete this
/// on import." A user who wants to clear a secret already has dedicated UI
/// for that (signing out of shared auth, clearing the gateway key).
///
/// Order matters here, twice over.
///
/// First: the imported settings are **validated before any secret is
/// written**, using the same checks `update_settings` itself runs
/// (`settings_commands::validate_settings_update`). Restoring a secret is
/// hard to undo unnoticed — a stale env-var-name rejection or a disallowed
/// host path used to be caught only when `update_settings` ran, by which
/// point the three keychain secrets below were already overwritten with the
/// file's, each with a fresh rotation id, silently flagging every project
/// container for recreation — while the error the user saw talked only
/// about the rejected setting and said nothing about the credentials that
/// had already moved. Failing this check first makes a rejected import
/// leave nothing touched, matching what "the import failed" is supposed to
/// mean.
///
/// Second, among the things that *do* get written: secrets are restored
/// **before** the settings replace runs (which is what triggers
/// `reconcile_gateway`), so a gateway recreation that replace provokes sees
/// the final key material rather than racing it — restoring the other way
/// round left a real window where the running gateway and the keychain
/// briefly disagreed. A gateway *secret* alone (same shape, new key) is
/// invisible to `reconcile_gateway`'s shape comparison, so this additionally
/// nudges a running gateway container to recreate itself whenever a secret
/// this import carried was actually written — otherwise the running
/// container keeps serving the old key material indefinitely while every
/// project container is handed the new one.
///
/// A keychain write failing is reported back rather than only logged: an
/// import that silently restores two of three secrets but not the third
/// must not read as unqualified success.
///
/// The pending import is only cleared on success. A failure here (rejected
/// by the validation above, a stale-file mismatch, or some other error)
/// leaves it pending so the frontend can let the user retry `apply` without
/// making them pick the file and re-enter the password again — the
/// preview's job was confirming *what* to import, not spending the one
/// attempt at applying it.
#[tauri::command]
pub async fn apply_settings_import(
password: String,
state: State<'_, AppState>,
) -> Result<SettingsImportOutcome, String> {
if password.is_empty() {
return Err("A password is required to import settings.".to_string());
}
let pending = state
.pending_settings_import
.lock()
.await
.clone()
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
let encrypted = std::fs::read(&pending.path)
.map_err(|e| format!("Failed to read export file: {}", e))?;
if hash_ciphertext(&encrypted) != pending.ciphertext_hash {
return Err(
"This file changed since you reviewed it — choose it again to see an up-to-date preview."
.to_string(),
);
}
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
let current = state.settings_store.get();
// The web-terminal token lives inside `AppSettings` itself rather than
// the keychain, so "leave an absent secret alone" has to be done by
// hand here: carry the destination's current token forward when the
// import doesn't have one, instead of letting the wholesale replace
// below blank it (every export writes `None` there — see
// `split_settings_and_secrets`).
let mut settings = payload.settings;
settings.web_terminal.access_token = non_blank(payload.secrets.web_terminal_access_token)
.or_else(|| current.web_terminal.access_token.clone());
crate::commands::settings_commands::validate_settings_update(&current, &settings)?;
let mut secret_restore_warnings = Vec::new();
let mut gateway_secret_changed = false;
if let Some(token) = non_blank(payload.secrets.claude_oauth_token) {
if let Err(e) = secure::store_claude_oauth_token(&token) {
log::warn!(
"Settings import: could not restore the shared Claude login: {}",
e
);
secret_restore_warnings
.push(format!("Could not restore your shared Claude login: {}", e));
}
}
if let Some(key) = non_blank(payload.secrets.gateway_api_key) {
match secure::store_gateway_api_key(&key) {
Ok(()) => gateway_secret_changed = true,
Err(e) => {
log::warn!(
"Settings import: could not restore the gateway provider API key: {}",
e
);
secret_restore_warnings.push(format!(
"Could not restore the gateway provider API key: {}",
e
));
}
}
}
if let Some(key) = non_blank(payload.secrets.gateway_master_key) {
match secure::store_gateway_master_key(&key) {
Ok(()) => gateway_secret_changed = true,
Err(e) => {
log::warn!(
"Settings import: could not restore the gateway master key: {}",
e
);
secret_restore_warnings
.push(format!("Could not restore the gateway master key: {}", e));
}
}
}
let saved =
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
// *shape* — port, provider, base URL, models — because that's what's
// rendered into the container's config. A secret changing with the shape
// held constant is invisible to it, so a running gateway container would
// otherwise keep serving the old key material forever after an import
// that restored a new one, while `docker::gateway`'s own fingerprint
// (which does include the secret rotation id) means the *next* unrelated
// settings save would suddenly and confusingly recreate it instead.
if gateway_secret_changed && saved.gateway.enabled {
match crate::docker::gateway::gateway_container_presence().await {
Ok((true, true)) => {
if let Err(e) = crate::docker::gateway::ensure_gateway_running(&saved.gateway).await
{
log::error!(
"Settings import: could not apply the restored gateway credentials to the running gateway container: {}",
e
);
}
}
Ok(_) => {}
Err(e) => log::debug!("Settings import: gateway reconcile skipped ({})", e),
}
}
state.pending_settings_import.lock().await.take();
Ok(SettingsImportOutcome {
settings: saved,
secret_restore_warnings,
})
}
fn non_blank(value: Option<String>) -> Option<String> {
value.filter(|v| !v.trim().is_empty())
}
/// Only the field `read_and_decrypt` needs before deciding whether the rest
/// of the payload is even worth attempting to parse.
#[derive(serde::Deserialize)]
struct FormatVersionProbe {
format_version: u32,
}
/// Read and decrypt an export file at `path`, then parse it — see
/// `read_and_decrypt_bytes` for why the format-version check runs before the
/// full parse. Every real caller already has the file's bytes in hand by the
/// time it needs this (`preview_settings_import`/`apply_settings_import`
/// both hash the ciphertext first) and calls `read_and_decrypt_bytes`
/// directly to avoid reading the file twice; this path-based wrapper only
/// exists now for tests that don't need that.
#[cfg(test)]
fn read_and_decrypt(path: &Path, password: &str) -> Result<SettingsExportPayload, String> {
let encrypted =
std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?;
read_and_decrypt_bytes(&encrypted, password)
}
/// Decrypt and parse an already-read export file's bytes, checking the
/// format version **before** attempting to deserialize the full payload.
///
/// That ordering is not just tidiness: a version bump that isn't
/// deserialize-compatible (a field's type changes, not just a new
/// `#[serde(default)]`-covered one) is exactly the case this check exists
/// for, and parsing the full struct first would fail on the shape mismatch
/// before the version check ever ran, surfacing a raw parse error instead
/// of "update Triple-C" — and, more seriously, `serde_json`'s type-mismatch
/// errors quote the offending value inline. This file is not attacker
/// content in the usual sense (it must still decrypt under the right
/// password), but the plaintext it decrypts to can hold a live credential,
/// so neither error path below ever interpolates what `serde_json`
/// actually says — only a fixed, generic message.
fn read_and_decrypt_bytes(encrypted: &[u8], password: &str) -> Result<SettingsExportPayload, String> {
let plaintext = settings_crypto::decrypt(encrypted, password)?;
let probe: FormatVersionProbe = serde_json::from_slice(&plaintext)
.map_err(|_| "This file doesn't look like a valid settings export.".to_string())?;
if probe.format_version > SETTINGS_EXPORT_FORMAT_VERSION {
return Err(format!(
"This export was made by a newer version of Triple-C (format {}, this app supports up to {}). \
Update Triple-C before importing it.",
probe.format_version, SETTINGS_EXPORT_FORMAT_VERSION
));
}
serde_json::from_slice(&plaintext).map_err(|_| {
"This file doesn't look like a valid settings export (unexpected shape).".to_string()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_blank_treats_whitespace_only_as_absent() {
assert_eq!(non_blank(Some(" ".to_string())), None);
assert_eq!(non_blank(Some("".to_string())), None);
assert_eq!(non_blank(None), None);
assert_eq!(non_blank(Some(" a ".to_string())), Some(" a ".to_string()));
}
#[test]
fn ciphertext_hashing_is_deterministic_and_tamper_sensitive() {
// What `apply_settings_import` compares against the pinned hash from
// `preview_settings_import` to detect a file swapped out from under a
// pending import — this only defends anything if identical bytes
// always hash identically and any change to those bytes changes the
// hash.
let bytes = b"pretend this is an encrypted export file";
assert_eq!(hash_ciphertext(bytes), hash_ciphertext(bytes));
let mut tampered = bytes.to_vec();
tampered[0] ^= 0xFF;
assert_ne!(hash_ciphertext(bytes), hash_ciphertext(&tampered));
}
fn write_export(
dir: &std::path::Path,
name: &str,
payload: &SettingsExportPayload,
password: &str,
) -> PathBuf {
write_raw_export(dir, name, &serde_json::to_value(payload).unwrap(), password)
}
/// Like `write_export`, but takes an arbitrary `serde_json::Value` rather
/// than a real `SettingsExportPayload` — for fixtures that are
/// deliberately not shape-compatible, which the typed helper above can't
/// produce at all.
fn write_raw_export(
dir: &std::path::Path,
name: &str,
value: &serde_json::Value,
password: &str,
) -> PathBuf {
let plaintext = serde_json::to_vec(value).unwrap();
let encrypted = settings_crypto::encrypt(&plaintext, password).unwrap();
let path = dir.join(name);
std::fs::write(&path, &encrypted).unwrap();
path
}
#[test]
fn splitting_settings_moves_the_web_terminal_token_out_rather_than_copying_it() {
let mut settings = AppSettings::default();
settings.web_terminal.access_token = Some("super-secret-token".to_string());
let (settings, secrets) = split_settings_and_secrets(settings);
assert_eq!(settings.web_terminal.access_token, None);
assert_eq!(
secrets.web_terminal_access_token,
Some("super-secret-token".to_string())
);
}
#[test]
fn splitting_settings_with_no_token_leaves_it_absent_on_both_sides() {
let (settings, secrets) = split_settings_and_secrets(AppSettings::default());
assert_eq!(settings.web_terminal.access_token, None);
assert_eq!(secrets.web_terminal_access_token, None);
}
fn sample_payload(format_version: u32) -> SettingsExportPayload {
SettingsExportPayload {
format_version,
exported_at: "2026-08-27T00:00:00Z".to_string(),
app_version: "0.4.14".to_string(),
settings: AppSettings::default(),
secrets: ExportedSecrets::default(),
}
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"triple-c-settings-export-test-{}-{}",
name,
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_file_from_a_newer_format_is_refused_before_the_full_shape_is_parsed() {
// Shape-incompatible with the *current* `SettingsExportPayload` (a
// future version could easily have changed `settings` from an object
// to something else) as well as newer — so this only passes under
// the probe-first ordering. Parsing the full struct first (the old
// behavior) would fail on the shape mismatch and never reach the
// version check, producing the "unexpected shape" message instead of
// "newer version" / "Update Triple-C".
let dir = temp_dir("newer-format");
let path = write_raw_export(
&dir,
"export.triplec",
&serde_json::json!({
"format_version": SETTINGS_EXPORT_FORMAT_VERSION + 1,
"exported_at": "2026-08-27T00:00:00Z",
"app_version": "9.9.9",
"settings": "this-app-version-stores-settings-differently",
"secrets": {},
}),
"correct password",
);
let err = read_and_decrypt(&path, "correct password").unwrap_err();
assert!(err.contains("newer version"), "unexpected message: {}", err);
assert!(err.contains("Update Triple-C"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_file_at_the_current_format_is_accepted() {
let dir = temp_dir("current-format");
let path = write_export(
&dir,
"export.triplec",
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
"correct password",
);
let payload = read_and_decrypt(&path, "correct password").unwrap();
assert_eq!(payload.format_version, SETTINGS_EXPORT_FORMAT_VERSION);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_malformed_payload_produces_a_generic_error_not_a_raw_serde_message() {
// A `format_version` the probe accepts, but a `settings` field of
// the wrong *type* rather than just a missing field — this is what
// makes `serde_json` produce an "invalid type: string `...`, expected
// struct AppSettings" error that quotes the offending value
// verbatim. That value here stands in for plaintext that, in a real
// export, could be a live credential — the assertion below is only
// meaningful against a fixture that actually exercises serde's
// value-quoting behavior, which a merely-missing-field fixture does
// not.
let dir = temp_dir("malformed");
let path = write_raw_export(
&dir,
"export.triplec",
&serde_json::json!({
"format_version": SETTINGS_EXPORT_FORMAT_VERSION,
"exported_at": "2026-08-27T00:00:00Z",
"app_version": "0.4.14",
"settings": "NOT-A-REAL-CREDENTIAL-abc123",
"secrets": {},
}),
"correct password",
);
let err = read_and_decrypt(&path, "correct password").unwrap_err();
assert!(
!err.contains("NOT-A-REAL-CREDENTIAL-abc123"),
"leaked plaintext into the error: {}",
err
);
assert!(err.contains("doesn't look like a valid settings export"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_wrong_password_is_reported_without_a_version_check_ever_running() {
let dir = temp_dir("wrong-password");
let path = write_export(
&dir,
"export.triplec",
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
"correct password",
);
let err = read_and_decrypt(&path, "wrong password").unwrap_err();
assert!(
err.contains("Wrong password"),
"unexpected message: {}",
err
);
std::fs::remove_dir_all(&dir).ok();
}
}
+42 -19
View File
@@ -197,18 +197,20 @@ pub async fn upload_host_file_to_terminal(
state: State<'_, AppState>,
) -> Result<String, String> {
// The drop target is a host path chosen by the webview, not by the OS drag
// itself, so it gets the same host-read policy as the Files pane's upload:
// absolute, no traversal, and nothing out of a hidden directory
// (`~/.ssh`, `~/.aws`) or a system location — applied to the path with its
// symlinks already resolved, so a visible directory that *leads* to `~/.ssh`
// is refused too. What comes back is that resolved path, and it is what
// gets opened.
// itself, so it goes through `file_commands`' host-read policy: absolute,
// no traversal, and nothing whose path passes through a hidden directory
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
// the path with its symlinks already resolved, so a visible directory that
// *leads* to one of those is refused too. What comes back is that resolved
// path, and it is what gets opened. Four commands touch a host path now,
// but only two take it *over IPC*: this one and `download_container_backup`.
// The Files pane's `download_container_file` and `upload_files_to_container`
// open their dialog from Rust instead, so for them the policy above is
// defence in depth and for these two it is the boundary itself.
// The name is taken from the path the user actually dropped, *before*
// resolution. Deriving it from the resolved path renames the file behind
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
// a symlink, would land it in the container as `2026-08-23.log`. The Files
// pane's upload had the same bug and fixes it the same way — one helper, so
// the two drop targets cannot drift.
// a symlink, would land it in the container as `2026-08-23.log`.
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
@@ -217,8 +219,24 @@ pub async fn upload_host_file_to_terminal(
let meta = tokio::fs::metadata(&host_path)
.await
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
if meta.is_dir() {
return Err(format!("{} is a directory — drop individual files", host_path));
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
// regular file, reports `len() == 0`, and passes both the directory check
// and the size cap below — and `std::fs::File::open` on one blocks forever
// with no writer, with no timeout anywhere on this path. The upload then
// never returns, the toast sticks on "Adding N files…" for the session and
// the rest of the batch is abandoned. Sockets and device nodes are the same
// shape. This is one of two routes for getting a host file into a
// container (the Files pane's upload is the other), so it is the wrong
// place to be clever.
if !meta.is_file() {
return Err(if meta.is_dir() {
format!("{} is a directory — drop individual files", host_path)
} else {
format!(
"{} is not a regular file — only ordinary files can be dropped into a terminal",
host_path
)
});
}
// Guard against ballooning host RAM: the file is packed into an in-memory
@@ -229,7 +247,7 @@ pub async fn upload_host_file_to_terminal(
use crate::docker::exec::MAX_DROP_BYTES;
if meta.len() > MAX_DROP_BYTES {
return Err(format!(
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
meta.len() as f64 / (1024.0 * 1024.0),
MAX_DROP_BYTES / (1024 * 1024)
));
@@ -246,7 +264,13 @@ pub async fn upload_host_file_to_terminal(
.await?;
let file_name = format!("triple-c-drops/{}", base);
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
crate::docker::exec::upload_host_file_to_container(
&container_id,
&host_path,
"/tmp",
&file_name,
)
.await
}
#[tauri::command]
@@ -301,19 +325,18 @@ pub async fn stop_audio_bridge(
#[cfg(test)]
mod tests {
/// Both drop targets must name a dropped file the way the *user* named it.
/// A dropped file must be named the way the *user* named it.
///
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
/// name from the path *after* symlink resolution, so dropping
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
/// `2026-08-23.log` — silently landed the file in the container under the
/// target's name. Nothing errored; the user just got a name they never
/// typed. The Files pane had the identical bug.
/// typed.
///
/// What actually keeps the two from drifting is that they now call one
/// helper, so this asserts that helper's contract from the terminal side:
/// the answer comes from the spelling, and a path that does not name a file
/// is refused rather than silently substituted (it used to fall back to
/// This asserts the shared helper's contract from the terminal side: the
/// answer comes from the spelling, and a path that does not name a file is
/// refused rather than silently substituted (it used to fall back to
/// `"dropped-file"`).
#[test]
fn a_dropped_file_keeps_the_name_the_user_dropped() {
+201 -24
View File
@@ -16,9 +16,37 @@ const REGISTRY_API_BASE: &str =
const GHCR_TOKEN_URL: &str =
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
/// The build-time preview suffix, if one was baked in and isn't blank.
///
/// The bundle version itself (`tauri.conf.json`, `Cargo.toml`, `package.json`)
/// is never given a `-preview.<sha>` suffix — `build-app-preview.yml` strips
/// it before patching those files, because the Windows MSI's `ProductVersion`
/// is a fixed-width numeric field with no room for one, and nothing here can
/// verify a change to that without an actual Windows build. `TRIPLE_C_BUILD_SUFFIX`
/// is the workaround: set as a build-time env var in the preview workflow
/// only, so `option_env!` bakes it into the binary without the bundle version
/// ever seeing it. A production build sets nothing, so `option_env!` reads
/// `None` here — see triple-c#32.
///
/// The single source of truth for "is this a preview build": both
/// `get_app_version()` (what the About panel shows) and `check_for_updates()`
/// (whether a same-numbered release counts as an update — see `pick_update`)
/// read this rather than each calling `option_env!` themselves, so the two
/// can never silently disagree about which build this is.
fn preview_build_suffix() -> Option<&'static str> {
option_env!("TRIPLE_C_BUILD_SUFFIX").filter(|s| !s.is_empty())
}
fn format_app_version(base: &str, build_suffix: Option<&str>) -> String {
match build_suffix {
Some(suffix) if !suffix.is_empty() => format!("{}-{}", base, suffix),
_ => base.to_string(),
}
}
#[tauri::command]
pub fn get_app_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix())
}
#[tauri::command]
@@ -51,30 +79,20 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
&[".AppImage", ".deb", ".rpm"]
};
// Filter releases that have at least one asset matching the current platform
let platform_releases: Vec<&GitHubRelease> = releases
.iter()
.filter(|r| {
r.assets.iter().any(|a| {
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
})
})
.collect();
// `current_version` above is always the bare, stripped `CARGO_PKG_VERSION`
// — the preview workflow patches `Cargo.toml` with that before compiling,
// never the `-preview.<sha>`-suffixed one `get_app_version()` reports —
// so a preview build and the release it precedes compile to the identical
// numeric tuple by construction (see `build-app-preview.yml`'s "highest
// tag used, +1" computation). A strict `>` therefore never fires for the
// one release a preview most needs to be offered. `is_preview_build`
// relaxes that one comparison to `>=` so "there is a real release at my
// own number" reads as an update, without touching the production case
// — see `pick_update`.
let is_preview_build = preview_build_suffix().is_some();
// Find the latest release with a higher semver version
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
for release in &platform_releases {
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
if ver > current_semver {
if best.is_none() || ver > best.unwrap().1 {
best = Some((release, ver));
}
}
}
}
match best {
Some((release, _)) => {
match pick_update(&releases, current_semver, platform_extensions, is_preview_build) {
Some(release) => {
// Only include assets matching the current platform
let assets = release
.assets
@@ -105,6 +123,51 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
}
}
/// Pick the newest available update out of a release list, or `None` if
/// nothing beats `current_semver`. Pure and synchronous — split out of
/// `check_for_updates` so the prerelease/platform/version filtering can be
/// tested without a live HTTP call.
///
/// Three filters, all of which must pass: not a prerelease (see the long
/// comment on `GitHubRelease::prerelease`), at least one asset for this
/// platform, and a tag that parses as semver *and* beats what is running. A
/// tag that does not parse — `preview-<sha>` (the shape
/// `build-app-preview.yml` actually creates release tags with), most
/// realistically — is skipped rather than erroring, the same as it always
/// has been; nothing here changes what an update tag is expected to look
/// like, only what channel it is allowed to come from.
///
/// `is_preview_build` relaxes "beats" from `>` to `>=`. A preview build's
/// `current_semver` is the bare number it was compiled with, which is by
/// construction identical to the release it precedes — see the comment at
/// `check_for_updates`'s call site — so a strict `>` would never fire for
/// exactly the release a preview install most needs to be told about.
fn pick_update<'a>(
releases: &'a [GitHubRelease],
current_semver: (u32, u32, u32),
platform_extensions: &[&str],
is_preview_build: bool,
) -> Option<&'a GitHubRelease> {
releases
.iter()
.filter(|r| !r.prerelease)
.filter(|r| {
r.assets
.iter()
.any(|a| platform_extensions.iter().any(|ext| a.name.ends_with(ext)))
})
.filter_map(|r| parse_semver_from_tag(&r.tag_name).map(|ver| (r, ver)))
.filter(|(_, ver)| {
if is_preview_build {
*ver >= current_semver
} else {
*ver > current_semver
}
})
.max_by_key(|(_, ver)| *ver)
.map(|(r, _)| r)
}
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
let clean = version.trim_start_matches('v');
@@ -131,6 +194,120 @@ fn extract_version_from_tag(tag: &str) -> Option<String> {
Some(format!("{}.{}.{}", major, minor, patch))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::GitHubAsset;
// ── format_app_version ──────────────────────────────────────────────
#[test]
fn a_production_build_reports_the_bare_version() {
assert_eq!(format_app_version("0.4.12", None), "0.4.12");
// An empty env var (set but blank) must not print a trailing dash.
assert_eq!(format_app_version("0.4.12", Some("")), "0.4.12");
}
#[test]
fn a_preview_build_reports_its_suffix() {
assert_eq!(
format_app_version("0.4.12", Some("preview.a1b2c3d")),
"0.4.12-preview.a1b2c3d"
);
}
// ── pick_update ──────────────────────────────────────────────────────
fn release(tag: &str, prerelease: bool, asset_names: &[&str]) -> GitHubRelease {
GitHubRelease {
tag_name: tag.to_string(),
html_url: format!("https://example.invalid/{}", tag),
body: String::new(),
assets: asset_names
.iter()
.map(|name| GitHubAsset {
name: name.to_string(),
browser_download_url: String::new(),
size: 0,
})
.collect(),
published_at: "2026-01-01T00:00:00Z".to_string(),
prerelease,
}
}
const LINUX_EXTENSIONS: &[&str] = &[".AppImage", ".deb", ".rpm"];
#[test]
fn a_prerelease_is_never_offered_even_if_its_tag_would_otherwise_win() {
let releases = vec![release("v9.9.9", true, &["app-9.9.9.AppImage"])];
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
}
#[test]
fn a_release_with_no_asset_for_this_platform_is_skipped() {
let releases = vec![release("v0.4.12", false, &["app-0.4.12.msi"])];
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
}
#[test]
fn a_release_that_is_not_newer_is_not_offered() {
let releases = vec![release("v0.4.10", false, &["app.AppImage"])];
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
}
#[test]
fn an_untagged_or_unparseable_release_is_skipped_not_fatal() {
// A `-preview.<sha>` tag is exactly the shape this must not choke on
// or mistake for an update — it simply never parses as a bare semver.
let releases = vec![
release("preview-a1b2c3d", false, &["app.AppImage"]),
release("v0.4.12", false, &["app.AppImage"]),
];
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
assert_eq!(best.tag_name, "v0.4.12");
}
#[test]
fn the_highest_qualifying_version_wins_not_the_first_or_last_in_the_list() {
let releases = vec![
release("v0.4.11", false, &["app.AppImage"]),
release("v0.4.13", false, &["app.AppImage"]),
release("v0.4.12", false, &["app.AppImage"]),
];
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
assert_eq!(best.tag_name, "v0.4.13");
}
// ── is_preview_build (>= instead of >) ─────────────────────────────────
/// The exact scenario triple-c#32 was filed to fix: a preview compiled as
/// `0.4.12-preview.<sha>` (bare `CARGO_PKG_VERSION` "0.4.12") must be
/// offered the `v0.4.12` release that follows it, even though the two
/// compute to the identical numeric tuple.
#[test]
fn a_preview_build_is_offered_the_release_it_precedes() {
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
let best = pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).unwrap();
assert_eq!(best.tag_name, "v0.4.12");
}
#[test]
fn a_preview_build_is_not_offered_an_older_release() {
let releases = vec![release("v0.4.11", false, &["app.AppImage"])];
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).is_none());
}
#[test]
fn a_production_build_still_requires_strictly_newer() {
// A production build must never treat "equal" as an update — that
// would perpetually re-offer the version already running.
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
}
}
/// Check whether a newer container image is available in the registry.
///
/// Compares the local image digest with the remote registry digest using the
File diff suppressed because it is too large Load Diff
+78 -47
View File
@@ -330,29 +330,58 @@ impl ExecSessionManager {
/// meant a moment earlier.
pub const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024;
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
/// Upload a host file into `dest_dir` under `dest_name`. The file is
/// read and packed into the tar inside a blocking task, so the synchronous IO
/// runs off the async worker. The tar's declared entry size is taken from the
/// bytes actually read (not a separate `stat`), so a file changing size between
/// a size check and the read can't desync the header and corrupt the archive.
/// Returns the in-container path (`/tmp/<dest_name>`).
/// Returns the in-container path (`<dest_dir>/<dest_name>`).
///
/// `dest_dir` must already exist and must already have been checked by the
/// caller — Docker's archive extractor writes wherever it is pointed. The two
/// callers both do that first, by different routes because they are answering
/// different questions: the terminal drop stages into a fixed `/tmp` path it
/// creates itself, and the Files pane passes the directory the user is looking
/// at, which `file_commands::resolve_container_dir` has already confirmed
/// resolves inside `CONTAINER_WRITE_ROOTS`.
pub async fn upload_host_file_to_container(
container_id: &str,
host_path: &str,
dest_dir: &str,
dest_name: &str,
) -> Result<String, String> {
let ids = container_user_ids(container_id).await;
upload_host_file_with_ids(container_id, host_path, dest_dir, dest_name, ids).await
}
/// [`upload_host_file_to_container`] for a caller that already knows the
/// container user's ids.
///
/// `container_user_ids` is a `docker exec`, and the Files pane's upload is a
/// *selection* — one dialog can hand back twenty files. Resolving the ids per
/// file made twenty extra round trips to answer the same `id -u` twenty times,
/// which is seconds of latency for a fact that cannot change inside one
/// container's lifetime. So the loop resolves once and passes the answer in.
/// The wrapper above keeps the single-file callers unchanged.
pub async fn upload_host_file_with_ids(
container_id: &str,
host_path: &str,
dest_dir: &str,
dest_name: &str,
(uid, gid): (u64, u64),
) -> Result<String, String> {
let host_path = host_path.to_string();
let dest_name = dest_name.to_string();
let dest_for_blk = dest_name.clone();
let (uid, gid) = container_user_ids(container_id).await;
let mtime = now_epoch_secs();
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
// The caller resolved this path (`resolve_host_read_path`); opening it
// is a second trip through the same directories, so the descriptor is
// checked against the path that was validated before its bytes are
// packed into anything. Same policy as the Files pane's upload — this
// is the terminal's drop target, and the two must not differ.
// packed into anything. Two paths reach here: the terminal's drop
// target, and the Files pane's upload via `upload_host_file_with_ids`.
// Between them they are how host bytes enter a container.
let file = std::fs::File::open(&host_path)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
crate::commands::file_commands::verify_opened_path(
@@ -381,7 +410,7 @@ pub async fn upload_host_file_to_container(
.upload_to_container(
container_id,
Some(UploadToContainerOptions {
path: "/tmp".to_string(),
path: dest_dir.to_string(),
..Default::default()
}),
tar_buf.into(),
@@ -389,7 +418,17 @@ pub async fn upload_host_file_to_container(
.await
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
Ok(format!("/tmp/{}", dest_name))
Ok(container_join(dest_dir, &dest_name))
}
/// Join a container directory to a name that may itself carry separators.
///
/// Only the *reported* path — the bytes have already landed by the time this is
/// called — but that path is what the terminal echoes and what the Files pane
/// puts in its toast, so `/tmp//x` reading back as a different file than `/tmp/x`
/// is worth the four lines. `"/"` trims to `""` and yields `/x`.
fn container_join(dir: &str, name: &str) -> String {
format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/'))
}
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
@@ -601,44 +640,6 @@ pub async fn exec_oneshot_as(
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
}
/// [`exec_oneshot_as`] with a wall-clock ceiling on the whole call.
///
/// H8. Nothing in this module bounds how long a container command may take,
/// which is right for the callers that need it — a base-image migration replays
/// `apt-get` and takes minutes — and wrong for a short command that can be made
/// to block forever by a *file* the caller does not control. The upload
/// reservation is the one that bit: a shell redirect onto a FIFO blocks in
/// `open(2)` until a reader appears, so a single `mkfifo` in a project
/// directory left the Files pane on "Uploading…" for the rest of the session
/// with the rest of the batch abandoned.
///
/// So the ceiling is opt-in per call site rather than global. Note what it can
/// and cannot do: dropping the future closes our end of the stream, but Docker
/// has no "kill an exec" API, so a process that is genuinely wedged stays
/// wedged in the container's process table. That is why the primitive matters
/// more than the timeout — this turns "the app never comes back" into "that
/// upload failed", and it is the caller's job not to run something that blocks.
pub async fn exec_oneshot_as_within(
container_id: &str,
user: &str,
cmd: Vec<String>,
env: Vec<String>,
limit: std::time::Duration,
) -> Result<(String, i64), String> {
match tokio::time::timeout(
limit,
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT),
)
.await
{
Ok(result) => result,
Err(_) => Err(format!(
"The container did not answer within {}s — the command may still be running inside it.",
limit.as_secs()
)),
}
}
/// What a one-shot exec printed, with the two streams still tellable apart.
///
/// `combined` is stdout and stderr interleaved in arrival order — the shape
@@ -817,8 +818,17 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
match docker.inspect_exec(exec_id).await {
Ok(info) => {
if info.running != Some(true) {
// Finished: use the reported code (default 0 if somehow absent).
return Some(info.exit_code.unwrap_or(0));
// Finished. `exit_code` rather than `unwrap_or(0)`: an exec
// that has stopped without a reported code is a status
// nobody can vouch for, and flattening it to *success* is
// the wrong default when a caller is deciding whether to
// rename a downloaded file over the user's own.
// `download_container_file` treats `None` as a failure
// precisely because it cannot tell that silence from a
// clean exit; an `unwrap_or` here made that check
// unreachable. Callers that only care about "did it fail
// loudly" use `is_some_and`, which reads `None` as before.
return info.exit_code;
}
}
Err(_) => return None,
@@ -930,4 +940,25 @@ mod tests {
// …but still comfortably above a genuine /proc/net/tcp{,6} pair.
assert!(PROC_NET_OUTPUT_LIMIT > 100 * 150);
}
/// The reported path, which is what the terminal echoes back to Claude and
/// what the Files pane puts in its log line. `/tmp//x` and `/tmp/x` are the
/// same file to the kernel and different strings to a person reading either
/// of those.
#[test]
fn container_join_produces_one_separator() {
assert_eq!(container_join("/tmp", "a.txt"), "/tmp/a.txt");
// The terminal's drop passes a nested name; it must not gain a second
// slash at the seam.
assert_eq!(
container_join("/tmp", "triple-c-drops/a.txt"),
"/tmp/triple-c-drops/a.txt"
);
// A directory the user navigated to can carry a trailing slash, and the
// container root is the case where trimming it must not eat the only
// separator there is.
assert_eq!(container_join("/workspace/", "a.txt"), "/workspace/a.txt");
assert_eq!(container_join("/", "a.txt"), "/a.txt");
assert_eq!(container_join("/", "/a.txt"), "/a.txt");
}
}
+33
View File
@@ -369,6 +369,15 @@ pub fn set_delta(from: &BTreeSet<String>, base: &BTreeSet<String>) -> Vec<String
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
let mut out: Vec<String> = paths
.iter()
// **The same filter `project_path_mounts` applies, and it has to be.**
// That function skips a row with an empty `host_path` or `mount_name`
// so a legacy row cannot brick the create. The consequence is that
// `/workspace/<name>` for such a row is *not* a bind mount — it is
// ordinary writable-layer content. Excluding it here would tell
// `compute_verbatim_paths` to skip staging it, and the container swap
// would then destroy whatever the user has put there. The two
// predicates must agree or a migration silently eats a directory.
.filter(|p| !p.mount_name.trim().is_empty() && !p.host_path.trim().is_empty())
.map(|p| format!("/workspace/{}", p.mount_name))
.collect();
out.sort();
@@ -1368,6 +1377,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
#[cfg(test)]
mod tests {
/// The mount filter and the migration's exclusion list must agree.
///
/// `project_path_mounts` skips a row with an empty `host_path` so a legacy
/// row cannot brick the create. That makes `/workspace/<name>` ordinary
/// writable-layer content rather than a bind mount — and if this function
/// still excluded it, `compute_verbatim_paths` would skip staging it and
/// the container swap would destroy whatever is there. A migration eating a
/// directory is the quietest kind of data loss there is.
#[test]
fn an_unmountable_row_is_not_excluded_from_the_migration_payload() {
let paths = vec![
ProjectPath { host_path: "/home/u/code".into(), mount_name: "code".into() },
// Legacy shapes that `project_path_mounts` skips.
ProjectPath { host_path: "".into(), mount_name: "data".into() },
ProjectPath { host_path: "/home/u/x".into(), mount_name: " ".into() },
];
let excluded = bind_mount_exclusions(&paths);
assert_eq!(
excluded,
vec!["/workspace/code".to_string()],
"only rows that are actually mounted may be excluded from staging"
);
}
use super::*;
use crate::models::{
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
+209 -3
View File
@@ -29,6 +29,21 @@ pub struct AppState {
pub auth_bridge: Arc<AuthBridgeManager>,
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
pub lifecycle: Arc<Lifecycle>,
/// The file `preview_settings_import` last decrypted successfully, held
/// so `apply_settings_import` can re-read and re-decrypt the same file
/// without the frontend ever passing a host path back to Rust as an
/// argument — see the doc comment on `commands::settings_export_commands`
/// for why that direction specifically is the one this app treats as
/// dangerous. Deliberately re-decrypted rather than cached in plaintext:
/// nothing here holds a decrypted secret in memory for longer than one
/// command's execution.
///
/// Also pins a hash of the file's ciphertext at preview time, so
/// `apply_settings_import` can refuse to proceed if the file on disk
/// changed underneath the pending import — otherwise confirming a
/// preview is not actually binding on what gets applied.
pub pending_settings_import:
Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>,
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -222,6 +237,7 @@ pub fn run() {
auth_bridge,
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
lifecycle,
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
})
.setup(move |app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
@@ -250,6 +266,7 @@ pub fn run() {
// an image open and the sweep will not force; pins are untagged
// second so the images they were holding are dangling by the time
// the sweep lists them; the sweep runs last and collects both.
let projects_store_for_cleanup = projects_store_setup.clone();
tauri::async_runtime::spawn(async move {
crate::docker::reap_probe_containers().await;
let reaped = crate::docker::reap_stale_migration_pins().await;
@@ -257,6 +274,15 @@ pub fn run() {
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
}
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
// A container/image/volume `remove_project` could not delete
// is recorded rather than lost — see triple-c#31 — and this is
// the only place anything ever retries it. Takes the store so
// it can refuse to touch a project that turns out to still be
// live — see the long comment on the function itself.
crate::commands::project_commands::retry_pending_cleanup_logged(
&projects_store_for_cleanup,
)
.await;
});
// Auto-start web terminal server if enabled in settings
@@ -435,7 +461,6 @@ pub fn run() {
commands::docker_commands::check_image_exists,
commands::docker_commands::build_image,
commands::docker_commands::get_container_info,
commands::docker_commands::list_sibling_containers,
// Projects
commands::project_commands::list_projects,
commands::project_commands::add_project,
@@ -485,6 +510,10 @@ pub fn run() {
commands::settings_commands::inspect_ca_cert_path,
commands::settings_commands::list_aws_profiles,
commands::settings_commands::detect_host_timezone,
// Settings export/import
commands::settings_export_commands::export_settings,
commands::settings_export_commands::preview_settings_import,
commands::settings_export_commands::apply_settings_import,
// Terminal
commands::terminal_commands::open_terminal_session,
commands::terminal_commands::terminal_input,
@@ -497,9 +526,9 @@ pub fn run() {
commands::terminal_commands::stop_audio_bridge,
// Files
commands::file_commands::list_container_files,
commands::file_commands::download_container_file,
commands::file_commands::download_container_backup,
commands::file_commands::upload_file_to_container,
commands::file_commands::download_container_file,
commands::file_commands::upload_files_to_container,
commands::file_commands::read_container_file,
commands::file_commands::rename_container_path,
commands::file_commands::create_container_directory,
@@ -689,6 +718,183 @@ mod tests {
/// pulls in `core:image:default` → `allow-from-path`, which is an
/// unconditional `std::fs::read` of any host path with no scope check, and
/// nothing in the frontend has ever imported `@tauri-apps/api/image`.
/// Every `#[tauri::command]` is registered, and every registration names a
/// command that exists.
///
/// This is the shape of the bug that caused the original OAuth-callback
/// complaint: `set_auth_bridge_enabled` existed, worked, and had a typed
/// frontend wrapper — with **zero call sites**. The switch the docs told
/// users to flip was never wired to anything, so the bridge stayed off and
/// every login callback was refused. Nothing failed; the feature was simply
/// absent, and no test noticed because both halves compiled.
///
/// The reverse direction matters too, and for a sharper reason: a command
/// that is registered but reachable from nowhere is still IPC surface a
/// compromised webview can call. `list_sibling_containers` — which returned
/// every container on the daemon, including the user's unrelated work —
/// sat in exactly that state, and this test is what found it. It has since
/// been removed at all four levels: registration, command, docker helper,
/// and the frontend wrapper and type.
///
/// So this asserts the two lists agree, and leaves *deciding* what belongs
/// on them to a human. It cannot see frontend call sites; `tsc` and the
/// vitest suite cover that side.
#[test]
fn every_command_is_registered_exactly_once() {
use std::collections::BTreeSet;
let mut defined: BTreeSet<String> = BTreeSet::new();
// Walk the source tree for the command attribute and take the `fn` name
// that follows.
//
// The first version of this matched `line.trim() == "#[tauri::command]"`
// exactly and broke on the first non-`#` line. An audit got five real,
// compiling, unregistered commands past it — `#[tauri::command(async)]`,
// `#[tauri::command(rename_all = "snake_case")]`, a trailing comment,
// spaces in the path, and a bare `#[command]` after `use tauri::command`
// — plus `pub(crate) fn` and a `///` line between attribute and `fn`.
// Every one of those is a command the frontend could not call, which is
// the bug this test exists for, and the test stayed green.
//
// The asymmetry matters: confusion on the *definition* side is a silent
// pass, while on the *registration* side it fails loudly against
// legitimate code — and rustc already covers that direction. So this
// errs toward over-matching definitions.
fn collect(dir: &std::path::Path, out: &mut BTreeSet<String>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
let Ok(text) = std::fs::read_to_string(&path) else { continue };
let lines: Vec<&str> = text.lines().collect();
for (i, line) in lines.iter().enumerate() {
let t = line.trim();
// `#[tauri::command]`, `#[tauri::command(async)]`,
// `#[tauri :: command]`, a bare `#[command]` under
// `use tauri::command`, and any of those with a
// trailing comment.
let attr = t.strip_prefix("#[").map(|a| {
a.split(']').next().unwrap_or("").replace(' ', "")
});
let is_command_attr = attr.is_some_and(|a| {
a == "command" || a == "tauri::command"
|| a.starts_with("command(")
|| a.starts_with("tauri::command(")
});
if !is_command_attr {
continue;
}
// Skip further attributes and doc comments rather than
// giving up at the first line that is not an attribute.
for next in lines.iter().skip(i + 1) {
let t = next.trim();
if t.starts_with('#') || t.starts_with("//") || t.is_empty() {
continue;
}
// Any visibility, then `fn` or `async fn`.
let after_vis = t
.strip_prefix("pub(crate) ")
.or_else(|| t.strip_prefix("pub(super) "))
.or_else(|| t.strip_prefix("pub(in crate) "))
.or_else(|| t.strip_prefix("pub "))
.unwrap_or(t);
let after_async =
after_vis.strip_prefix("async ").unwrap_or(after_vis);
if let Some(rest) = after_async.strip_prefix("fn ") {
if let Some(name) = rest.split(['(', '<']).next() {
out.insert(name.trim().to_string());
}
}
break;
}
}
}
}
}
collect(
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
&mut defined,
);
// The registration list, read from this file rather than from a macro
// expansion so the test does not depend on `generate_handler!`'s shape.
let this = include_str!("lib.rs");
let handler = this
.split_once("generate_handler![")
.and_then(|(_, rest)| rest.split_once("])"))
.map(|(inside, _)| inside)
.expect("lib.rs should contain a generate_handler! list");
// Line-based, not `split(',')`: the list is grouped under `// Docker`
// style comments, and splitting on commas glues each comment to the
// command that follows it. A `starts_with("//")` filter then drops that
// command — silently, and once per group.
let registered: BTreeSet<String> = handler
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//"))
.filter_map(|l| {
l.trim_end_matches(',')
.rsplit("::")
.next()
.map(|n| n.trim().to_string())
})
.filter(|n| !n.is_empty())
.collect();
assert!(
!defined.is_empty() && !registered.is_empty(),
"the scan found nothing — it has stopped testing anything (defined={}, registered={})",
defined.len(),
registered.len()
);
let unregistered: Vec<&String> = defined.difference(&registered).collect();
assert!(
unregistered.is_empty(),
"these commands exist but are not registered, so the frontend cannot call them: {:?}",
unregistered
);
let undefined: Vec<&String> = registered.difference(&defined).collect();
assert!(
undefined.is_empty(),
"these are registered but no `#[tauri::command]` defines them: {:?}",
undefined
);
// "exactly once" was in this test's name and not in its body: both
// sides were sets, so registering the same command twice in a
// hand-maintained 118-line list compiled, warned about nothing, and
// passed here.
let mut seen: Vec<&str> = Vec::new();
let mut duplicated: Vec<&str> = Vec::new();
for line in handler
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("//"))
{
if let Some(name) = line.trim_end_matches(',').rsplit("::").next() {
let name = name.trim();
if name.is_empty() {
continue;
}
if seen.contains(&name) {
duplicated.push(name);
} else {
seen.push(name);
}
}
}
assert!(
duplicated.is_empty(),
"these are registered more than once: {:?}",
duplicated
);
}
#[test]
fn the_capability_grants_are_the_ones_that_were_reviewed() {
let raw = include_str!("../capabilities/default.json");
+48
View File
@@ -1,6 +1,54 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
/// under Wayland, printing `Could not create default EGL display:
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
/// code and killing the webview before Triple-C's own logging even starts —
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
///
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
/// variable is exported into an XWayland client's environment too, so a
/// gate on it wouldn't even cleanly separate "Wayland" from "X11" — and
/// there is no reliable heuristic at all for the actual variable that
/// matters, which Mesa/driver/compositor combination is affected. This is
/// the blunt instrument, chosen deliberately because the fallback is a real
/// trade, not a free one: the terminal's `@xterm/addon-webgl` renderer
/// (`TerminalView.tsx`) is the one surface in this app actually asking for
/// GPU compositing, and it degrades to xterm's canvas renderer under this
/// setting — slower on very heavy output, but the addon's own construction
/// is already wrapped in a fallback (`WebGL not available` is a handled
/// case, not a crash), so this is a real but graceful downgrade, traded
/// against a startup abort that has no fallback at all.
///
/// Must be set before `triple_c_lib::run()` — GTK/WebKitGTK reads it at
/// their own init time, which happens inside the Tauri builder that
/// function calls into, not at binary load.
///
/// A user who has already set this themselves is left alone. That includes
/// setting it to `0`, on the assumption WebKitGTK treats it as a boolean
/// rather than presence-only — not verified against WebKitGTK's own source,
/// so if it turns out to be presence-only, `=0` still reads as "set" here
/// and disables DMA-BUF the same as any other value, which is at least the
/// safe direction to be wrong in.
///
/// This env var also leaks to whatever the app spawns afterwards — notably
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
/// call. Narrow in practice (an already-running browser just receives the
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
/// worth knowing before chasing the "links don't open" half of triple-c#34
/// as a separate, unrelated cause.
#[cfg(target_os = "linux")]
fn apply_webkit_wayland_workaround() {
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}
fn main() {
#[cfg(target_os = "linux")]
apply_webkit_wayland_workaround();
triple_c_lib::run()
}
+2
View File
@@ -3,6 +3,7 @@ pub mod container_config;
pub mod app_settings;
pub mod gateway_settings;
pub mod migration;
pub mod settings_export;
pub mod update_info;
pub use project::*;
@@ -10,4 +11,5 @@ pub use container_config::*;
pub use app_settings::*;
pub use gateway_settings::*;
pub use migration::*;
pub use settings_export::*;
pub use update_info::*;
+427 -8
View File
@@ -8,6 +8,100 @@ pub struct EnvVar {
pub value: String,
}
/// Whether `key` is a name a shell will read back as an ordinary variable:
/// `[A-Za-z_][A-Za-z0-9_]*`.
///
/// ## Why a charset rule, and not just the reserved-name list
///
/// `docker::container::is_reserved_env_key` answers a different question — "is
/// this one of the names Triple-C manages itself" — and nothing anywhere asked
/// what the *characters* were. A key is joined into `KEY=VALUE` and handed to
/// the daemon, which puts it in the container's environment verbatim, so a name
/// that is not an identifier travels through unchallenged.
///
/// The one that matters is `BASH_FUNC_name%%`, bash's wire format for an
/// exported shell function: bash imports those at startup and the *body* is the
/// value. Today that is latent rather than live — the image's `/bin/sh` is
/// dash, which does not import them, and an auditor confirmed the vector fires
/// under `bash -c` and not under `sh -c` in the shipped image. But the
/// pre-commit scrub runs `/bin/sh -c` **as root**, `/bin/sh` is whatever
/// `ubuntu:24.04` points it at, and nothing pins that. One base-image change,
/// or one call site spelled `bash`, turns a stored project setting into root
/// code execution inside the container at commit time.
///
/// So the rule is the shape of the thing rather than a list of the names that
/// are known to be dangerous: `IFS`, `LD_PRELOAD` and `PATH` are all perfectly
/// good identifiers and are the user's business, while nothing legitimate needs
/// a `%`, a `(` or a space in an environment variable name.
///
/// The key is judged **trimmed**, because that is what `create_container` sends
/// — ` FOO ` already reaches the container as `FOO`, and refusing it here would
/// break a setting that works.
pub fn is_valid_env_key(key: &str) -> bool {
let mut chars = key.trim().chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
/// Validate a custom environment variable list that is about to be stored,
/// admitting the entries it is already stored with.
///
/// Same shape, and the same reasoning, as
/// `commands::project_commands::validate_project_paths_update`: nothing ever
/// checked these keys, so `projects.json` and `settings.json` in the field can
/// hold whatever was typed. Holding every save to the new rule would make such
/// a project unsavable *entirely* — `update_project` is the single command
/// behind the whole Config tab — and would buy nothing, because the stored key
/// is already being handed to every container that starts. An entry carried
/// over verbatim is admitted; a new or edited one is held to the rule, which is
/// what keeps the escalation closed, since escalation means *introducing* a bad
/// key through this command.
///
/// Counted rather than set-tested, for the same reason as the folder rows: a
/// second copy of an existing entry is a new entry.
///
/// The blank entry is not a violation. "+ Add variable" appends
/// `{key: "", value: ""}` and saves the list immediately, so refusing it would
/// turn the button itself into an error toast; `create_container` skips an
/// empty key, so it reaches nothing.
pub fn validate_env_vars_update(stored: &[EnvVar], incoming: &[EnvVar]) -> Result<(), String> {
// An entry with no key is the placeholder, whatever is in its value:
// `create_container` skips it, so it reaches nothing and there is nothing
// to refuse. The editor saves on every blur, and typing the value before
// the name is an ordinary way to fill a row in.
let is_blank = |v: &EnvVar| v.key.trim().is_empty();
let mut carried: std::collections::HashMap<(&str, &str), usize> =
std::collections::HashMap::new();
for v in stored.iter().filter(|v| !is_blank(v)) {
*carried
.entry((v.key.as_str(), v.value.as_str()))
.or_insert(0) += 1;
}
for v in incoming.iter().filter(|v| !is_blank(v)) {
match carried.get_mut(&(v.key.as_str(), v.value.as_str())) {
Some(remaining) if *remaining > 0 => {
*remaining -= 1;
}
_ => {
if !is_valid_env_key(&v.key) {
return Err(format!(
"'{}' is not a usable environment variable name. Use a letter or \
underscore followed by letters, digits or underscores.",
v.key
));
}
}
}
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectPath {
pub host_path: String,
@@ -85,6 +179,7 @@ impl PermissionMode {
/// Settings for Claude Code CLI behavior inside the container.
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(from = "StoredClaudeCodeSettings")]
/// Every field is three-state, and the third state is load-bearing.
///
/// `None` means "not set at this level". For a *project* that is "inherit
@@ -98,24 +193,24 @@ pub struct ClaudeCodeSettings {
/// what lets Claude Code pick the renderer itself; `Some("default")` pins
/// the classic main-screen renderer and `Some("fullscreen")` the alt-screen
/// one. All three are distinct — "let it choose" is not "classic".
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tui_mode: Option<String>,
/// Saved `/effort` level: `None` = unset, otherwise one of
/// `"low" | "medium" | "high" | "xhigh"`. Written to settings.json as
/// `effortLevel` (**not** `effort`, which Claude Code has never read).
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<String>,
/// Disable auto-scroll in fullscreen TUI mode. Held in the *disabled* sense
/// because Claude Code's `autoScrollEnabled` defaults to `true`, so the
/// zero value of this field has to mean "leave it on".
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_scroll_disabled: Option<bool>,
/// Collapse tool output to one-line summaries. Written to settings.json as
/// `viewMode: "focus"`; there is no `focusMode` key in Claude Code.
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub focus_mode: Option<bool>,
/// Show thinking summaries in responses
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub show_thinking_summaries: Option<bool>,
/// Turn the session recap **off**.
///
@@ -128,16 +223,99 @@ pub struct ClaudeCodeSettings {
/// never touched the control holds — as "the user turned the recap off" and
/// silently disabled it for all of them. A new name lets the old key be
/// ignored, which lands every existing project on the correct default.
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_recap_disabled: Option<bool>,
/// Strip credentials from subprocess environments
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_scrub: Option<bool>,
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
#[serde(default)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_caching_1h: Option<bool>,
}
/// `ClaudeCodeSettings` in every shape `projects.json` and `settings.json` can
/// be holding, which is what [`ClaudeCodeSettings`] is actually deserialised
/// through.
///
/// ## The upgrade this exists to survive
///
/// Before the widening, the five booleans were plain `bool`s with
/// `#[serde(default)]` and no `skip_serializing_if`, so **every** settings
/// object ever written carries an explicit `"env_scrub": false` — not because
/// anyone chose it, but because that is what a `bool` serialises to. Under the
/// old merge (`if p.x { true } else { g.x }`) that `false` carried no
/// information at all: it was the only value an unset switch could produce, and
/// the global always won.
///
/// Read as `Some(false)` by the new code it becomes a *deliberate off* that
/// beats a global `Some(true)` — so upgrading silently turned five settings off
/// for every project that had ever opened this editor, `env_scrub` ("strip
/// credentials from subprocess environments") among them. There is no store
/// migration anywhere: `projects_store` parses these structs directly.
///
/// ## How an old record is told apart from a new one
///
/// By `enable_session_recap`. It was in the struct from the day it existed and
/// was a plain `bool`, so its key is present in every pre-widening record and
/// in no other — the field was *renamed* to `session_recap_disabled` precisely
/// so the old key could be ignored (see the doc on that field), and the new
/// code has never written it. Its presence is therefore an exact statement that
/// these bytes were written by a binary in which `false` meant "unset", and the
/// booleans are read back that way: `true` is a real choice and survives,
/// `false` becomes `None` and inherits again.
///
/// Nothing marks a *new* record, and nothing needs to: absent is `None` (the
/// fields skip serialising when unset) and a present `false` is the deliberate
/// off the widening was for. That is also what keeps a downgrade survivable —
/// an older binary reads an absent key as `false` through its own
/// `#[serde(default)]`, where a `null` would fail to parse and take the whole
/// of `projects.json` down with it, since `ProjectsStore` parses all-or-nothing
/// and starts empty on an error.
#[derive(Deserialize)]
struct StoredClaudeCodeSettings {
#[serde(default)]
tui_mode: Option<String>,
#[serde(default)]
effort: Option<String>,
#[serde(default)]
auto_scroll_disabled: Option<bool>,
#[serde(default)]
focus_mode: Option<bool>,
#[serde(default)]
show_thinking_summaries: Option<bool>,
#[serde(default)]
session_recap_disabled: Option<bool>,
#[serde(default)]
env_scrub: Option<bool>,
#[serde(default)]
prompt_caching_1h: Option<bool>,
/// The pre-widening spelling of `session_recap_disabled`, and the *only*
/// use of its value: presence dates the record. Its meaning was inverted
/// and it never worked, so it is read for the marker and discarded.
#[serde(default)]
enable_session_recap: Option<bool>,
}
impl From<StoredClaudeCodeSettings> for ClaudeCodeSettings {
fn from(stored: StoredClaudeCodeSettings) -> Self {
let pre_widening = stored.enable_session_recap.is_some();
// On a pre-widening record `false` is what an untouched switch wrote,
// so it means "not set at this level" and must inherit. A `true` was a
// real choice either way.
let read = |v: Option<bool>| if pre_widening { v.filter(|on| *on) } else { v };
ClaudeCodeSettings {
tui_mode: stored.tui_mode,
effort: stored.effort,
auto_scroll_disabled: read(stored.auto_scroll_disabled),
focus_mode: read(stored.focus_mode),
show_thinking_summaries: read(stored.show_thinking_summaries),
session_recap_disabled: read(stored.session_recap_disabled),
env_scrub: read(stored.env_scrub),
prompt_caching_1h: read(stored.prompt_caching_1h),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
@@ -244,6 +422,61 @@ pub enum ProjectStatus {
Error,
}
/// What `remove_project` could not delete, named so the UI can say so instead
/// of reporting a clean removal that was not one.
///
/// The project record is dropped from `projects.json` regardless — see the
/// long comment on `remove_project` for why refusing is not the answer — but
/// anything named here is also written to a pending-cleanup record that
/// startup housekeeping retries, so it stays reachable after the project it
/// belonged to no longer exists.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct ProjectRemovalReport {
/// The project's container, if it could not be removed. Named by its
/// deterministic `triple-c-{id}` name (see `Project::container_name`),
/// not the container id, since the id can be stale or absent and the
/// name is what a later retry can still resolve.
pub container: Option<String>,
/// The `triple-c-snapshot-{id}` image, if it could not be removed.
pub image: Option<String>,
/// Named volumes (home, claude config) that could not be removed.
pub volumes: Vec<String>,
/// True once the leftovers above were durably recorded for automatic
/// retry on the next launch. False means the pending-cleanup record
/// itself could not be written — nothing will retry these, and the UI
/// must say so rather than promising a retry that will not happen.
/// Meaningless (and left at its default) when `is_clean()` is true.
pub retry_scheduled: bool,
}
impl ProjectRemovalReport {
/// True when nothing was left behind.
pub fn is_clean(&self) -> bool {
self.container.is_none() && self.image.is_none() && self.volumes.is_empty()
}
}
/// What `rebuild_project_container` (Reset) produced: the project as it
/// stands after restarting, and anything Reset could not clear.
///
/// Reset's contract is "back to a clean base image", so a leftover volume or
/// image here is reused/rebuilt-from as-is by the container this creates —
/// the opposite of what was asked for — and unlike [`ProjectRemovalReport`]
/// there is no pending-cleanup record for either: the project id survives
/// Reset, so a later Reset attempt can retry them itself.
#[derive(Debug, Clone, Serialize)]
pub struct ProjectResetOutcome {
pub project: Project,
/// The `triple-c-snapshot-{id}` image, if Reset could not remove it. The
/// more serious of the two leftovers here: the new container is created
/// from this image whenever it exists, so a surviving image means Reset
/// silently rebuilt the exact system layer it was asked to discard.
pub leftover_image: Option<String>,
/// Volumes that survived Reset and were mounted into the new container
/// unchanged.
pub leftover_volumes: Vec<String>,
}
/// Which AI model backend/provider the project uses.
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
@@ -467,3 +700,189 @@ impl Project {
val
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── ProjectRemovalReport ────────────────────────────────────────────────
#[test]
fn a_report_is_clean_only_with_nothing_left_behind() {
assert!(ProjectRemovalReport::default().is_clean());
let mut r = ProjectRemovalReport::default();
r.container = Some("abc123".to_string());
assert!(!r.is_clean(), "a leftover container must not read as clean");
let mut r = ProjectRemovalReport::default();
r.image = Some("triple-c-snapshot-x:latest".to_string());
assert!(!r.is_clean(), "a leftover image must not read as clean");
let mut r = ProjectRemovalReport::default();
r.volumes.push("triple-c-home-x".to_string());
assert!(!r.is_clean(), "a leftover volume must not read as clean");
}
// ── Custom environment variable names ─────────────────────────────────
#[test]
fn an_env_var_name_has_to_be_a_shell_identifier() {
for ok in ["PATH", "_", "_x", "MY_VAR2", "a", " SPACED_BY_THE_EDITOR "] {
assert!(is_valid_env_key(ok), "'{}' should be a usable name", ok);
}
for bad in [
// bash's wire format for an exported shell function: the value is
// the body, and a `bash` that imports it runs it. The scrub exec is
// `/bin/sh -c` as root, and nothing pins `/bin/sh` to dash.
"BASH_FUNC_stat%%",
"BASH_FUNC_ls()",
"MY VAR",
"2FAST",
"WITH-DASH",
"WITH.DOT",
"",
" ",
"$(id)",
"A=B",
] {
assert!(!is_valid_env_key(bad), "'{}' should be refused", bad);
}
}
fn env(key: &str, value: &str) -> EnvVar {
EnvVar { key: key.to_string(), value: value.to_string() }
}
#[test]
fn a_bad_env_var_name_cannot_be_introduced_but_a_stored_one_does_not_brick_the_editor() {
let bad = [env("BASH_FUNC_stat%%", "() { id; }")];
// Introducing it through the Config tab is the escalation.
assert!(validate_env_vars_update(&[], &bad).is_err());
// Already stored: it is handed to every container that starts whether
// or not an unrelated save is allowed through, and refusing the save
// would make every toggle on the Config tab fail.
assert!(validate_env_vars_update(&bad, &bad).is_ok());
// Editing its value is a new entry, and refused again.
assert!(
validate_env_vars_update(&bad, &[env("BASH_FUNC_stat%%", "() { rm -rf /; }")]).is_err()
);
// Fixing the name is what the message asks for, and it saves.
assert!(validate_env_vars_update(&bad, &[env("STAT", "() { id; }")]).is_ok());
// Dropping it entirely is always fine.
assert!(validate_env_vars_update(&bad, &[]).is_ok());
}
#[test]
fn the_blank_row_the_add_button_saves_is_not_an_error() {
// "+ Add variable" appends an empty entry and saves the list at once,
// so this is the button, not an attempt at anything.
assert!(validate_env_vars_update(&[], &[env("", "")]).is_ok());
// Typing the value before the name is an ordinary way to fill it in,
// and an entry with no name reaches no container either way.
assert!(validate_env_vars_update(&[], &[env("", "value-first")]).is_ok());
assert!(validate_env_vars_update(&[], &[env("GOOD", "v"), env("", "")]).is_ok());
}
#[test]
fn a_stored_entry_may_be_kept_but_not_multiplied() {
let stored = [env("BAD NAME", "v")];
assert!(validate_env_vars_update(&stored, &stored).is_ok());
// A second copy is a new entry, and held to the rule.
assert!(
validate_env_vars_update(&stored, &[env("BAD NAME", "v"), env("BAD NAME", "v")])
.is_err()
);
}
// ── Claude Code settings written before the fields were widened ───────
/// `projects.json` exactly as the shipped `main` binary wrote it: the five
/// booleans were plain `bool`s that always serialised, so every project
/// that ever opened the editor carries `false` for the ones it never
/// touched.
const MAIN_SHAPE_PROJECT: &str = r#"{
"id": "p1",
"name": "demo",
"paths": [{ "host_path": "/home/u/demo", "mount_name": "demo" }],
"container_id": null,
"status": "stopped",
"backend": "anthropic",
"bedrock_config": null,
"ollama_config": null,
"openai_compatible_config": null,
"allow_docker_access": false,
"ssh_key_path": null,
"git_user_name": null,
"git_user_email": null,
"claude_code_settings": {
"tui_mode": "fullscreen",
"effort": null,
"auto_scroll_disabled": false,
"focus_mode": false,
"show_thinking_summaries": false,
"enable_session_recap": false,
"env_scrub": false,
"prompt_caching_1h": false
},
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z"
}"#;
#[test]
fn a_setting_stored_as_false_by_the_old_binary_still_inherits_the_global() {
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
let stored = project.claude_code_settings.expect("settings should parse");
// Read verbatim these would be `Some(false)`, which under
// `docker::container::merge_claude_code_settings` beats the global.
assert_eq!(stored.env_scrub, None);
assert_eq!(stored.auto_scroll_disabled, None);
assert_eq!(stored.focus_mode, None);
assert_eq!(stored.show_thinking_summaries, None);
assert_eq!(stored.prompt_caching_1h, None);
assert_eq!(stored.session_recap_disabled, None);
// A value the user did choose is untouched.
assert_eq!(stored.tui_mode.as_deref(), Some("fullscreen"));
// The merge rule itself, spelled the way
// `merge_claude_code_settings` spells it. `main` resolved this with
// `if p.env_scrub { true } else { g.env_scrub }`, i.e. the global won —
// and it has to go on winning, because the user never turned this off.
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
assert_eq!(
stored.env_scrub.or(global.env_scrub),
Some(true),
"upgrading silently turned off 'strip credentials from subprocess environments'"
);
}
#[test]
fn an_off_chosen_in_the_new_editor_still_beats_a_global_on() {
// Same record without the pre-widening key: this `false` is the
// deliberate off the widening exists to make expressible.
let json = r#"{ "env_scrub": false }"#;
let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap();
assert_eq!(chosen.env_scrub, Some(false));
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false));
}
#[test]
fn an_unset_setting_is_written_as_absent_rather_than_null() {
// A downgrade parses these fields as plain `bool` with
// `#[serde(default)]`: an absent key is `false`, a `null` is a parse
// error — and `ProjectsStore` parses all-or-nothing, so one project
// with one null empties the whole list and the next save persists that.
let json = serde_json::to_string(&ClaudeCodeSettings::default()).unwrap();
assert_eq!(json, "{}");
assert!(!json.contains("null"));
let partial = ClaudeCodeSettings { env_scrub: Some(false), ..Default::default() };
let json = serde_json::to_string(&partial).unwrap();
assert_eq!(json, r#"{"env_scrub":false}"#);
// And it reads back as what it is.
let round_tripped: ClaudeCodeSettings = serde_json::from_str(&json).unwrap();
assert_eq!(round_tripped, partial);
}
}
+366
View File
@@ -0,0 +1,366 @@
//! Settings export/import — see triple-c#35.
//!
//! `SettingsExportPayload` is the whole plaintext export before encryption
//! and after decryption (see `storage::settings_crypto`). It bundles
//! `AppSettings` — with one field carved out, see below — with the global
//! secrets that live in the OS keychain instead: the shared Claude Code
//! OAuth login and the model gateway's two keys. Per-project settings,
//! per-project secrets, and anything living in a project's Docker volumes
//! are deliberately out of scope: this exports the *host* environment, not
//! any one project's.
//!
//! **`AppSettings` is not entirely the non-secret shape it looks like.**
//! `WebTerminalSettings::access_token` is a live bearer credential for a
//! server that binds every interface, stored as a plain field on the
//! struct that is otherwise safe to treat as config. A review of this
//! feature caught it: exporting `AppSettings` wholesale would have carried
//! that token along as if it were as inert as a port number, and — worse —
//! importing it would apply `web_terminal.enabled` and the token together
//! with no more warning than any other setting, letting a crafted export
//! silently stand up a LAN-listening terminal server with an
//! attacker-known token on the next launch. `export_settings` /
//! `apply_settings_import` blank this field out of the `settings` they
//! read from and write to, and it travels only through
//! [`ExportedSecrets::web_terminal_access_token`] instead, with the same
//! "only overwrite what the import actually has" treatment as the other
//! three secrets.
use serde::{Deserialize, Serialize};
use super::{AppSettings, ImageSource};
/// Bumped when the shape of [`SettingsExportPayload`] changes in a way that
/// isn't just an additive, `#[serde(default)]`-covered field — e.g. if a
/// field is ever removed or its meaning changes. `apply_settings_import`
/// checks this before touching anything.
pub const SETTINGS_EXPORT_FORMAT_VERSION: u32 = 1;
/// The global secrets bundled into an export. Deliberately a separate struct
/// from `AppSettings`: these live in the OS keychain, never in
/// `settings.json`, and — outside of this export/import flow — the values
/// themselves never cross into the frontend; see the doc comments on
/// `storage::secure::get_gateway_api_key` and
/// `commands::settings_export_commands` for why that boundary matters here
/// too.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExportedSecrets {
#[serde(default)]
pub claude_oauth_token: Option<String>,
#[serde(default)]
pub gateway_api_key: Option<String>,
#[serde(default)]
pub gateway_master_key: Option<String>,
/// See the module doc comment — this is `AppSettings::web_terminal
/// .access_token`, carved out because it is a live bearer credential,
/// not config, despite living on a struct that is otherwise safe to
/// export wholesale.
#[serde(default)]
pub web_terminal_access_token: Option<String>,
}
impl ExportedSecrets {
pub fn is_empty(&self) -> bool {
let blank = |s: &Option<String>| s.as_deref().is_none_or(|v| v.trim().is_empty());
blank(&self.claude_oauth_token)
&& blank(&self.gateway_api_key)
&& blank(&self.gateway_master_key)
&& blank(&self.web_terminal_access_token)
}
}
/// What `apply_settings_import` hands back: the settings that were actually
/// saved, plus a human-readable note for each keychain secret this import
/// carried but could not be restored. A keychain write failing partway
/// through must not read as unqualified success just because the settings
/// half of the import went through.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsImportOutcome {
pub settings: AppSettings,
#[serde(default)]
pub secret_restore_warnings: Vec<String>,
}
/// The full plaintext payload — this is what gets encrypted on export and
/// what decryption recovers on import. Never written to disk unencrypted;
/// see `storage::settings_crypto`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsExportPayload {
pub format_version: u32,
/// RFC3339. Purely informational — shown in the import preview so a user
/// picking between a few old export files has something to go on.
pub exported_at: String,
/// The exporting app's `CARGO_PKG_VERSION`. Also informational: every
/// field below already round-trips through `#[serde(default)]`-covered
/// `AppSettings`, so an older or newer export still deserializes; this is
/// for a human to notice "this is from a much older version" if an import
/// ever looks wrong, not something the code branches on.
pub app_version: String,
pub settings: AppSettings,
#[serde(default)]
pub secrets: ExportedSecrets,
}
/// What `preview_settings_import` hands the frontend before anything is
/// applied — counts and presence flags only, **never** a secret value itself,
/// so this type is safe to return across the IPC boundary and render
/// directly. The confirmation UI is built from this.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsImportPreview {
pub exported_at: String,
pub app_version: String,
pub custom_env_var_count: usize,
pub gateway_model_count: usize,
pub has_claude_code_settings: bool,
pub has_claude_oauth_token: bool,
pub has_gateway_api_key: bool,
pub has_gateway_master_key: bool,
pub has_web_terminal_access_token: bool,
/// Whether the imported settings turn the web terminal on. Named
/// separately from the token above: `enabled` and the token are two
/// different fields, either can be true without the other, and
/// "this import turns on a service that listens on your network" is
/// exactly the kind of change a wholesale settings replace must not
/// bury in a generic "settings replaced" line — see the module doc
/// comment on why this field exists at all.
pub enables_web_terminal: bool,
/// Non-blank custom base URLs the import would set, so a redirect of
/// model traffic to somewhere other than the usual provider is visible
/// at import time rather than discovered later. These are endpoints, not
/// secrets — safe to show verbatim, unlike everything above.
#[serde(default)]
pub ollama_base_url: Option<String>,
#[serde(default)]
pub llamacpp_base_url: Option<String>,
#[serde(default)]
pub openai_compatible_base_url: Option<String>,
#[serde(default)]
pub gateway_api_base: Option<String>,
/// Whether the import sets a custom Docker image, and its name if so —
/// disclosed for the same reason as the base URLs above, and arguably
/// more sharply: this is the image *every* project container is created
/// from (`models::container_config::resolve_image_name`), so a crafted
/// export pointing it at an attacker-controlled image is a path to
/// running arbitrary code with whatever a project's containers are
/// allowed to reach (the Docker socket, an SSH key, project files) —
/// not merely a redirected API endpoint.
#[serde(default)]
pub image_source: ImageSource,
#[serde(default)]
pub custom_image_name: Option<String>,
}
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
/// into a preview a user reads and a UI renders without truncation of its
/// own. Applied to every field above that carries free-form text straight
/// from the import file rather than a count or a boolean — a base URL or an
/// image name a hostile export author controls has had no validation done
/// on it yet at preview time, and nothing stops it from being pathological
/// (embedded control characters, or long enough to blow out the confirmation
/// dialog and push the security warnings below it off screen).
const MAX_PREVIEW_STRING_LEN: usize = 100;
fn sanitize_for_preview(value: &str) -> String {
let cleaned: String = value.chars().filter(|c| !c.is_control()).collect();
let trimmed = cleaned.trim();
if trimmed.chars().count() > MAX_PREVIEW_STRING_LEN {
let truncated: String = trimmed.chars().take(MAX_PREVIEW_STRING_LEN).collect();
format!("{}", truncated)
} else {
trimmed.to_string()
}
}
impl SettingsImportPreview {
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
let non_blank = |s: &Option<String>| s.as_deref().is_some_and(|v| !v.trim().is_empty());
let sanitized_non_blank = |s: &Option<String>| {
s.as_deref()
.map(sanitize_for_preview)
.filter(|v| !v.is_empty())
};
Self {
exported_at: payload.exported_at.clone(),
app_version: payload.app_version.clone(),
custom_env_var_count: payload.settings.global_custom_env_vars.len(),
gateway_model_count: payload.settings.gateway.models.len(),
has_claude_code_settings: payload.settings.global_claude_code_settings.is_some(),
has_claude_oauth_token: non_blank(&payload.secrets.claude_oauth_token),
has_gateway_api_key: non_blank(&payload.secrets.gateway_api_key),
has_gateway_master_key: non_blank(&payload.secrets.gateway_master_key),
has_web_terminal_access_token: non_blank(&payload.secrets.web_terminal_access_token),
enables_web_terminal: payload.settings.web_terminal.enabled,
ollama_base_url: sanitized_non_blank(&payload.settings.global_ollama.base_url),
llamacpp_base_url: sanitized_non_blank(&payload.settings.global_llamacpp.base_url),
openai_compatible_base_url: sanitized_non_blank(
&payload.settings.global_openai_compatible.base_url,
),
gateway_api_base: sanitized_non_blank(&payload.settings.gateway.api_base),
image_source: payload.settings.image_source.clone(),
custom_image_name: sanitized_non_blank(&payload.settings.custom_image_name),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::AppSettings;
fn payload_with(secrets: ExportedSecrets) -> SettingsExportPayload {
let settings = AppSettings {
global_custom_env_vars: vec![
crate::models::EnvVar {
key: "A".to_string(),
value: "1".to_string(),
},
crate::models::EnvVar {
key: "B".to_string(),
value: "2".to_string(),
},
],
..AppSettings::default()
};
SettingsExportPayload {
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
exported_at: "2026-08-27T00:00:00Z".to_string(),
app_version: "0.4.14".to_string(),
settings,
secrets,
}
}
#[test]
fn the_preview_never_carries_a_secret_value() {
let payload = payload_with(ExportedSecrets {
claude_oauth_token: Some("sk-super-secret-token".to_string()),
gateway_api_key: Some("sk-another-secret".to_string()),
gateway_master_key: Some("sk-triple-c-yet-another".to_string()),
web_terminal_access_token: Some("wt-super-secret-token".to_string()),
});
let preview = SettingsImportPreview::from_payload(&payload);
let serialized = serde_json::to_string(&preview).unwrap();
assert!(!serialized.contains("sk-super-secret-token"));
assert!(!serialized.contains("sk-another-secret"));
assert!(!serialized.contains("sk-triple-c-yet-another"));
assert!(!serialized.contains("wt-super-secret-token"));
assert!(preview.has_claude_oauth_token);
assert!(preview.has_gateway_api_key);
assert!(preview.has_gateway_master_key);
assert!(preview.has_web_terminal_access_token);
}
#[test]
fn a_blank_secret_reads_as_absent_in_the_preview() {
// A keychain entry that exists but holds only whitespace must not
// read as "present" — same "blank counts as absent" rule the
// keychain layer itself applies when storing these.
let payload = payload_with(ExportedSecrets {
claude_oauth_token: Some(" ".to_string()),
gateway_api_key: None,
gateway_master_key: None,
web_terminal_access_token: Some(" ".to_string()),
});
let preview = SettingsImportPreview::from_payload(&payload);
assert!(!preview.has_claude_oauth_token);
assert!(!preview.has_gateway_api_key);
assert!(!preview.has_gateway_master_key);
assert!(!preview.has_web_terminal_access_token);
}
#[test]
fn enabling_the_web_terminal_is_surfaced_regardless_of_whether_a_token_came_with_it() {
// `enabled` and the token are independent fields — a crafted export
// could set one without the other, and both are worth a user's
// attention: this is the field that exists specifically so "this
// import turns on a service that listens on your network" cannot
// hide inside a generic "settings replaced" summary.
let mut payload = payload_with(ExportedSecrets::default());
payload.settings.web_terminal.enabled = true;
let preview = SettingsImportPreview::from_payload(&payload);
assert!(preview.enables_web_terminal);
assert!(!preview.has_web_terminal_access_token);
}
#[test]
fn custom_base_urls_are_surfaced_but_blank_ones_read_as_absent() {
let mut payload = payload_with(ExportedSecrets::default());
payload.settings.global_ollama.base_url = Some("http://attacker.example:11434".to_string());
payload.settings.global_llamacpp.base_url = Some(" ".to_string());
payload.settings.gateway.api_base = Some("https://gateway.example/v1".to_string());
let preview = SettingsImportPreview::from_payload(&payload);
assert_eq!(
preview.ollama_base_url.as_deref(),
Some("http://attacker.example:11434")
);
assert_eq!(preview.llamacpp_base_url, None);
assert_eq!(preview.openai_compatible_base_url, None);
assert_eq!(
preview.gateway_api_base.as_deref(),
Some("https://gateway.example/v1")
);
}
#[test]
fn counts_reflect_the_real_settings() {
let payload = payload_with(ExportedSecrets::default());
let preview = SettingsImportPreview::from_payload(&payload);
assert_eq!(preview.custom_env_var_count, 2);
}
#[test]
fn an_empty_secrets_bundle_reports_itself_as_empty() {
assert!(ExportedSecrets::default().is_empty());
assert!(!ExportedSecrets {
claude_oauth_token: Some("x".to_string()),
..Default::default()
}
.is_empty());
}
#[test]
fn a_secrets_bundle_holding_only_whitespace_still_reports_itself_as_empty() {
// Matches the "blank counts as absent" rule every other consumer of
// these fields applies (`has_claude_oauth_token` and friends above) —
// a keychain entry that exists but holds only whitespace carries
// nothing usable, so the export-time "nothing to export" log line
// must still fire for it.
assert!(ExportedSecrets {
claude_oauth_token: Some(" ".to_string()),
..Default::default()
}
.is_empty());
}
#[test]
fn a_custom_docker_image_is_surfaced() {
let mut payload = payload_with(ExportedSecrets::default());
payload.settings.image_source = crate::models::ImageSource::Custom;
payload.settings.custom_image_name = Some("ghcr.io/attacker/triple-c:latest".to_string());
let preview = SettingsImportPreview::from_payload(&payload);
assert_eq!(preview.image_source, crate::models::ImageSource::Custom);
assert_eq!(
preview.custom_image_name.as_deref(),
Some("ghcr.io/attacker/triple-c:latest")
);
}
#[test]
fn preview_strings_are_stripped_of_control_characters_and_capped_in_length() {
let mut payload = payload_with(ExportedSecrets::default());
payload.settings.global_ollama.base_url =
Some(format!("http://example.test/{}\u{0007}bell", "x".repeat(200)));
let preview = SettingsImportPreview::from_payload(&payload);
let shown = preview.ollama_base_url.expect("non-blank base url");
assert!(!shown.contains('\u{0007}'), "control character leaked into the preview");
// +1 for the trailing ellipsis appended when truncated.
assert!(
shown.chars().count() <= MAX_PREVIEW_STRING_LEN + 1,
"preview string was not capped: {} chars",
shown.chars().count()
);
}
}
+18
View File
@@ -26,6 +26,24 @@ pub struct GitHubRelease {
pub body: String,
pub assets: Vec<GitHubAsset>,
pub published_at: String,
/// Whether GitHub itself has this release marked as a prerelease.
/// `#[serde(default)]` rather than required: every response GitHub sends
/// carries this, but nothing here should refuse to parse the rest of a
/// release over one missing field. Defaults to `false` (offered) rather
/// than `true` (excluded) — a missing field only happens if GitHub's API
/// shape changes, and "API changed, therefore updates silently stop
/// working forever" is the worse failure of the two.
///
/// `build-app.yml`'s own mirror never publishes a prerelease, but
/// `.gitea/workflows/backfill-releases.yml` forwards every Gitea release
/// unfiltered, `prerelease` included. A preview release's `preview-<sha>`
/// tag already fails semver parsing on its own, so this field is not what
/// stops *that* case — it is what stops the case tag-parsing can't catch:
/// a normally-tagged release (`v0.4.13`) that someone marks as a
/// prerelease on Gitea (a hotfix candidate, an RC) and a backfill then
/// mirrors as-is. Real defence for that case, not a no-op.
#[serde(default)]
pub prerelease: bool,
}
/// GitHub API asset response (internal).
+2
View File
@@ -1,6 +1,8 @@
pub mod migration_store;
pub mod pending_cleanup;
pub mod projects_store;
pub mod secure;
pub mod settings_crypto;
pub mod settings_store;
#[allow(unused_imports)]
@@ -0,0 +1,349 @@
//! Host-side record of Docker resources `remove_project` could not delete.
//!
//! `remove_project` drops a project's id from `projects.json` unconditionally
//! — see the comment on `ProjectRemovalReport` — so once that happens nothing
//! in the app can name the leftover container, image or volume again by any
//! path a user can reach. This is what keeps it reachable anyway: one JSON
//! file per affected project under `<data_dir>/triple-c/pending-cleanup/`,
//! written *before* the project record is dropped. Startup housekeeping
//! retries every record on the next launch (see
//! `commands::project_commands::retry_pending_cleanup_logged`) and deletes
//! the ones that fully succeed.
//!
//! **This record is written in the same instant its record in `projects.json`
//! is destroyed, and it is the only remaining handle on the leftover
//! resource** — which is a stronger claim on durability than an ordinary
//! write-temp-then-rename gives. `storage::migration_store::save` carries the
//! same reasoning for the migration state file: `fs::write` returns once the
//! bytes are in the page cache, and a rename over them is atomic with respect
//! to other readers, not to power loss. A crash in that window leaves the
//! rename applied and the data half-written, which [`list`] then treats as
//! unparseable and skips — reproducing the exact bug this module exists to
//! close, silently, with only a startup log line as evidence. So `save` here
//! takes the same `File::create` → `write_all` → `sync_all` → `rename` →
//! directory-sync shape `migration_store` does.
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingCleanup {
pub project_id: String,
/// Kept only so a log line or a future UI can name the project without a
/// second lookup — the project record itself is already gone by the time
/// this is read back.
pub project_name: String,
/// The project's container, if it could not be removed. Named by its
/// deterministic `triple-c-{id}` name rather than the (possibly stale)
/// container id Docker handed out — Docker's remove-container API
/// accepts either, and the name is the one identifier guaranteed to still
/// resolve to the same container by the time a retry runs.
pub container_id: Option<String>,
pub image: Option<String>,
pub volumes: Vec<String>,
pub recorded_at: String,
}
impl PendingCleanup {
/// True once nothing named here still needs to be removed.
pub fn is_empty(&self) -> bool {
self.container_id.is_none() && self.image.is_none() && self.volumes.is_empty()
}
}
/// `<data_dir>/triple-c/pending-cleanup`, created on demand.
fn dir() -> Result<PathBuf, String> {
let dir = dirs::data_dir()
.ok_or_else(|| {
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
})?
.join("triple-c")
.join("pending-cleanup");
fs::create_dir_all(&dir)
.map_err(|e| format!("Failed to create pending-cleanup directory: {}", e))?;
Ok(dir)
}
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
/// the write anywhere but the pending-cleanup directory. Mirrors
/// `storage::migration_store::sanitize`.
fn sanitize(project_id: &str) -> String {
project_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect()
}
/// Write (or overwrite) a project's pending-cleanup record.
pub fn save(record: &PendingCleanup) -> Result<(), String> {
save_in(&dir()?, record)
}
/// Remove a project's pending-cleanup record. Missing is success — this is
/// how a fully-succeeded retry (or a record that never existed) is expressed.
pub fn clear(project_id: &str) -> Result<(), String> {
clear_in(&dir()?, project_id)
}
/// Every pending-cleanup record on disk. An unparseable file is logged and
/// skipped rather than blocking every other project's retry — the same
/// "one bad record can't wedge the rest" reasoning as the migration store.
pub fn list() -> Vec<PendingCleanup> {
let Ok(dir) = dir() else { return Vec::new() };
list_in(&dir)
}
fn path_in(dir: &Path, project_id: &str) -> PathBuf {
dir.join(format!("{}.json", sanitize(project_id)))
}
/// Durable write: fsync the file before the rename, and fsync the directory
/// after it — see the module doc comment for why a plain
/// write-temp-then-rename is not enough here. Mirrors
/// `storage::migration_store::save`/`sync_dir`.
fn save_in(dir: &Path, record: &PendingCleanup) -> Result<(), String> {
let path = path_in(dir, &record.project_id);
let data = serde_json::to_string_pretty(record)
.map_err(|e| format!("Failed to serialize pending cleanup record: {}", e))?;
let tmp = path.with_extension("json.tmp");
{
use std::io::Write;
let mut file = fs::File::create(&tmp)
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
file.write_all(data.as_bytes())
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
file.sync_all()
.map_err(|e| format!("Failed to flush pending cleanup record to disk: {}", e))?;
}
fs::rename(&tmp, &path)
.map_err(|e| format!("Failed to commit pending cleanup record: {}", e))?;
sync_dir(&path);
Ok(())
}
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
let path = path_in(dir, project_id);
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("Failed to remove pending cleanup record: {}", e)),
}
}
fn list_in(dir: &Path) -> Vec<PendingCleanup> {
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
entries
.flatten()
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
.filter_map(|e| {
let path = e.path();
let data = fs::read_to_string(&path).ok()?;
match serde_json::from_str::<PendingCleanup>(&data) {
Ok(record) => Some(record),
Err(err) => {
// Moved aside rather than left in place: a record nothing
// ever repairs would otherwise warn on every single
// startup forever, same as an ordinary `.json` file it
// would keep looking like one to `list_in` on the next
// call too. One aside-copy is enough here — this only
// ever holds names to retry removing, not the class of
// once-in-a-lifetime crash evidence `migration_store`
// keeps multiple timestamped backups of.
let corrupt = path.with_extension("json.corrupt");
let moved = !corrupt.exists() && fs::rename(&path, &corrupt).is_ok();
log::warn!(
"Could not parse pending cleanup record {}: {}{}",
path.display(),
err,
if moved {
format!(" — moved aside to {}", corrupt.display())
} else {
" — leaving it in place".to_string()
}
);
None
}
}
})
.collect()
}
/// fsync the directory holding `path`, so a rename into it survives power
/// loss. Best effort only on the platforms where it is meaningless: Windows
/// has no directory handle to sync and errors on the attempt, so failure is
/// logged rather than propagated — the file's own `sync_all` above is what
/// carries the data. Mirrors `storage::migration_store::sync_dir`, which is
/// private to that module, so this is a small deliberate duplicate rather
/// than a shared dependency between two otherwise-independent stores.
fn sync_dir(path: &Path) {
let Some(dir) = path.parent() else { return };
match fs::File::open(dir).and_then(|d| d.sync_all()) {
Ok(()) => {}
Err(e) => log::debug!(
"Could not fsync the pending-cleanup directory {}: {} — the record itself was flushed",
dir.display(),
e
),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"triple-c-pending-cleanup-{}-{}",
name,
uuid::Uuid::new_v4().simple()
));
fs::create_dir_all(&dir).unwrap();
dir
}
fn record(project_id: &str) -> PendingCleanup {
PendingCleanup {
project_id: project_id.to_string(),
project_name: "Some Project".to_string(),
container_id: Some("triple-c-abc".to_string()),
image: Some("triple-c-snapshot-abc:latest".to_string()),
volumes: vec!["triple-c-home-abc".to_string()],
recorded_at: "2026-08-25T00:00:00Z".to_string(),
}
}
#[test]
fn project_ids_cannot_escape_the_pending_cleanup_directory() {
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
assert_eq!(sanitize("a/b"), "a_b");
assert_eq!(
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
"ab62cd24-51aa-4645-8f5c-17a124062050"
);
}
#[test]
fn is_empty_reflects_whatever_still_needs_removing() {
let mut r = record("p1");
assert!(!r.is_empty());
r.container_id = None;
r.image = None;
assert!(!r.is_empty(), "a leftover volume alone still counts");
r.volumes.clear();
assert!(r.is_empty());
}
/// Exercises the real `save_in`/`list_in`/`clear_in` — not a
/// re-implementation of their bodies — against a temp directory standing
/// in for `dir()`.
#[test]
fn a_saved_record_round_trips_and_clearing_removes_it() {
let dir = temp_dir("roundtrip");
let rec = record("proj-1");
save_in(&dir, &rec).expect("save");
let found = list_in(&dir);
assert_eq!(found.len(), 1);
assert_eq!(found[0].project_id, "proj-1");
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
clear_in(&dir, "proj-1").expect("clear");
assert!(list_in(&dir).is_empty());
fs::remove_dir_all(&dir).ok();
}
/// A second `save` for the same project overwrites rather than appending
/// — a retry that narrows the leftovers must not leave the old, wider
/// record behind it.
#[test]
fn saving_the_same_project_twice_overwrites_not_appends() {
let dir = temp_dir("overwrite");
let mut rec = record("proj-1");
save_in(&dir, &rec).expect("save");
rec.container_id = None;
rec.image = None;
save_in(&dir, &rec).expect("save again");
let found = list_in(&dir);
assert_eq!(found.len(), 1, "one file per project, not one per save");
assert!(found[0].container_id.is_none());
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
fs::remove_dir_all(&dir).ok();
}
/// A record that fails to parse must not poison the rest of the listing.
#[test]
fn an_unparseable_record_is_skipped_not_fatal() {
let dir = temp_dir("corrupt");
fs::write(dir.join("bad.json"), "{ not json").unwrap();
save_in(&dir, &record("proj-2")).expect("save");
let found = list_in(&dir);
assert_eq!(found.len(), 1);
assert_eq!(found[0].project_id, "proj-2");
fs::remove_dir_all(&dir).ok();
}
/// A record that fails to parse is moved aside once, rather than left in
/// place to be re-warned about — and re-warned about — on every future
/// launch forever.
#[test]
fn an_unparseable_record_is_moved_aside_exactly_once() {
let dir = temp_dir("corrupt-aside");
let bad = dir.join("bad.json");
fs::write(&bad, "{ not json").unwrap();
list_in(&dir);
assert!(!bad.exists(), "the bad file should have been moved aside");
let corrupt = dir.join("bad.json.corrupt");
assert!(corrupt.exists(), "and the moved copy should be at .json.corrupt");
// A second pass must not warn about `bad.json` again — it is gone —
// and must not choke on `.json.corrupt` already being there.
assert!(list_in(&dir).is_empty());
assert!(corrupt.exists(), "the aside copy is not itself deleted");
fs::remove_dir_all(&dir).ok();
}
/// `list_in` must not pick up the `.json.tmp` staging file `save_in`
/// leaves behind if a crash lands between the write and the rename — the
/// whole point of the temp-then-rename dance is that only the renamed
/// file is ever a complete record.
#[test]
fn a_leftover_tmp_file_is_not_listed() {
let dir = temp_dir("tmp-leftover");
fs::write(dir.join("proj-3.json.tmp"), "not a complete record").unwrap();
assert!(list_in(&dir).is_empty());
fs::remove_dir_all(&dir).ok();
}
/// Clearing by project id must remove exactly the file that id maps to
/// under `sanitize`, and nothing else.
#[test]
fn clearing_one_project_does_not_touch_another() {
let dir = temp_dir("clear-scoped");
save_in(&dir, &record("proj-a")).unwrap();
save_in(&dir, &record("proj-b")).unwrap();
clear_in(&dir, "proj-a").unwrap();
let found = list_in(&dir);
assert_eq!(found.len(), 1);
assert_eq!(found[0].project_id, "proj-b");
fs::remove_dir_all(&dir).ok();
}
}
+31 -7
View File
@@ -321,28 +321,52 @@ pub fn delete_gateway_api_key() -> Result<(), String> {
/// only enforces auth when a master key is configured, so Triple-C always
/// configures one.
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
if !existing.trim().is_empty() {
return Ok(existing);
}
if let Some(existing) = get_gateway_master_key()? {
return Ok(existing);
}
regenerate_gateway_master_key()
}
/// Read the gateway master key without minting one if none exists yet.
/// Distinct from [`get_or_create_gateway_master_key`], which mints as a side
/// effect the read half of that function must not have — settings export
/// (triple-c#35) needs "is there one, and if so what is it", not "make sure
/// one exists".
pub fn get_gateway_master_key() -> Result<Option<String>, String> {
Ok(read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")?
.filter(|k| !k.trim().is_empty()))
}
/// Mint a new gateway master key, invalidating the old one. Projects using the
/// previous value must be updated.
pub fn regenerate_gateway_master_key() -> Result<String, String> {
// LiteLLM requires the master key to start with `sk-`.
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
store_gateway_master_key(&key)?;
Ok(key)
}
/// Store an exact given gateway master key, replacing any previous one.
///
/// Distinct from [`regenerate_gateway_master_key`], which always mints a
/// fresh random value: this exists for settings import (triple-c#35), where
/// restoring the *same* key an export captured is the point — projects on
/// the destination machine may not exist yet, but a project migrated or
/// re-added later that still has the old key pasted into its config must
/// keep working against it. Blank input is rejected rather than silently
/// stored, matching every other `store_*` function in this module.
pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
if key.trim().is_empty() {
return Err("Refusing to store an empty gateway master key.".to_string());
}
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
entry
.set_password(&key)
.set_password(key.trim())
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
bump_gateway_secret_version()?;
Ok(key)
bump_gateway_secret_version()
}
@@ -0,0 +1,184 @@
//! Password-based encryption for the settings export/import file — see
//! triple-c#35.
//!
//! The exported payload can carry live credentials (the shared Claude OAuth
//! token, the gateway provider/master keys — see
//! `commands::settings_export_commands`), so this is not encryption for its
//! own sake; a wrong or missing key here is a real credential leak, not a
//! cosmetic bug. 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), and AES-256-GCM is what actually
//! encrypts — authenticated, so a wrong password is detected by a failed tag
//! check rather than producing silent garbage.
//!
//! File format: `MAGIC (4 bytes) | salt (16 bytes) | nonce (12 bytes) |
//! ciphertext+tag`. The salt and nonce are not secret — they are written in
//! the clear right here, on purpose. The salt's only job is to make two
//! exports with the same password derive different keys (defeats a
//! precomputed-table attack against the password alone); the nonce's job is
//! GCM's requirement that a (key, nonce) pair never repeat. Both hold
//! because a fresh random value is drawn for each, on every call to
//! [`encrypt`].
//!
//! The whole header (magic + salt + nonce) is passed to AES-GCM as
//! associated data, not just placed alongside the ciphertext — free to do,
//! and it makes tampering with any header byte fail the same authentication
//! check the ciphertext gets, by construction rather than as a side effect
//! of the salt/nonce also feeding key derivation and the cipher.
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Nonce};
use argon2::{Algorithm, Argon2, Params, Version};
use rand::RngCore;
use zeroize::Zeroizing;
/// Identifies the file as a Triple-C settings export and pins the format —
/// a change to the salt/nonce lengths or the KDF/cipher choice below needs a
/// new magic value, not a silent reinterpretation of old bytes.
const MAGIC: &[u8; 4] = b"TCX1";
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;
const HEADER_LEN: usize = MAGIC.len() + SALT_LEN + NONCE_LEN;
/// Argon2id parameters: memory cost in KiB, time cost (iterations),
/// parallelism. `(19 MiB, 2, 1)` is OWASP's documented minimum recommendation
/// for Argon2id — deliberately heavier than a login-flow KDF would use, since
/// this runs once per export/import rather than on every request, so trading
/// roughly a second of wall time for real brute-force resistance costs
/// nothing a user would notice.
fn argon2_params() -> Params {
Params::new(19 * 1024, 2, 1, Some(KEY_LEN)).expect("hardcoded Argon2 params are valid")
}
/// The derived key is wrapped in `Zeroizing` so it is overwritten with zeros
/// when it drops rather than left in freed memory for whatever reuses that
/// stack slot next — cheap insurance (`zeroize` is already in the dependency
/// tree via `aes-gcm`) for material that exists only to decrypt live
/// credentials.
fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, String> {
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params());
let mut key = Zeroizing::new([0u8; KEY_LEN]);
argon2
.hash_password_into(password.as_bytes(), salt, &mut *key)
.map_err(|e| format!("Failed to derive encryption key: {}", e))?;
Ok(key)
}
/// Encrypt `plaintext` with a key derived from `password`. Returns the whole
/// file's bytes (header + ciphertext) — see the module doc for the layout.
pub fn encrypt(plaintext: &[u8], password: &str) -> Result<Vec<u8>, String> {
let mut salt = [0u8; SALT_LEN];
rand::rng().fill_bytes(&mut salt);
let key = derive_key(password, &salt)?;
let mut nonce_bytes = [0u8; NONCE_LEN];
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let mut header = Vec::with_capacity(HEADER_LEN);
header.extend_from_slice(MAGIC);
header.extend_from_slice(&salt);
header.extend_from_slice(&nonce_bytes);
let cipher = Aes256Gcm::new_from_slice(&*key)
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
// The header (magic + salt + nonce) is authenticated as associated data
// even though none of it is secret: it costs nothing extra here, and it
// means tampering with any header byte is caught by the same tag check
// that already covers the ciphertext, by construction rather than as a
// side effect of the header also feeding key/nonce derivation.
let ciphertext = cipher
.encrypt(nonce, Payload { msg: plaintext, aad: &header })
.map_err(|e| format!("Encryption failed: {}", e))?;
let mut out = header;
out.extend_from_slice(&ciphertext);
Ok(out)
}
/// Decrypt a file produced by [`encrypt`]. The one error this returns for a
/// wrong password is deliberately generic ("wrong password, or the file is
/// corrupted") rather than distinguishing the two: GCM's authentication tag
/// fails to verify for the wrong key on essentially any ciphertext, so there
/// is no reliable way to tell "wrong password" from "corrupted file" apart,
/// and guessing would be worse than saying so.
///
/// Returns `Zeroizing<Vec<u8>>` rather than a plain `Vec<u8>` — the plaintext
/// this recovers is the whole settings-plus-secrets payload, so it gets the
/// same "wipe it when it drops" treatment as the derived key in
/// [`derive_key`].
pub fn decrypt(data: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>, String> {
if data.len() < HEADER_LEN {
return Err("This does not look like a Triple-C settings export (file too short).".to_string());
}
if &data[..MAGIC.len()] != MAGIC {
return Err("This does not look like a Triple-C settings export (unrecognized file).".to_string());
}
let header = &data[..HEADER_LEN];
let salt = &data[MAGIC.len()..MAGIC.len() + SALT_LEN];
let nonce_bytes = &data[MAGIC.len() + SALT_LEN..HEADER_LEN];
let ciphertext = &data[HEADER_LEN..];
let key = derive_key(password, salt)?;
let cipher = Aes256Gcm::new_from_slice(&*key)
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, Payload { msg: ciphertext, aad: header })
.map(Zeroizing::new)
.map_err(|_| "Wrong password, or the file is corrupted.".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_round_trip_with_the_right_password_recovers_the_plaintext() {
let plaintext = b"{\"settings\": \"whatever\"}";
let encrypted = encrypt(plaintext, "correct horse battery staple").unwrap();
let decrypted = decrypt(&encrypted, "correct horse battery staple").unwrap();
assert_eq!(&*decrypted, plaintext);
}
#[test]
fn the_wrong_password_fails_rather_than_returning_garbage() {
let encrypted = encrypt(b"secret payload", "correct password").unwrap();
let result = decrypt(&encrypted, "wrong password");
assert!(result.is_err(), "decrypting with the wrong password must fail, not silently succeed");
}
#[test]
fn two_exports_of_the_same_plaintext_and_password_produce_different_files() {
// If this ever failed it would mean the salt or nonce stopped being
// randomized — either one repeating is a real security regression
// (a fixed salt lets an attacker precompute against the password
// alone; a repeated (key, nonce) pair breaks GCM's guarantees
// outright), not just a cosmetic one.
let a = encrypt(b"same plaintext", "same password").unwrap();
let b = encrypt(b"same plaintext", "same password").unwrap();
assert_ne!(a, b, "two independent exports must not be byte-identical");
}
#[test]
fn corrupting_a_single_byte_of_ciphertext_is_detected() {
let mut encrypted = encrypt(b"tamper-evident payload", "a password").unwrap();
let last = encrypted.len() - 1;
encrypted[last] ^= 0xFF;
assert!(decrypt(&encrypted, "a password").is_err());
}
#[test]
fn a_file_that_is_too_short_is_rejected_cleanly_not_by_panicking() {
assert!(decrypt(b"short", "any password").is_err());
assert!(decrypt(b"", "any password").is_err());
}
#[test]
fn a_file_with_the_wrong_magic_is_rejected() {
let mut encrypted = encrypt(b"payload", "password").unwrap();
encrypted[0] = b'X';
assert!(decrypt(&encrypted, "password").is_err());
}
}
+56 -6
View File
@@ -431,6 +431,18 @@
const mobileInput = document.getElementById('mobileInput');
const btnEnter = document.getElementById('btnEnter');
const btnNewline = document.getElementById('btnNewline');
// Whether the *active* session understands ESC+CR as "insert a newline".
//
// Only Claude Code does. `bash -l` has no readline binding for `\e\r`, so
// sending it there is a silent no-op — which is worse from the mobile bar
// than from a hardware key, because the bar puts a dedicated button on
// screen that appears to do nothing. The xterm key handler is already scoped
// this way; these two paths were not.
function activeSessionTakesEscCr() {
const s = activeSessionId && sessions[activeSessionId];
return !!s && s.type === 'claude';
}
const btnTab = document.getElementById('btnTab');
const btnCtrlC = document.getElementById('btnCtrlC');
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
@@ -590,7 +602,7 @@
updateProjectList(msg.projects);
break;
case 'opened':
onSessionOpened(msg.session_id, msg.project_name);
onSessionOpened(msg.session_id, msg.project_name, msg.session_type);
break;
case 'output':
onSessionOutput(msg.session_id, msg.data);
@@ -641,8 +653,18 @@
});
}
function onSessionOpened(sessionId, projectName) {
const sessionType = pendingSessionType || 'claude';
function onSessionOpened(sessionId, projectName, serverSessionType) {
// Prefer the type the *server* reports for this session. The old path read
// a single `pendingSessionType` global set at request time, so opening two
// sessions before the first reply landed swapped their labels — routine on
// mobile, where nothing disables the buttons. That was cosmetic until
// Shift+Enter became type-dependent: a Claude session labelled `shell`
// sends a bare CR and submits a half-written prompt.
//
// The fallback keeps an older server working, and defaults to `claude`,
// which is the safe direction — ESC+CR is an unbound no-op in bash, while
// a bare CR in Claude Code loses the prompt.
const sessionType = serverSessionType || pendingSessionType || 'claude';
pendingSessionType = null;
// Create terminal
@@ -735,7 +757,13 @@
sessionType === 'claude'
) {
sendTerminalInput('\x1b\r');
return false; // xterm must not also send a bare CR, which submits
// `preventDefault()` is what stops the submit, not the `return false`.
// xterm's `_keyDown` returns before setting `_keyDownHandled`, so
// `_keyPress` still fires and emits a bare CR for Enter — inserting the
// newline and then submitting the prompt anyway. See the same comment
// in TerminalView.tsx.
e.preventDefault();
return false;
}
return true;
});
@@ -791,6 +819,7 @@
switchToSession(remaining[remaining.length - 1]);
} else {
activeSessionId = null;
syncNewlineButton();
emptyState.style.display = '';
}
}
@@ -817,6 +846,7 @@
function switchToSession(sessionId) {
activeSessionId = sessionId;
syncNewlineButton();
// Update tab styles
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -896,7 +926,7 @@
// reasoning, as the terminal's own key handler above. A hardware
// keyboard on a tablet is the only way to reach this; the phone case is
// the dedicated newline button beside Enter.
sendTerminalInput(e.shiftKey ? '\x1b\r' : '\r');
sendTerminalInput(e.shiftKey && activeSessionTakesEscCr() ? '\x1b\r' : '\r');
} else if (e.key === 'Tab') {
e.preventDefault();
sendTerminalInput('\t');
@@ -904,7 +934,27 @@
});
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
btnNewline.onclick = () => { sendTerminalInput('\x1b\r'); mobileInput.focus(); };
btnNewline.onclick = () => {
if (!activeSessionTakesEscCr()) { mobileInput.focus(); return; }
sendTerminalInput('\x1b\r');
mobileInput.focus();
};
// Keep the button's affordance honest: on a shell tab there is no byte that
// means "newline without running the line", so the control is disabled
// rather than left looking live.
function syncNewlineButton() {
const usable = activeSessionTakesEscCr();
btnNewline.disabled = !usable;
btnNewline.title = usable
? 'Insert a newline without submitting (Shift+Enter)'
: 'Only Claude sessions support this — a shell runs the line instead';
}
// With no session open yet, `activeSessionTakesEscCr()` is already false —
// but nothing had called this, so the button rendered live before the first
// tab existed.
syncNewlineButton();
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
@@ -46,6 +46,16 @@ enum ServerMessage {
Opened {
session_id: String,
project_name: String,
/// Echoed back so the client can label the session from the reply
/// rather than from a global set at request time.
///
/// Without it the client correlates through a single
/// `pendingSessionType`, so opening two sessions before the first
/// reply lands swaps their labels. That used to be cosmetic; it stopped
/// being cosmetic when Shift+Enter became type-dependent, because a
/// Claude session mislabelled as a shell now submits a half-written
/// prompt instead of inserting a newline.
session_type: String,
},
Output {
session_id: String,
@@ -319,6 +329,11 @@ async fn handle_open(
let _ = out_tx.send(ServerMessage::Opened {
session_id,
project_name,
// Derived from the same match that chose `cmd` above, not echoed from
// the request: anything that is not exactly "bash" runs Claude, so
// echoing the raw value would label an unrecognised string as its own
// type and put the client back where it started.
session_type: if session_type == Some("bash") { "bash" } else { "claude" }.to_string(),
});
Ok(())
@@ -60,12 +60,18 @@ describe("ClaudeCodeSettingsEditor", () => {
});
it("offers every effort level Claude Code accepts", () => {
// Verified against the shipped `claude` binary's own schema rather than
// inferred: low/medium/high/xhigh/max. `max` was missing until an audit
// checked externally — which is the whole weakness of this test. It can
// only prove the editor agrees with this list, never that the list is the
// one Claude Code reads. The same blind spot is why `effort` and
// `focusMode` were confidently wrong for months.
renderEditor(null);
expect(
Array.from(
screen.getByLabelText("Effort level").querySelectorAll("option"),
).map((o) => o.getAttribute("value")),
).toEqual(["", "low", "medium", "high", "xhigh"]);
).toEqual(["", "low", "medium", "high", "xhigh", "max"]);
});
describe("project scope", () => {
@@ -193,4 +199,27 @@ describe("ClaudeCodeSettingsEditor", () => {
expect(screen.getByRole("switch", { name: label })).toBeChecked();
});
});
/**
* A settings object with nothing set at this level arrives as `{}`: the Rust
* struct skips serialising a field it has no value for, which is what keeps
* an older binary able to parse `projects.json` after a downgrade. It is also
* the exact shape a project stored before the fields were widened is read
* back as every one of its `false`s meant "unset" so reading absent as
* "off" would show a switch the user never touched as a deliberate choice.
*/
it("reads an absent field as Global rather than as Off", () => {
renderEditor({} as ClaudeCodeSettings, "project");
expect((screen.getByLabelText("Env scrub") as HTMLSelectElement).value).toBe("global");
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe("global");
});
it("still collapses to null when an absent-field object is edited back", () => {
const onSave = renderEditor({} as ClaudeCodeSettings, "global");
// Off and straight back on: the round trip has to land on `null`, or an
// untouched global stops being indistinguishable from one never opened.
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
expect(onSave).toHaveBeenLastCalledWith(null);
});
});
@@ -37,15 +37,20 @@ export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
* overrides a global on, so a settings object holding one has to be persisted.
*/
function isAllDefaults(s: ClaudeCodeSettings): boolean {
// `== null`, not `===`: an unset field is *absent* on the wire, not null.
// The Rust struct skips serialising one it has no value for, so a project
// whose stored settings were all "unset" arrives here as `{}` — and reading
// that as "off" is exactly the mistake the three-state control exists to
// avoid. See the note on `ClaudeCodeSettings` in `lib/types.ts`.
return (
s.tui_mode === null &&
s.effort === null &&
s.auto_scroll_disabled === null &&
s.focus_mode === null &&
s.show_thinking_summaries === null &&
s.session_recap_disabled === null &&
s.env_scrub === null &&
s.prompt_caching_1h === null
s.tui_mode == null &&
s.effort == null &&
s.auto_scroll_disabled == null &&
s.focus_mode == null &&
s.show_thinking_summaries == null &&
s.session_recap_disabled == null &&
s.env_scrub == null &&
s.prompt_caching_1h == null
);
}
@@ -63,7 +68,15 @@ const BOOLEAN_FIELDS: {
hint: string;
invert?: boolean;
}[] = [
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
{
key: "focus_mode",
label: "Focus mode",
// It summarises tool *calls*, not all output — and it does nothing at all
// unless the fullscreen renderer is on, which is a separate switch above.
// Saying so here is cheaper than the user concluding the setting is broken,
// which is the complaint that started this whole round of work.
hint: "Summarises each tool call to one line, showing the last prompt and the final response. Needs TUI mode set to Fullscreen.",
},
{
key: "show_thinking_summaries",
label: "Thinking summaries",
@@ -163,6 +176,9 @@ export default function ClaudeCodeSettingsEditor({
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="xhigh">Extra high</option>
{/* `max` is accepted by the CLI and was missing here. Confirmed
against the shipped claude binary's own schema, not just docs. */}
<option value="max">Maximum</option>
</select>
}
/>
@@ -204,7 +220,7 @@ export default function ClaudeCodeSettingsEditor({
// `stored` holds the deviation from Claude Code's default, so an
// inverted field reads back the other way round — see BOOLEAN_FIELDS.
const selected =
stored === null ? "global" : (invert ? !stored : stored) ? "on" : "off";
stored == null ? "global" : (invert ? !stored : stored) ? "on" : "off";
return (
<SwitchRow
@@ -16,14 +16,12 @@ interface Props {
projectId: string;
entry: FileEntry;
onClose: () => void;
/** "Save to host…" — the way out for anything the viewer can't render. */
onSaveToHost: (entry: FileEntry) => void;
}
type Preview =
| { kind: "loading" }
| { kind: "error"; message: string }
/** Too big to render whole — offered as a download rather than a half-file. */
/** Too big to render whole — said so rather than shown as a half-file. */
| { kind: "too-large" }
| { kind: "text"; text: string; truncated: boolean; shownBytes: number; trueSize: number }
| { kind: "image"; url: string }
@@ -37,7 +35,7 @@ type Preview =
* keeps a multi-megabyte base64 string out of the DOM. `blob:` is in the app's
* `img-src` for exactly this; the asset protocol deliberately is not enabled.
*/
export default function FileViewerModal({ projectId, entry, onClose, onSaveToHost }: Props) {
export default function FileViewerModal({ projectId, entry, onClose }: Props) {
const [preview, setPreview] = useState<Preview>({ kind: "loading" });
/**
@@ -121,19 +119,9 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
);
const footer = (
<>
<Button
size="md"
onClick={() => {
onSaveToHost(entry);
}}
>
Save to host
</Button>
<Button size="md" variant="primary" onClick={onClose}>
Close
</Button>
</>
<Button size="md" variant="primary" onClick={onClose}>
Close
</Button>
);
return (
@@ -156,14 +144,16 @@ export default function FileViewerModal({ projectId, entry, onClose, onSaveToHos
{preview.kind === "too-large" && (
<p className="text-[13px] text-[var(--text-secondary)]">
This file is {formatBytes(entry.size)} too large to preview in the app. Save it
to the host to open it there.
This file is {formatBytes(entry.size)} too large to preview in the app. Use
Save to host on its row to open it in a program that can, or read it from a
terminal in the container.
</p>
)}
{preview.kind === "unsupported" && (
<p className="text-[13px] text-[var(--text-secondary)]">
There is no preview for this file type. Save it to the host to open it there.
There is no preview for this file type. Use Save to host on its row to open it
in a program that can, or read it from a terminal in the container.
</p>
)}
@@ -1,23 +1,23 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
import FilesTab from "./FilesTab";
import type { FileContents, FileEntry, Project } from "../../../lib/types";
const listContainerFiles = vi.fn();
const downloadContainerFile = vi.fn(async () => {});
const uploadFileToContainer = vi.fn(async () => {});
const renameContainerPath = vi.fn(async () => "");
const createContainerDirectory = vi.fn(async () => "");
const readContainerFile = vi.fn();
const uploadFilesToContainer = vi.fn();
const downloadContainerFile = vi.fn();
vi.mock("../../../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
}));
/** Transient failures land in `ToastHost`, not in an inline string. */
@@ -31,29 +31,6 @@ const toastText = () =>
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
.join("\n");
const save = vi.fn(async () => "/host/out");
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: (o: unknown) => save(o),
open: vi.fn(async () => null),
}));
/** The webview's window-wide native drag-drop listener, captured for driving. */
type DragPayload =
| { type: "enter" | "over"; position: { x: number; y: number }; paths: string[] }
| { type: "leave" }
| { type: "drop"; position: { x: number; y: number }; paths: string[] };
let dragHandler: ((e: { payload: DragPayload }) => void | Promise<void>) | null = null;
const unlistenDrag = vi.fn();
vi.mock("@tauri-apps/api/webview", () => ({
getCurrentWebview: () => ({
onDragDropEvent: async (cb: (e: { payload: DragPayload }) => void) => {
dragHandler = cb;
return unlistenDrag;
},
}),
}));
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
const entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
@@ -82,39 +59,17 @@ async function renderTab() {
return view;
}
/** Fire the native drop payload at a point inside the pane's stubbed rect. */
async function drop(paths: string[], position = { x: 100, y: 100 }) {
await act(async () => {
await dragHandler?.({ payload: { type: "drop", position, paths } });
});
}
/** Every row that is part of the grid's roving tabindex, in order. */
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
/** The rows that are actually tab stops. There must never be more than one. */
const tabStops = () => gridRows().filter((r) => r.getAttribute("tabindex") === "0");
/** Fire a drop without awaiting it — for the paths that stop to ask a question. */
function dropWithoutWaiting(paths: string[], position = { x: 100, y: 100 }) {
let pending: unknown;
act(() => {
pending = dragHandler?.({ payload: { type: "drop", position, paths } });
});
return pending as Promise<void> | undefined;
}
beforeEach(() => {
vi.clearAllMocks();
dragHandler = null;
listContainerFiles.mockResolvedValue([
entry("src", { is_directory: true, path: "/workspace/src" }),
entry("notes.txt"),
]);
// jsdom lays nothing out, so the pane's hit-test rect has to be supplied.
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600,
toJSON: () => ({}),
} as DOMRect);
// Not implemented in jsdom; the image preview needs both halves.
URL.createObjectURL = vi.fn(() => "blob:mock-url");
URL.revokeObjectURL = vi.fn();
@@ -224,7 +179,15 @@ describe("FilesTab viewer", () => {
});
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
expect(screen.queryByAltText("huge.png")).toBeNull();
expect(screen.getByRole("button", { name: "Save to host…" })).toBeTruthy();
// A refusal has to name the way out, and the way out is now the button on
// the row rather than the `cat`-it-in-a-terminal workaround that existed
// because the button did not.
// Scoped to the modal: every file row also carries a "Save to host…"
// button now, so an unscoped query matches the grid behind the overlay and
// would pass with the refusal saying nothing at all.
expect(
within(screen.getByRole("dialog")).getByText(/Save to host/),
).toBeTruthy();
});
it("says so in words when only a prefix of a big text file came back", async () => {
@@ -239,7 +202,7 @@ describe("FilesTab viewer", () => {
expect(screen.getByText("first megabyte")).toBeTruthy();
});
it("offers Save to host for a file it cannot render", async () => {
it("says there is no preview, and where to open the file instead", async () => {
listContainerFiles.mockResolvedValue([entry("blob.bin")]);
readContainerFile.mockResolvedValue(contents("a\x00b"));
await renderTab();
@@ -314,191 +277,6 @@ describe("FilesTab new folder", () => {
});
});
describe("FilesTab host drag-and-drop", () => {
it("uploads dropped paths into the directory on screen, then re-lists", async () => {
await renderTab();
listContainerFiles.mockClear();
await drop(["/host/a.png", "/host/b.png"]);
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace");
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace");
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
});
it("drops into the directory the user has navigated to", async () => {
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("src"));
});
await drop(["/host/a.png"]);
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace/src");
});
it("ignores a drop outside the pane — the listener is window-wide", async () => {
// This is the whole routing discipline: the terminal's listener is live at
// the same time, and only the hit-test keeps them apart.
await renderTab();
await drop(["/host/a.png"], { x: 5000, y: 5000 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("divides the payload position by devicePixelRatio on Windows only", async () => {
// Only wry's WebView2 backend hands over *physical* pixels; the macOS and
// GTK ones deliver logical points and `tauri-runtime-wry` does not rescale
// them. At dpr 2 a physical (900, 900) is a CSS (450, 450) — inside the
// 800x600 pane — but the same payload on a HiDPI Mac or Linux box really
// is (900, 900) and belongs to nobody.
const originalDpr = window.devicePixelRatio;
const originalUa = window.navigator.userAgent;
Object.defineProperty(window, "devicePixelRatio", { value: 2, configurable: true });
Object.defineProperty(window.navigator, "userAgent", {
value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
configurable: true,
});
await renderTab();
await drop(["/host/a.png"], { x: 900, y: 900 });
expect(uploadFileToContainer).toHaveBeenCalled();
Object.defineProperty(window.navigator, "userAgent", {
value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
configurable: true,
});
vi.mocked(uploadFileToContainer).mockClear();
await drop(["/host/a.png"], { x: 900, y: 900 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
// …and the *unhalved* point still lands, which is the half a HiDPI Mac
// user was losing.
await drop(["/host/a.png"], { x: 400, y: 300 });
expect(uploadFileToContainer).toHaveBeenCalled();
Object.defineProperty(window, "devicePixelRatio", {
value: originalDpr,
configurable: true,
});
Object.defineProperty(window.navigator, "userAgent", {
value: originalUa,
configurable: true,
});
});
it("accepts a drop that lands on a toast floating over the pane", async () => {
// Round 1. `ToastHost` is `fixed bottom-4 right-4 z-[60]` and 24rem wide,
// and its error cards stay until dismissed — so a z-order gate asking "is
// what is painted here part of my pane?" made the bottom-right corner of
// this pane refuse drops for as long as one error was on screen. jsdom has
// no `elementFromPoint`, so that branch only ran when a test supplied one;
// the gate no longer asks, and this pins that nothing painted over a pane
// can refuse a drop on its own account.
await renderTab();
const toastCard = document.createElement("div");
document.body.appendChild(toastCard);
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
writable: true,
value: () => toastCard,
});
await drop(["/host/a.png"], { x: 700, y: 550 });
expect(uploadFileToContainer).toHaveBeenCalled();
delete (document as Partial<Document>).elementFromPoint;
toastCard.remove();
});
it("refuses a drop while a dialog is open, toast painted over it or not", async () => {
// Round 2, which is the reason this file exists in its current shape. The
// refusal pushes a toast; `ToastHost` is `z-[60]` and the `Modal` backdrop
// is `z-50` in the same stacking context, so the *toast* becomes the
// topmost element over a covered pane. A gate that asked `elementFromPoint`
// "is a blocker painted here?" then answered no and uploaded into the
// directory the dialog was covering — one refused drop was all it took to
// open the hole. Both stubs below therefore have to be refused.
await renderTab();
const backdrop = document.createElement("div");
backdrop.setAttribute("data-blocks-drop", "true");
document.body.appendChild(backdrop);
const toastCard = document.createElement("div"); // z-[60], above the backdrop
document.body.appendChild(toastCard);
const stub = (top: Element) =>
Object.defineProperty(document, "elementFromPoint", {
configurable: true,
writable: true,
value: () => top,
});
stub(backdrop);
await drop(["/host/a.png"], { x: 400, y: 300 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
stub(toastCard);
await drop(["/host/a.png"], { x: 700, y: 550 });
expect(uploadFileToContainer).not.toHaveBeenCalled();
delete (document as Partial<Document>).elementFromPoint;
toastCard.remove();
backdrop.remove();
});
it("highlights the pane while a drag hovers it, and drops the highlight on leave", async () => {
await renderTab();
await act(async () => {
await dragHandler?.({
payload: { type: "over", position: { x: 100, y: 100 }, paths: [] },
});
});
expect(screen.getByText(/Drop files into \/workspace/)).toBeTruthy();
await act(async () => {
await dragHandler?.({ payload: { type: "leave" } });
});
expect(screen.queryByText(/Drop files into/)).toBeNull();
});
});
describe("FilesTab save to host", () => {
it("copies a file out to the path the user picks", async () => {
await renderTab();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Save to host… — notes.txt" }));
});
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
});
it("does not offer a directory download, which cannot work", async () => {
await renderTab();
expect(screen.queryByRole("button", { name: "Save to host… — src" })).toBeNull();
});
});
describe("FilesTab drop hit test", () => {
it("uploads nothing when a dialog is covering the pane", async () => {
// The pane still has its rect underneath the viewer's `fixed inset-0`
// portal, which is exactly why a rect alone was the wrong test.
readContainerFile.mockResolvedValue(contents("hello"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("notes.txt"));
});
await screen.findByRole("dialog");
await drop(["/host/a.png"]);
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("does not paint the hint under a dialog either", async () => {
readContainerFile.mockResolvedValue(contents("hello"));
await renderTab();
await act(async () => {
fireEvent.doubleClick(screen.getByText("notes.txt"));
});
await screen.findByRole("dialog");
await act(async () => {
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
});
expect(screen.queryByText(/Drop files into/)).toBeNull();
});
});
describe("FilesTab grid focus", () => {
it("gives the grid exactly one tab stop and moves it with the arrows", async () => {
// Every row used to be `tabIndex={0}`: a 400-entry directory was ~1200 tab
@@ -592,22 +370,28 @@ describe("FilesTab grid semantics", () => {
await renderTab();
const rename = screen.getByRole("button", { name: "Rename — notes.txt" });
expect(rename.textContent).toBe("Rename");
expect(rename.getAttribute("aria-label")).toContain("Rename");
const saveTo = screen.getByRole("button", { name: "Save to host… — notes.txt" });
expect(saveTo.getAttribute("aria-label")).toContain(saveTo.textContent!);
expect(rename.getAttribute("aria-label")).toContain(rename.textContent!);
});
it("mounts the live region empty, then fills it", async () => {
// A `role="status"` node inserted already carrying its text is frequently
// not announced at all, which is how every one of these went by in silence.
createContainerDirectory.mockResolvedValue("/workspace/new");
await renderTab();
const live = screen.getByRole("status");
expect(live.textContent).toBe("");
await drop(["/host/a.png"]);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
});
const input = screen.getByLabelText("New folder name");
fireEvent.change(input, { target: { value: "new" } });
await act(async () => {
fireEvent.blur(input);
});
// Same node throughout — it is never unmounted.
expect(screen.getByRole("status")).toBe(live);
expect(live.textContent).toContain("Uploaded 1 item");
expect(live.textContent).toContain('Created "new"');
});
it("keeps a listing failure inline, where the rows it explains are missing", async () => {
@@ -619,129 +403,72 @@ describe("FilesTab grid semantics", () => {
});
});
describe("FilesTab overwrite prompt", () => {
it("asks before replacing, and re-uploads with overwrite on Replace", async () => {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
await renderTab();
const pending = dropWithoutWaiting(["/host/notes.txt"]);
const dialog = await screen.findByRole("dialog");
expect(dialog.textContent).toContain("notes.txt");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
await pending;
});
expect(uploadFileToContainer).toHaveBeenLastCalledWith(
"p1",
"/host/notes.txt",
"/workspace",
true,
);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("uploads nothing more on Skip", async () => {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/notes.txt already exists");
await renderTab();
const pending = dropWithoutWaiting(["/host/notes.txt"]);
await screen.findByRole("dialog");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
await pending;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("offers the blanket answers only when files are queued behind this one", async () => {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
await renderTab();
const pending = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
await screen.findByRole("dialog");
expect(screen.getByRole("button", { name: "Replace all" })).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Skip all" }));
await pending;
});
expect(screen.queryByRole("dialog")).toBeNull();
});
});
/**
* Dismissal. `Modal` gives every dialog Escape, a and click-outside for free,
* and `OverwriteConfirmModal` maps all three onto `onChoose("skip")` because
* the destructive answer has to be chosen, and because a dialog that is closed
* rather than answered must not leave the batch waiting forever or throw away
* the files behind it.
* The pane's two host-transfer affordances.
*
* They are asserted at the *button* level and not only in the hook, because
* this is the half that was actually lost: the commands behind them had been
* deleted, but so had the controls, and a working command nobody can reach is
* the same regression. Neither button names a host path Rust opens the
* dialog so what a click is required to prove is that the container-side
* argument reaching the backend is the one the user is looking at.
*/
describe("FilesTab overwrite prompt dismissal", () => {
/**
* Drop two files where the first name is taken, and stop at the dialog. The
* unsettled batch comes back wrapped returning it bare from an `async`
* helper would adopt it, and awaiting the helper would then wait for an
* upload that cannot proceed until the helper has returned.
*/
async function dropIntoConflict(): Promise<{ batch: Promise<void> | undefined }> {
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
describe("FilesTab host transfers", () => {
beforeEach(() => {
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
downloadContainerFile.mockResolvedValue(4);
});
it("uploads into the directory currently on screen", async () => {
listContainerFiles.mockResolvedValue([entry("src", { is_directory: true })]);
await renderTab();
const batch = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
await screen.findByRole("dialog");
return { batch };
}
/** What every dismissal has to leave behind: one skip, one upload, no clobber. */
function expectSkippedAndCarriedOn() {
expect(screen.queryByRole("dialog")).toBeNull();
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
expect(uploadFileToContainer).toHaveBeenLastCalledWith("p1", "/host/b.txt", "/workspace");
expect(uploadFileToContainer.mock.calls.some((call) => call[3] === true)).toBe(false);
expect(screen.getByRole("status").textContent).toContain("skipped 1");
}
it("counts Escape as a Skip", async () => {
const { batch } = await dropIntoConflict();
await act(async () => {
fireEvent.keyDown(document, { key: "Escape" });
await batch;
fireEvent.doubleClick(screen.getByText("src"));
});
expectSkippedAndCarriedOn();
uploadFilesToContainer.mockResolvedValueOnce({
uploaded: ["/workspace/src/a.txt"],
failures: [],
});
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Upload…" }));
});
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/src");
});
it("counts the ✕ as a Skip", async () => {
const { batch } = await dropIntoConflict();
it("offers Save to host on a file and not on a folder", async () => {
listContainerFiles.mockResolvedValue([
entry("notes.txt"),
entry("src", { is_directory: true }),
]);
await renderTab();
// The accessible name carries the row, per WCAG 2.5.3 — and it is how a
// per-row action is told apart from every other row's copy of it.
expect(
screen.getByRole("button", { name: "Save to host — notes.txt" }),
).toBeTruthy();
expect(
screen.queryByRole("button", { name: "Save to host — src" }),
).toBeNull();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Close dialog" }));
await batch;
fireEvent.click(screen.getByRole("button", { name: "Save to host — notes.txt" }));
});
expectSkippedAndCarriedOn();
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
});
it("counts a click on the backdrop as a Skip", async () => {
const { batch } = await dropIntoConflict();
// The overlay is the dialog panel's parent — `Modal` only closes when the
// click landed on the overlay itself, not on anything inside the panel.
const overlay = screen.getByRole("dialog").parentElement!;
it("does not open the file viewer when Save to host is double-clicked", async () => {
// Opening a file is a *double*-click on the row, and a double-click on a
// button inside that row still bubbles — `onClick`'s `stopPropagation` does
// nothing about it. So an impatient double-click on Save used to save the
// file and drop the viewer modal over the pane at the same time, on top of
// the save dialog the backend had just opened.
listContainerFiles.mockResolvedValue([entry("notes.txt")]);
readContainerFile.mockResolvedValue(contents("hello"));
await renderTab();
await act(async () => {
fireEvent.click(overlay);
await batch;
fireEvent.doubleClick(
screen.getByRole("button", { name: "Save to host — notes.txt" }),
);
});
expectSkippedAndCarriedOn();
});
it("does not dismiss on a click inside the dialog", async () => {
const { batch } = await dropIntoConflict();
fireEvent.click(screen.getByRole("dialog"));
expect(screen.queryByRole("dialog")).not.toBeNull();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
await batch;
});
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
expect(readContainerFile).not.toHaveBeenCalled();
});
});
+60 -99
View File
@@ -1,12 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import type { FileEntry, Project } from "../../../lib/types";
import { useFileManager } from "../../../hooks/useFileManager";
import { classifyDrop, isDropTarget, DROP_BLOCKED_TOAST } from "../../../lib/dropTarget";
import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button";
import FileViewerModal from "./FileViewerModal";
import OverwriteConfirmModal from "./OverwriteConfirmModal";
import { formatBytes } from "./format";
interface Props {
@@ -17,7 +13,28 @@ interface Props {
const PARENT_ROW = "..";
/**
* The project's file manager.
* The project's file browser.
*
* It lists, opens, renames and creates folders inside the container, and it
* copies single files across the boundary: "Upload…" in the toolbar, and a
* per-row "Save to host…".
*
* **Neither of those names a host path, and this file must never learn how
* to.** Four successive audits found that host paths crossing IPC were where
* the criticals lived a frontend `open()`/`save()` handing Rust a string is
* exactly the shape that failed so the picker is opened by the *backend*
* (`pick_files_to_upload` / `pick_save_path` in `commands/file_commands.rs`).
* What this file *sends* is a project id and a container path; the host side of
* the transfer is chosen by a person in an OS dialog. That is why
* `uploadFiles()` takes no argument and `saveToHost()` takes only the entry.
* (A failed transfer does report a host path back, in the text of its error
* the inbound direction is the one that is closed, not both.)
*
* Drag-and-drop is deliberately still absent, in both directions. A file also
* gets into a container by being dropped onto the Terminal tab, and a whole
* tree comes back out through "Back up container" in the project's menu
* which is still the right answer for a directory, since "Save to host…" is one
* file at a time and is not offered on folders.
*
* Interaction model, chosen to match every desktop file manager rather than
* the old half-and-half: **single click selects, double click opens**. That
@@ -42,18 +59,16 @@ export default function FilesTab({ project }: Props) {
entries,
loading,
error,
busy,
completed,
conflict,
resolveConflict,
navigate,
goUp,
refresh,
downloadFile,
uploadFile,
uploadPaths,
renameEntry,
createFolder,
uploadFiles,
saveToHost,
uploading,
savingPaths,
} = useFileManager(project.id);
const running = project.status === "running";
@@ -65,8 +80,6 @@ export default function FilesTab({ project }: Props) {
const [creatingFolder, setCreatingFolder] = useState(false);
const [folderDraft, setFolderDraft] = useState("");
const [viewing, setViewing] = useState<FileEntry | null>(null);
/** A host drag is currently over this pane. */
const [dragOver, setDragOver] = useState(false);
/** The row that owns the grid's single tab stop. */
const [activeRow, setActiveRow] = useState<string | null>(null);
@@ -234,58 +247,6 @@ export default function FilesTab({ project }: Props) {
goUp();
}, [currentPath, goUp]);
// Host → container drag and drop.
//
// This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same
// reason `TerminalView` uses it: `dragDropEnabled` is on (the terminal needs
// it), which blocks HTML5 drag inside the webview on Windows, and only the
// native payload carries real file *paths*. The listener is window-wide, so
// routing is `classifyDrop` — the rect hit test, which says *whose* drop it
// is, plus the document-wide question a rect cannot answer: is a modal or a
// blocking overlay on screen at all? That second half is deliberately not a
// per-point z-order test; `lib/dropTarget.ts` records the two ways that went
// wrong.
useEffect(() => {
if (!running) return;
let unlisten: (() => void) | undefined;
let cancelled = false;
(async () => {
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
const payload = event.payload;
if (payload.type === "leave") {
setDragOver(false);
return;
}
if (payload.type === "enter" || payload.type === "over") {
setDragOver(isDropTarget(paneRef.current, payload.position));
return;
}
if (payload.type !== "drop") return;
setDragOver(false);
const verdict = classifyDrop(paneRef.current, payload.position);
// Aimed at this pane and refused anyway: say so. Nothing else would —
// the file just never appears in the listing.
if (verdict === "blocked") {
console.warn("[drop] refused: a dialog or overlay is open", payload.position);
useAppState.getState().pushToast(DROP_BLOCKED_TOAST);
return;
}
if (verdict !== "accept") return;
const paths = payload.paths ?? [];
if (paths.length === 0) return;
await uploadPaths(paths);
});
if (cancelled) un();
else unlisten = un;
})();
return () => {
cancelled = true;
unlisten?.();
};
}, [running, uploadPaths]);
const breadcrumbs =
currentPath === "/"
? [{ label: "/", path: "/" }]
@@ -324,10 +285,10 @@ export default function FilesTab({ project }: Props) {
/**
* The live region's text. One region, always mounted, filled and emptied
* a `role="status"` node that is *inserted* already carrying its text is
* frequently not announced at all, which is how "uploading 3 items…" and
* every completion notice used to go by in silence.
* frequently not announced at all, which is how every completion notice used
* to go by in silence.
*/
const liveText = busy ? busy : (completed ?? "");
const liveText = completed ?? "";
return (
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
@@ -361,8 +322,15 @@ export default function FilesTab({ project }: Props) {
>
New folder
</Button>
<Button onClick={uploadFile} className="ml-1">
Upload file
{/* The file picker this opens belongs to Rust, not to the webview so
this file imports no dialog plugin and never composes a host path.
`uploadFiles` takes no argument for the same reason. */}
<Button
onClick={() => void uploadFiles()}
disabled={uploading}
className="ml-1"
>
{uploading ? "Uploading…" : "Upload…"}
</Button>
<Button onClick={refresh} disabled={loading} className="ml-1">
Refresh
@@ -372,9 +340,9 @@ export default function FilesTab({ project }: Props) {
<div className="flex-1 overflow-y-auto min-h-0">
{/* The one failure that stays inline: it explains why the grid below is
empty, it is in context, and there are no rows for it to scroll
behind. Every *transient* failure upload, rename, mkdir,
save-to-host goes to `ToastHost` instead, which is above
the file viewer's overlay and does not scroll away. */}
behind. Every *transient* failure rename, new folder goes to
`ToastHost` instead, which is above the file viewer's overlay and
does not scroll away. */}
{error && (
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
{error}
@@ -560,16 +528,32 @@ export default function FilesTab({ project }: Props) {
>
Rename
</Button>
{/* Folders have no single-file equivalent a
recursive download is what "Back up container" is
for, and offering one here would mean rebuilding
the tree-walking this pane deliberately does not
do. */}
{!entry.is_directory && (
<Button
aria-label={`Save to host${entry.name}`}
aria-label={`Save to host — ${entry.name}`}
className="ml-1"
// Only this row: a large file can take a while,
// and there is no reason the rest of the pane
// should go dead while it is written.
disabled={savingPaths.has(entry.path)}
onClick={(e) => {
e.stopPropagation();
downloadFile(entry);
void saveToHost(entry);
}}
// A double-click is its own event, and
// `onClick`'s `stopPropagation` says nothing
// about it — so an impatient double-click here
// reached the row's `onDoubleClick` and dropped
// the viewer modal over the pane, on top of the
// save dialog the backend had just opened.
onDoubleClick={(e) => e.stopPropagation()}
>
Save to host
{savingPaths.has(entry.path) ? "Saving…" : "Save to host…"}
</Button>
)}
</>
@@ -594,34 +578,11 @@ export default function FilesTab({ project }: Props) {
)}
</div>
{/* Drop hint. Purely decorative the native listener is what accepts the
drop, so this must never intercept pointer events. */}
{dragOver && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center border-2 border-dashed border-[var(--accent)] bg-[var(--bg-primary)]/70"
>
<span className="text-[13px] font-medium text-[var(--text-primary)]">
Drop files into {currentPath}
</span>
</div>
)}
{conflict && (
<OverwriteConfirmModal
name={conflict.name}
directory={conflict.directory}
remaining={conflict.remaining}
onChoose={resolveConflict}
/>
)}
{viewing && (
<FileViewerModal
projectId={project.id}
entry={viewing}
onClose={() => setViewing(null)}
onSaveToHost={downloadFile}
/>
)}
</div>
@@ -1,73 +0,0 @@
import type { OverwriteChoice } from "../../../lib/uploadErrors";
import Button from "../../ui/Button";
import Modal from "../../ui/Modal";
interface Props {
/** Bare name of the file that is already there. */
name: string;
/** Container directory it is going into. */
directory: string;
/** How many more files are queued behind this one. */
remaining: number;
onChoose: (choice: OverwriteChoice) => void;
}
/**
* "That name is taken — replace it?"
*
* This exists because the backend stopped overwriting silently, and a raw
* error string would have been a worse answer than the old silent clobber: it
* tells the user their drop failed without telling them it *can* succeed. The
* dialog names the file and the directory, because a drop is aimed with a
* mouse and "notes.txt" alone does not say which `notes.txt`.
*
* The blanket answers only appear when there is something to apply them to a
* single-file drop with "Replace all" on it invites the reflex of clicking the
* widest button for no benefit.
*
* Dismissing (Escape, , click-outside) is a **skip**, never a replace: the
* destructive answer has to be chosen explicitly.
*/
export default function OverwriteConfirmModal({ name, directory, remaining, onChoose }: Props) {
const footer = (
<>
{remaining > 0 && (
<>
<Button size="md" onClick={() => onChoose("skip-all")}>
Skip all
</Button>
<Button size="md" onClick={() => onChoose("replace-all")}>
Replace all
</Button>
</>
)}
<Button size="md" onClick={() => onChoose("skip")}>
Skip
</Button>
<Button size="md" variant="primary" onClick={() => onChoose("replace")}>
Replace
</Button>
</>
);
return (
<Modal
title="A file with that name is already there"
description={directory}
onClose={() => onChoose("skip")}
footer={footer}
widthClassName="w-[30rem]"
>
<p className="text-[13px] text-[var(--text-primary)]">
<span className="font-mono">{name}</span> already exists in{" "}
<span className="font-mono">{directory}</span>. Replacing it overwrites the container's
copy, and that cannot be undone from here.
</p>
{remaining > 0 && (
<p className="mt-2 text-xs text-[var(--text-secondary)]">
{remaining} more file{remaining === 1 ? "" : "s"} still to upload.
</p>
)}
</Modal>
);
}
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { projectRemovalIsClean } from "../../../lib/types";
import { useAppState } from "../../../store/appState";
import { useProjectActions } from "../../../hooks/useProjectActions";
import { useProjects } from "../../../hooks/useProjects";
@@ -18,6 +19,7 @@ import ConfigTab from "./ConfigTab";
import FilesTab from "./FilesTab";
import BrowserTab from "./BrowserTab";
import { formatUptime } from "./format";
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
const TABS = [
{ id: "overview", label: "Overview" },
@@ -282,7 +284,25 @@ export default function ProjectHome({ projectId, active }: Props) {
onConfirm={async () => {
setConfirmRemove(false);
try {
await remove(project.id);
const report = await remove(project.id);
if (!projectRemovalIsClean(report)) {
const verb = leftoverVerb(report);
if (report.retry_scheduled) {
useAppState.getState().pushToast({
kind: "info",
message: `${project.name}” was removed, but Triple-C could not confirm all its Docker resources were removed`,
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed. It will check again the next time it starts.`,
});
} else {
// The pending-cleanup record itself failed to save — no
// retry will happen, so this must not promise one.
useAppState.getState().pushToast({
kind: "error",
message: `${project.name}” was removed, but Triple-C could not confirm its Docker resources were removed`,
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed, and could not record this for a retry. You may need to remove ${leftoverPronoun(report)} manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).`,
});
}
}
} catch (e) {
useAppState.getState().pushToast({
kind: "error",
@@ -109,7 +109,16 @@ export default function RuntimeSection({
<ConfigGroup
title="Claude Code settings"
description="Per-project CLI behaviour. Anything left on Global follows Settings; Off overrides a global On."
description={
"Per-project CLI behaviour. Anything left on Global follows Settings; " +
"Off overrides a global On. Changing any of these recreates the container, " +
"which commits a new image layer — so flipping switches repeatedly costs disk. " +
"Turning TUI mode, Effort level, Focus mode or Session recap back to Global " +
"also needs the base image updated first: those four are cleared by removing a " +
"key, and an older image's startup script ignores the instruction to remove it. " +
"Update the base image from Overview. TUI mode, Effort level and Focus mode " +
"visibly refuse to switch off until you do; Session recap just stays off silently."
}
>
<ClaudeCodeSettingsEditor
scope="project"
@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import WorkspaceSection from "./WorkspaceSection";
import type { Project } from "../../../../lib/types";
// The Browse button is the OS folder picker.
const open = vi.fn();
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: (...args: unknown[]) => open(...args),
}));
const baseProject: Project = {
id: "p1",
name: "api-server",
paths: [{ host_path: "/src/api", mount_name: "api" }],
container_id: null,
status: "stopped",
backend: "anthropic",
bedrock_config: null,
ollama_config: null,
llamacpp_config: null,
openai_compatible_config: null,
allow_docker_access: false,
sandbox_mode_enabled: true,
mission_control_enabled: false,
auth_bridge_enabled: false,
browser_view_enabled: false,
vpn_support_enabled: false,
use_shared_auth_token: true,
full_permissions: false,
permission_mode: null,
ssh_key_path: null,
ca_cert_path: null,
git_token: null,
git_user_name: null,
git_user_email: null,
custom_env_vars: [],
port_mappings: [],
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const save = vi.fn().mockResolvedValue(true);
function renderSection(over: Partial<Project> = {}, disabled = false) {
return render(
<WorkspaceSection
project={{ ...baseProject, ...over }}
save={save}
disabled={disabled}
/>,
);
}
/** Every folder list this component has sent to `update_project`. */
function savedLists() {
return save.mock.calls
.filter(([patch]) => "paths" in patch)
.map(([patch]) => patch.paths);
}
describe("WorkspaceSection — the blank row is never stored", () => {
beforeEach(() => vi.clearAllMocks());
/**
* The bug this file exists for. `create_container` mounts every stored row
* unfiltered, so a persisted `{host_path: "", mount_name: ""}` becomes
* `{"Target": "/workspace/", "Source": ""}` and the daemon refuses the whole
* container with `field Source must not be empty` the project can never be
* started or recreated again. Click "+ Add folder", blur a field, and it is
* bricked.
*/
it("drops the placeholder row when a real edit is saved", () => {
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
const hostPath = screen.getByLabelText("Folder 1 host path");
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
fireEvent.blur(hostPath);
expect(save).toHaveBeenCalledTimes(1);
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
});
it("drops it when Browse fills a different row in", async () => {
open.mockResolvedValueOnce("/src/api-v2");
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
// The picker is awaited inside the handler, so the state update that
// follows it lands outside the click.
await act(async () => {
fireEvent.click(screen.getAllByRole("button", { name: "Browse" })[0]);
});
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
});
it("drops it when a row is removed", () => {
renderSection({
paths: [
{ host_path: "/src/api", mount_name: "api" },
{ host_path: "/src/web", mount_name: "web" },
],
});
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
fireEvent.click(screen.getByRole("button", { name: "Remove folder 2" }));
expect(savedLists()[0]).toEqual([{ host_path: "/src/api", mount_name: "api" }]);
});
it("never sends a row with an empty host path, whatever the route", () => {
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
const hostPath = screen.getByLabelText("Folder 1 host path");
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
fireEvent.blur(hostPath);
for (const list of savedLists()) {
for (const row of list) {
expect(row.host_path).not.toBe("");
expect(row.mount_name).not.toBe("");
}
}
});
});
describe("WorkspaceSection — what a blur is allowed to save", () => {
beforeEach(() => vi.clearAllMocks());
/**
* Both inputs save on blur, so tabbing from the host path to the mount name
* fires a save with the name still empty which `update_project` refuses,
* turning an ordinary keystroke into an error toast.
*/
it("holds a half-filled row back until it is complete", () => {
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
const newHostPath = screen.getByLabelText("Folder 2 host path");
fireEvent.change(newHostPath, { target: { value: "/src/web" } });
fireEvent.blur(newHostPath);
expect(save).not.toHaveBeenCalled();
const newMountName = screen.getByLabelText("Folder 2 mount name");
fireEvent.change(newMountName, { target: { value: "web" } });
fireEvent.blur(newMountName);
expect(savedLists()[0]).toEqual([
{ host_path: "/src/api", mount_name: "api" },
{ host_path: "/src/web", mount_name: "web" },
]);
});
/**
* Blurring out of an untouched field is not an edit. Saving anyway would
* round-trip the filtered list through `project` and take the empty row away
* while the user was still filling it in.
*/
it("saves nothing when the blur changed nothing", () => {
renderSection();
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
fireEvent.blur(screen.getByLabelText("Folder 1 mount name"));
expect(save).not.toHaveBeenCalled();
expect(screen.getByLabelText("Folder 2 host path")).toBeTruthy();
});
it("still saves a rename, which does not go through the folder list", () => {
renderSection();
const name = screen.getByDisplayValue("api-server");
fireEvent.change(name, { target: { value: "api-v2" } });
fireEvent.blur(name);
expect(save).toHaveBeenCalledWith({ name: "api-v2" });
});
});
@@ -10,6 +10,14 @@ interface Props {
disabled: boolean;
}
/** Whether two folder lists are the same rows in the same order. */
function sameRows(a: ProjectPath[], b: ProjectPath[]): boolean {
return (
a.length === b.length &&
a.every((row, i) => row.host_path === b[i].host_path && row.mount_name === b[i].mount_name)
);
}
export default function WorkspaceSection({ project, save, disabled }: Props) {
const [name, setName] = useState(project.name);
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
@@ -19,6 +27,27 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
setPaths(project.paths ?? []);
}, [project]);
/**
* Persist a folder list, minus the rows that are only in it because the UI
* put them there.
*
* **The blank row must never reach the store.** "+ Add folder" inserts
* `{host_path: "", mount_name: ""}` deliberately, and `create_container`
* mounts every stored row unfiltered a stored blank one becomes
* `{"Target": "/workspace/", "Source": ""}`, which the daemon rejects with
* `field Source must not be empty`. The project then cannot be started or
* recreated at all, from a click and a blur. `AddProjectDialog` has always
* filtered this; this section computed the filtered list and then saved the
* unfiltered one.
*
* Every save goes through here for that reason Browse and Remove write the
* list too, and either can be holding a blank row from an earlier click.
*/
const persist = (rows: ProjectPath[]) => {
const filled = rows.filter((p) => p.host_path.trim() || p.mount_name.trim());
return save({ paths: filled });
};
/**
* Save only when every row is fully filled in.
*
@@ -27,12 +56,18 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
* a half-filled row is refused so the unconditional save turned an ordinary
* keystroke into an error toast. A blank row is *not* incomplete: the
* "+ Add folder" button adds one deliberately, and it is dropped on save.
*
* A blur that changed nothing saves nothing, which is what keeps the blank
* row on screen while it is being filled in: persisting the filtered list
* would round-trip through `project` and take the empty row away under the
* cursor.
*/
const saveIfComplete = () => {
const filled = paths.filter((p) => p.host_path.trim() || p.mount_name.trim());
const halfFilled = filled.some((p) => !p.host_path.trim() || !p.mount_name.trim());
if (halfFilled) return;
return save({ paths });
if (sameRows(filled, project.paths ?? [])) return;
return persist(paths);
};
return (
@@ -106,7 +141,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
mount_name: updated[i].mount_name || basename,
};
setPaths(updated);
save({ paths: updated });
persist(updated);
}
}}
>
@@ -137,7 +172,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
onClick={() => {
const updated = paths.filter((_, j) => j !== i);
setPaths(updated);
save({ paths: updated });
persist(updated);
}}
>
Remove
@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { describeLeftovers, leftoverVerb } from "./removalReport";
import { projectRemovalIsClean } from "../../../lib/types";
import type { ProjectRemovalReport } from "../../../lib/types";
function report(overrides: Partial<ProjectRemovalReport> = {}): ProjectRemovalReport {
return {
container: null,
image: null,
volumes: [],
retry_scheduled: false,
...overrides,
};
}
describe("projectRemovalIsClean", () => {
it("is true only when nothing survived", () => {
expect(projectRemovalIsClean(report())).toBe(true);
expect(projectRemovalIsClean(report({ container: "triple-c-abc" }))).toBe(false);
expect(projectRemovalIsClean(report({ image: "triple-c-snapshot-abc:latest" }))).toBe(false);
expect(projectRemovalIsClean(report({ volumes: ["triple-c-home-abc"] }))).toBe(false);
});
});
describe("describeLeftovers", () => {
it("names each kind of leftover", () => {
expect(describeLeftovers(report({ container: "triple-c-abc" }))).toBe("its container");
expect(describeLeftovers(report({ image: "x" }))).toBe("its saved image");
expect(describeLeftovers(report({ volumes: ["v1"] }))).toBe("a volume");
expect(describeLeftovers(report({ volumes: ["v1", "v2"] }))).toBe("2 volumes");
});
it("joins multiple kinds together", () => {
expect(
describeLeftovers(report({ container: "triple-c-abc", image: "x", volumes: ["v1", "v2"] })),
).toBe("its container, its saved image, 2 volumes");
});
});
describe("leftoverVerb", () => {
it("is singular for exactly one leftover of any kind", () => {
expect(leftoverVerb(report({ container: "triple-c-abc" }))).toBe("was");
expect(leftoverVerb(report({ image: "x" }))).toBe("was");
expect(leftoverVerb(report({ volumes: ["v1"] }))).toBe("was");
});
it("is plural once more than one thing survived, including multiple volumes alone", () => {
expect(leftoverVerb(report({ container: "triple-c-abc", image: "x" }))).toBe("were");
expect(leftoverVerb(report({ volumes: ["v1", "v2"] }))).toBe("were");
});
});
@@ -0,0 +1,39 @@
import type { ProjectRemovalReport } from "../../../lib/types";
/**
* Names what a `ProjectRemovalReport` says survived, for the leftover toast.
*
* Worded as "could not confirm" rather than "is still on disk": the same
* report shape covers a genuine leftover (a locked volume) and a daemon that
* was simply unreachable at the time, in which case nothing was ever created
* and there is nothing to find asserting certainty either way would be
* wrong in one of those cases.
*/
export function describeLeftovers(report: ProjectRemovalReport): string {
const parts: string[] = [];
if (report.container) parts.push("its container");
if (report.image) parts.push("its saved image");
if (report.volumes.length === 1) parts.push("a volume");
else if (report.volumes.length > 1) parts.push(`${report.volumes.length} volumes`);
return parts.join(", ");
}
/** How many distinct things `describeLeftovers` is describing a container
* and an image each count as one, however many volumes are named. Shared by
* `leftoverVerb` and `leftoverPronoun` so the two can never disagree about
* singular vs. plural. */
function leftoverCount(report: ProjectRemovalReport): number {
return (report.container ? 1 : 0) + (report.image ? 1 : 0) + report.volumes.length;
}
/** Verb agreement for `describeLeftovers`'s output "its container" needs
* "was", "its container, a volume" needs "were". */
export function leftoverVerb(report: ProjectRemovalReport): "was" | "were" {
return leftoverCount(report) === 1 ? "was" : "were";
}
/** Pronoun agreement for referring back to `describeLeftovers`'s output
* "remove it manually" for one thing, "remove them manually" for more. */
export function leftoverPronoun(report: ProjectRemovalReport): "it" | "them" {
return leftoverCount(report) === 1 ? "it" : "them";
}
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import ExportSettingsModal from "./ExportSettingsModal";
const exportSettings = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
exportSettings: (password: string) => exportSettings(password),
}));
beforeEach(() => {
vi.clearAllMocks();
});
function fillPasswords(password: string, confirm: string) {
fireEvent.change(screen.getByLabelText("Password"), { target: { value: password } });
fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: confirm } });
}
describe("ExportSettingsModal", () => {
it("keeps the submit button disabled until the passwords are long enough and match", () => {
render(<ExportSettingsModal onClose={vi.fn()} />);
const submit = screen.getByRole("button", { name: /choose where to save/i });
expect(submit).toBeDisabled();
fillPasswords("short", "short");
expect(submit).toBeDisabled();
expect(screen.getByText(/use at least 8 characters/i)).toBeInTheDocument();
fillPasswords("longenoughpassword", "different");
expect(submit).toBeDisabled();
expect(screen.getByText(/don't match/i)).toBeInTheDocument();
fillPasswords("longenoughpassword", "longenoughpassword");
expect(submit).not.toBeDisabled();
});
it("exports with the entered password and shows success", async () => {
exportSettings.mockResolvedValue(true);
render(<ExportSettingsModal onClose={vi.fn()} />);
fillPasswords("longenoughpassword", "longenoughpassword");
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
await waitFor(() => expect(exportSettings).toHaveBeenCalledWith("longenoughpassword"));
await waitFor(() => expect(screen.getByText(/settings exported/i)).toBeInTheDocument());
});
it("closes quietly when the save dialog is dismissed", async () => {
exportSettings.mockResolvedValue(false);
const onClose = vi.fn();
render(<ExportSettingsModal onClose={onClose} />);
fillPasswords("longenoughpassword", "longenoughpassword");
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(screen.queryByText(/settings exported/i)).not.toBeInTheDocument();
});
it("shows an error rather than closing when the export fails", async () => {
exportSettings.mockRejectedValue("Disk is full");
const onClose = vi.fn();
render(<ExportSettingsModal onClose={onClose} />);
fillPasswords("longenoughpassword", "longenoughpassword");
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
await waitFor(() => expect(screen.getByText("Disk is full")).toBeInTheDocument());
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
import { useState } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Field, { inputClass } from "../ui/Field";
import { exportSettings } from "../../lib/tauri-commands";
interface Props {
onClose: () => void;
}
const MIN_PASSWORD_LENGTH = 8;
/**
* Password entry for exporting global settings. The save dialog itself opens
* from Rust once a password is confirmed here see the doc comment on
* `commands::settings_export_commands` for why the host path never
* round-trips through this component.
*/
export default function ExportSettingsModal({ onClose }: Props) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const mismatch = confirmPassword.length > 0 && password !== confirmPassword;
const tooShort = password.length > 0 && password.length < MIN_PASSWORD_LENGTH;
const canSubmit = password.length >= MIN_PASSWORD_LENGTH && password === confirmPassword;
const handleExport = async () => {
setError(null);
setBusy(true);
try {
const saved = await exportSettings(password);
if (saved) setDone(true);
// `false` means the save dialog was dismissed — close quietly, same as
// if the user had cancelled the modal itself.
else onClose();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<Modal
title="Export settings"
description="Saves your global settings and any stored credentials (a shared Claude login, gateway keys) to one encrypted file. Project-specific settings and container data are not included."
widthClassName="w-[28rem]"
dismissible={!busy}
onClose={onClose}
footer={
done ? (
<Button size="md" variant="primary" onClick={onClose}>
Done
</Button>
) : (
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
size="md"
variant="primary"
onClick={() => void handleExport()}
disabled={!canSubmit || busy}
>
{busy ? "Exporting…" : "Choose where to save…"}
</Button>
</>
)
}
>
{done ? (
<p className="text-[13px] text-[var(--success)]">
Settings exported. Keep the password somewhere safe there is no way to recover
the file without it.
</p>
) : (
<div className="space-y-3">
<Field label="Password" hint={`At least ${MIN_PASSWORD_LENGTH} characters. You'll need this exact password to import the file later.`}>
{(id) => (
<input
id={id}
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy}
className={inputClass}
/>
)}
</Field>
<Field label="Confirm password">
{(id) => (
<input
id={id}
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={busy}
className={inputClass}
/>
)}
</Field>
{tooShort && (
<p className="text-xs text-[var(--error)]">
Use at least {MIN_PASSWORD_LENGTH} characters.
</p>
)}
{mismatch && <p className="text-xs text-[var(--error)]">Passwords don't match.</p>}
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
</div>
)}
</Modal>
);
}
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import ImportSettingsModal from "./ImportSettingsModal";
import type { AppSettings, SettingsImportOutcome, SettingsImportPreview } from "../../lib/types";
const previewSettingsImport = vi.fn();
const applySettingsImport = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
previewSettingsImport: (password: string) => previewSettingsImport(password),
applySettingsImport: (password: string) => applySettingsImport(password),
}));
beforeEach(() => {
vi.clearAllMocks();
});
const samplePreview: SettingsImportPreview = {
exported_at: "2026-08-27T00:00:00Z",
app_version: "0.4.14",
custom_env_var_count: 2,
gateway_model_count: 0,
has_claude_code_settings: false,
has_claude_oauth_token: true,
has_gateway_api_key: false,
has_gateway_master_key: false,
has_web_terminal_access_token: false,
enables_web_terminal: false,
ollama_base_url: null,
llamacpp_base_url: null,
openai_compatible_base_url: null,
gateway_api_base: null,
image_source: "registry",
custom_image_name: null,
};
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
return { settings, secret_restore_warnings: secretRestoreWarnings };
}
describe("ImportSettingsModal", () => {
it("keeps 'Choose file' disabled until a password is entered", () => {
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
expect(screen.getByRole("button", { name: /choose file/i })).toBeDisabled();
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
expect(screen.getByRole("button", { name: /choose file/i })).not.toBeDisabled();
});
it("shows the preview and confirms with the same password used to open it", async () => {
previewSettingsImport.mockResolvedValue(samplePreview);
applySettingsImport.mockResolvedValue(outcome({} as AppSettings));
const onImported = vi.fn();
render(<ImportSettingsModal onClose={vi.fn()} onImported={onImported} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await waitFor(() => expect(previewSettingsImport).toHaveBeenCalledWith("hunter2"));
expect(await screen.findByText(/2 global custom env vars/i)).toBeInTheDocument();
expect(screen.getByText(/your shared claude login/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
await waitFor(() => expect(applySettingsImport).toHaveBeenCalledWith("hunter2"));
await waitFor(() => expect(onImported).toHaveBeenCalledWith({}));
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
});
it("shows a distinct warning when the import would enable the web terminal", async () => {
previewSettingsImport.mockResolvedValue({ ...samplePreview, enables_web_terminal: true });
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
});
it("warns about a custom Docker image every time, not just on change", async () => {
previewSettingsImport.mockResolvedValue({
...samplePreview,
image_source: "custom",
custom_image_name: "ghcr.io/attacker/triple-c:latest",
});
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
expect(
await screen.findByText(/custom docker image: ghcr\.io\/attacker\/triple-c:latest/i),
).toBeInTheDocument();
});
it("shows a secret-restore warning alongside success rather than hiding it", async () => {
previewSettingsImport.mockResolvedValue(samplePreview);
applySettingsImport.mockResolvedValue(
outcome({} as AppSettings, ["Could not restore the gateway master key: keychain locked"]),
);
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await screen.findByText(/2 global custom env vars/i);
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
expect(await screen.findByText(/could not restore the gateway master key/i)).toBeInTheDocument();
});
it("closes quietly when the file picker is dismissed", async () => {
previewSettingsImport.mockResolvedValue(null);
const onClose = vi.fn();
render(<ImportSettingsModal onClose={onClose} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("shows an error when the password is wrong rather than a blank preview", async () => {
previewSettingsImport.mockRejectedValue("Wrong password, or the file is corrupted.");
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "wrong" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
expect(await screen.findByText(/wrong password, or the file is corrupted/i)).toBeInTheDocument();
});
it("shows an error if applying the import fails, without claiming success", async () => {
previewSettingsImport.mockResolvedValue(samplePreview);
applySettingsImport.mockRejectedValue("Keychain write failed");
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await screen.findByText(/2 global custom env vars/i);
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
expect(await screen.findByText("Keychain write failed")).toBeInTheDocument();
expect(screen.queryByText(/settings imported/i)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,166 @@
import { useState } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Field, { inputClass } from "../ui/Field";
import { applySettingsImport, previewSettingsImport } from "../../lib/tauri-commands";
import { describeImport, describeImportWarnings } from "../../lib/settingsImportPreview";
import type { AppSettings, SettingsImportPreview } from "../../lib/types";
interface Props {
onClose: () => void;
/** Fired once the import is actually applied, so the caller can refresh
* whatever reads settings from the store. */
onImported: (settings: AppSettings) => void;
}
/**
* Two phases: enter the password and pick the file (backend resolves the
* file dialog itself see `commands::settings_export_commands`), then
* confirm a preview before anything is actually applied. The same password
* is reused for the second call rather than asking again; nothing about
* that call needs a fresh secret; the backend just doesn't cache the
* *decrypted payload* between the two.
*/
export default function ImportSettingsModal({ onClose, onImported }: Props) {
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<SettingsImportPreview | null>(null);
const [applied, setApplied] = useState(false);
const [secretWarnings, setSecretWarnings] = useState<string[]>([]);
const handleChooseFile = async () => {
setError(null);
setBusy(true);
try {
const result = await previewSettingsImport(password);
if (result) setPreview(result);
else onClose(); // File picker dismissed.
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const handleConfirm = async () => {
setError(null);
setBusy(true);
try {
const outcome = await applySettingsImport(password);
setApplied(true);
setSecretWarnings(outcome.secret_restore_warnings);
onImported(outcome.settings);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<Modal
title="Import settings"
description={
preview
? "Review what this file will change before applying it."
: "Choose a Triple-C settings export and enter the password it was created with."
}
widthClassName="w-[28rem]"
dismissible={!busy}
onClose={onClose}
footer={
applied ? (
<Button size="md" variant="primary" onClick={onClose}>
Done
</Button>
) : preview ? (
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={() => void handleConfirm()} disabled={busy}>
{busy ? "Importing…" : "Import"}
</Button>
</>
) : (
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
size="md"
variant="primary"
onClick={() => void handleChooseFile()}
disabled={!password || busy}
>
{busy ? "Opening…" : "Choose file…"}
</Button>
</>
)
}
>
{applied ? (
<div className="space-y-2">
<p className="text-[13px] text-[var(--success)]">Settings imported.</p>
{secretWarnings.map((warning) => (
<p
key={warning}
className="px-2.5 py-2 text-xs text-[var(--error)] bg-[var(--error-muted)] border border-[var(--error)]/40 rounded-[var(--radius-control)] leading-snug"
>
{warning}
</p>
))}
</div>
) : preview ? (
<div className="space-y-3">
<p className="text-xs text-[var(--text-secondary)]">
Exported {new Date(preview.exported_at).toLocaleString()} from Triple-C{" "}
{preview.app_version}.
</p>
{/* Warnings render before the replace list, deliberately: the list
* below can run long, and the one thing here that most needs to
* stay above the fold while scrolling is "this turns on a
* network-listening service" or "this runs a different image"
* not a bullet buried among ordinary settings. */}
{describeImportWarnings(preview).map((warning) => (
<p
key={warning}
className="px-2.5 py-2 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] leading-snug break-all"
>
{warning}
</p>
))}
<div>
<p className="text-[13px] font-medium text-[var(--text-primary)]">This will replace:</p>
<ul className="mt-1 list-disc pl-4 text-[13px] text-[var(--text-secondary)] space-y-0.5">
{describeImport(preview).map((item) => (
<li key={item} className="break-all">
{item}
</li>
))}
</ul>
</div>
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
</div>
) : (
<div className="space-y-3">
<Field label="Password">
{(id) => (
<input
id={id}
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy}
className={inputClass}
/>
)}
</Field>
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
</div>
)}
</Modal>
);
}
+38 -1
View File
@@ -19,9 +19,11 @@ import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
import CertificateSettings from "./CertificateSettings";
import ExportSettingsModal from "./ExportSettingsModal";
import ImportSettingsModal from "./ImportSettingsModal";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
const { appSettings, saveSettings, setAppSettings } = useSettings();
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
@@ -33,6 +35,8 @@ export default function SettingsPanel() {
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
// Sync local state when appSettings change
useEffect(() => {
@@ -269,6 +273,39 @@ export default function SettingsPanel() {
</div>
</AccordionSection>
<AccordionSection id="backup" title="Backup" defaultOpen={false}>
<div className="space-y-2">
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Export your global settings and stored credentials (a shared Claude login,
gateway keys) to one password-encrypted file, or restore them on a new machine.
Project-specific settings and container data are never included.
</p>
<div className="flex gap-2">
<button
onClick={() => setShowExportModal(true)}
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Export settings
</button>
<button
onClick={() => setShowImportModal(true)}
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Import settings
</button>
</div>
</div>
</AccordionSection>
{showExportModal && <ExportSettingsModal onClose={() => setShowExportModal(false)} />}
{showImportModal && (
<ImportSettingsModal
onClose={() => setShowImportModal(false)}
onImported={(settings) => setAppSettings(settings)}
/>
)}
{showInstructionsModal && (
<ClaudeInstructionsModal
instructions={globalInstructions}
@@ -137,19 +137,36 @@ afterEach(() => {
});
describe("TerminalView — Shift+Enter", () => {
it("sends ESC+CR and nothing else in a Claude session", () => {
it("sends ESC+CR and cancels the keydown, so no bare CR follows", () => {
// **The cancel is the load-bearing half, and this test could not see it.**
//
// Returning `false` from xterm's custom key handler does not cancel the
// event: `_keyDown` returns before setting `_keyDownHandled`, so
// `_keyPress` still runs and emits a bare CR for Enter's charCode 13. In a
// real browser that submitted the prompt straight after inserting the
// newline. jsdom never synthesizes the follow-up keypress, so the old
// `expect(sent()).not.toContain("\r")` assertion below could not fail no
// matter what the code did — it was named for a behaviour it could not
// exercise.
//
// Asserting `defaultPrevented` pins the actual mechanism that stops the
// keypress, which is a property jsdom *can* observe.
const { container } = mountSession("claude");
fireEvent.keyDown(helperTextarea(container), {
const event = new KeyboardEvent("keydown", {
key: "Enter",
keyCode: 13,
shiftKey: true,
bubbles: true,
cancelable: true,
});
helperTextarea(container).dispatchEvent(event);
// The bytes `/terminal-setup` installs for every other editor.
expect(sent()).toEqual(["\x1b\r"]);
// And specifically not the bare CR that would have submitted the prompt.
expect(sent()).not.toContain("\r");
// Without this, the browser fires keypress and xterm submits.
expect(event.defaultPrevented).toBe(true);
});
it("leaves a plain Enter alone", () => {
+13 -1
View File
@@ -414,7 +414,19 @@ export default function TerminalView({ sessionId, active }: Props) {
sessionTypeRef.current === "claude"
) {
sendInput(sessionId, "\x1b\r");
return false; // xterm must not also send a bare CR, which submits
// **`preventDefault()` is what stops the submit, not the `return false`.**
//
// xterm's `_keyDown` returns the instant a custom handler says `false`
// — *before* it sets `_keyDownHandled` and before it cancels the event.
// `_keyPress` then checks that same flag, finds it still false, and
// emits a bare CR for Enter's charCode 13. So returning `false` alone
// sent ESC+CR *and* a submit: the newline was inserted and the
// half-written prompt went to Claude with a stray blank line in it.
// Cancelling the keydown is what stops the browser firing keypress at
// all. Verified in Chromium; jsdom never synthesizes the follow-up
// keypress, which is why the unit test could not see this.
event.preventDefault();
return false;
}
return true;
});
+10 -1
View File
@@ -47,7 +47,16 @@ function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
{tone.glyph}
</span>
<div className="flex-1 min-w-0">
<div className="text-[var(--text-primary)] break-words">{toast.message}</div>
{/* Clamped. A toast message is normally a sentence, but some of them
quote text a *container* wrote and this card is `z-[60]`, above
every modal, with its dismiss button at the top. An unclamped
message of a few kilobytes is a card taller than the viewport whose
has been pushed off-screen, i.e. an unclosable overlay. The
`detail` block below has always had `max-h-40 overflow-auto`; this
half did not. */}
<div className="text-[var(--text-primary)] break-words max-h-40 overflow-y-auto">
{toast.message}
</div>
{toast.detail && (
<>
<button
+290 -354
View File
@@ -4,18 +4,18 @@ import { useFileManager } from "./useFileManager";
import type { FileEntry } from "../lib/types";
const listContainerFiles = vi.fn();
const downloadContainerFile = vi.fn();
const uploadFileToContainer = vi.fn();
const renameContainerPath = vi.fn();
const createContainerDirectory = vi.fn();
const uploadFilesToContainer = vi.fn();
const downloadContainerFile = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
downloadContainerFile: (p: string, c: string, h: string) => downloadContainerFile(p, c, h),
uploadFileToContainer: (...args: unknown[]) => uploadFileToContainer(...args),
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
readContainerFile: vi.fn(),
}));
@@ -35,13 +35,6 @@ const toastText = () =>
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
.join("\n");
const save = vi.fn();
const openDialog = vi.fn();
vi.mock("@tauri-apps/plugin-dialog", () => ({
save: (opts: unknown) => save(opts),
open: (opts: unknown) => openDialog(opts),
}));
const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
name,
path: `/workspace/${name}`,
@@ -56,6 +49,8 @@ const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
beforeEach(() => {
vi.clearAllMocks();
listContainerFiles.mockResolvedValue([file("a.txt")]);
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
downloadContainerFile.mockResolvedValue(0);
});
describe("useFileManager navigation", () => {
@@ -100,48 +95,6 @@ describe("useFileManager navigation", () => {
});
});
describe("useFileManager uploads", () => {
it("uploads every dropped path into the current directory, then re-lists once", async () => {
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/workspace/app");
});
listContainerFiles.mockClear();
await act(async () => {
await result.current.uploadPaths(["/host/a.png", "/host/b.png"]);
});
expect(uploadFileToContainer).toHaveBeenNthCalledWith(1, "p1", "/host/a.png", "/workspace/app");
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.png", "/workspace/app");
// One refresh for the batch, not one per file.
expect(listContainerFiles).toHaveBeenCalledTimes(1);
});
it("reports a failed upload but still lists whatever did land", async () => {
uploadFileToContainer.mockResolvedValueOnce(undefined);
uploadFileToContainer.mockRejectedValueOnce("File too large to upload (900 MB; limit 256 MB)");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadPaths(["/host/ok.txt", "/host/huge.bin"]);
});
// Inline `error` is reserved for the listing failure the user can see in
// context; a failed upload goes where it cannot scroll away.
expect(result.current.error).toBeNull();
expect(toastText()).toContain("too large");
expect(listContainerFiles).toHaveBeenCalled();
});
it("does nothing when the file picker is cancelled", async () => {
openDialog.mockResolvedValue(null);
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadFile();
});
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
});
describe("useFileManager rename and mkdir", () => {
it("sends the bare new name, never a path, and re-lists on success", async () => {
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
@@ -204,47 +157,22 @@ describe("useFileManager rename and mkdir", () => {
});
});
describe("useFileManager save to host", () => {
it("writes to the path the user picked", async () => {
save.mockResolvedValue("/host/Downloads/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.downloadFile(file("a.txt"));
});
expect(downloadContainerFile).toHaveBeenCalledWith(
"p1",
"/workspace/a.txt",
"/host/Downloads/a.txt",
);
});
it("reports a refused download — a directory is no longer written as garbage", async () => {
save.mockResolvedValue("/host/Downloads/src");
downloadContainerFile.mockRejectedValue("/workspace/src is a folder — download its files individually");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.downloadFile(file("src", { is_directory: true }));
});
expect(toastText()).toContain("is a folder");
});
});
describe("useFileManager stays where the user is", () => {
it("does not drag the pane back when the user navigates away mid-upload", async () => {
it("does not drag the pane back when the user navigates away mid-operation", async () => {
// The closure captured `/workspace`; the user is in `/workspace/src` by the
// time the copy finishes. Re-listing the *captured* path is what used to
// time the rename finishes. Re-listing the *captured* path is what used to
// yank them out of the directory they had walked into.
let failUpload: (reason: unknown) => void = () => {};
let failRename: (reason: unknown) => void = () => {};
// `Once`, deliberately: `clearAllMocks` clears calls but not
// implementations, so a never-settling one would hang every test after it.
uploadFileToContainer.mockImplementationOnce(
() => new Promise((_resolve, reject) => { failUpload = reject; }),
renameContainerPath.mockImplementationOnce(
() => new Promise((_resolve, reject) => { failRename = reject; }),
);
const { result } = renderHook(() => useFileManager("p1"));
let upload!: Promise<void>;
let rename!: Promise<boolean>;
await act(async () => {
upload = result.current.uploadPaths(["/host/big.bin"]);
rename = result.current.renameEntry(file("big.bin"), "bigger.bin");
await Promise.resolve();
});
@@ -255,13 +183,13 @@ describe("useFileManager stays where the user is", () => {
listContainerFiles.mockClear();
await act(async () => {
failUpload("cp: no space left on device");
await upload;
failRename("mv: no space left on device");
await rename;
});
expect(result.current.currentPath).toBe("/workspace/src");
expect(result.current.entries.map((e) => e.name)).toEqual(["index.ts"]);
// No re-list of the directory the upload targeted…
// No re-list of the directory the rename targeted…
expect(listContainerFiles).not.toHaveBeenCalled();
// …and no failure text painted over the listing that replaced it.
expect(result.current.error).toBeNull();
@@ -269,13 +197,14 @@ describe("useFileManager stays where the user is", () => {
});
it("re-lists when the user stayed put, which is the ordinary case", async () => {
renameContainerPath.mockResolvedValue("/workspace/b.txt");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/workspace");
});
listContainerFiles.mockClear();
await act(async () => {
await result.current.uploadPaths(["/host/a.png"]);
await result.current.renameEntry(file("a.txt"), "b.txt");
});
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
});
@@ -316,248 +245,11 @@ describe("useFileManager stays where the user is", () => {
await result.current.navigate("/root");
});
listContainerFiles.mockClear();
// The pane never left /workspace, so an upload started now targets it.
// The pane never left /workspace, so a new folder made now lands there.
await act(async () => {
await result.current.uploadPaths(["/host/a.png"]);
await result.current.createFolder("new");
});
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.png", "/workspace");
});
});
describe("useFileManager overwrite prompt", () => {
const alreadyThere = "FILE_EXISTS: /workspace/a.txt already exists";
it("asks rather than clobbering, and replaces on demand", async () => {
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
uploadFileToContainer.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useFileManager("p1"));
let upload!: Promise<void>;
await act(async () => {
upload = result.current.uploadPaths(["/host/a.txt"]);
await Promise.resolve();
});
await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt"));
expect(result.current.conflict?.directory).toBe("/workspace");
// One file, so there is nothing for a blanket answer to apply to.
expect(result.current.conflict?.remaining).toBe(0);
await act(async () => {
result.current.resolveConflict("replace");
await upload;
});
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
expect(result.current.conflict).toBeNull();
});
it("skips without uploading anything when the user says so", async () => {
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
const { result } = renderHook(() => useFileManager("p1"));
let upload!: Promise<void>;
await act(async () => {
upload = result.current.uploadPaths(["/host/a.txt"]);
await Promise.resolve();
});
await waitFor(() => expect(result.current.conflict).not.toBeNull());
await act(async () => {
result.current.resolveConflict("skip");
await upload;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
// A skip is a choice, not a failure — nothing to report.
expect(toastText()).not.toContain("could not be uploaded");
});
it("asks once for a batch when the answer is Replace all", async () => {
uploadFileToContainer.mockRejectedValueOnce(alreadyThere);
uploadFileToContainer.mockResolvedValueOnce(undefined);
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/b.txt already exists");
uploadFileToContainer.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useFileManager("p1"));
let upload!: Promise<void>;
await act(async () => {
upload = result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]);
await Promise.resolve();
});
await waitFor(() => expect(result.current.conflict?.remaining).toBe(1));
await act(async () => {
result.current.resolveConflict("replace-all");
await upload;
});
expect(result.current.conflict).toBeNull();
expect(uploadFileToContainer).toHaveBeenNthCalledWith(4, "p1", "/host/b.txt", "/workspace", true);
});
it("leaves an unrelated failure alone — no prompt offering a button that cannot work", async () => {
uploadFileToContainer.mockRejectedValueOnce("File too large to upload (900 MB; limit 256 MB)");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadPaths(["/host/huge.bin"]);
});
expect(result.current.conflict).toBeNull();
expect(toastText()).toContain("too large");
});
});
/**
* The loop, end to end. The prompt only earns its place if the *batch* survives
* it: one answer, given once, has to leave every other file in the drop exactly
* where it would have been.
*/
describe("useFileManager overwrite prompt closes the loop", () => {
const clash = (name: string) => `FILE_EXISTS: /workspace/${name} already exists`;
/**
* Start an upload and wait for it to stop at the prompt, handing back the
* still-unsettled batch.
*
* Wrapped in an object on purpose: an `async` function that returned the
* promise itself would *adopt* it, so awaiting the helper would wait for the
* whole upload which cannot finish until the question is answered, which
* cannot happen until the helper returns. That deadlock looks exactly like
* the hang these tests exist to rule out.
*/
async function uploadUntilPrompt(
result: { current: ReturnType<typeof useFileManager> },
paths: string[],
): Promise<{ batch: Promise<void> }> {
let batch!: Promise<void>;
await act(async () => {
batch = result.current.uploadPaths(paths);
await Promise.resolve();
});
await waitFor(() => expect(result.current.conflict).not.toBeNull());
return { batch };
}
it("replaces the file that clashed and still uploads the rest of the batch", async () => {
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt")) // 1: a.txt, no overwrite
.mockResolvedValueOnce(undefined) // 2: a.txt, overwrite: true
.mockResolvedValueOnce(undefined); // 3: b.txt, no clash
const { result } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
expect(result.current.conflict?.name).toBe("a.txt");
expect(result.current.conflict?.remaining).toBe(1);
await act(async () => {
result.current.resolveConflict("replace");
await batch;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(3);
// The retry is the whole point: same file, same directory, overwrite on.
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
// …and "Replace" answered for *that* file only, so the next one is offered
// to the backend the safe way round.
expect(uploadFileToContainer).toHaveBeenNthCalledWith(3, "p1", "/host/b.txt", "/workspace");
expect(result.current.conflict).toBeNull();
expect(result.current.completed).toContain("Uploaded 2 items");
expect(toastText()).not.toContain("could not be uploaded");
});
it("moves on to the next file on Skip rather than ending the batch", async () => {
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt"))
.mockResolvedValueOnce(undefined); // b.txt still goes
const { result } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
await act(async () => {
result.current.resolveConflict("skip");
await batch;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.txt", "/workspace");
// Nothing was overwritten.
expect(uploadFileToContainer.mock.calls.some((c) => c[3] === true)).toBe(false);
expect(result.current.completed).toContain("skipped 1");
});
it("dismissing the dialog is a Skip — the batch carries on", async () => {
// `OverwriteConfirmModal` maps Escape / ✕ / click-outside onto this exact
// call, so a dismissal must not hang the loop or abort the drop.
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt"))
.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
await act(async () => {
// What `Modal`'s `onClose` produces.
result.current.resolveConflict("skip");
await batch;
});
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
expect(result.current.completed).toContain("Uploaded 1 item, skipped 1");
expect(result.current.busy).toBeNull();
});
it("answers every remaining clash with Skip all, asking only once", async () => {
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt"))
.mockRejectedValueOnce(clash("b.txt"))
.mockRejectedValueOnce(clash("c.txt"));
const { result } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt", "/host/c.txt"]);
expect(result.current.conflict?.remaining).toBe(2);
await act(async () => {
result.current.resolveConflict("skip-all");
await batch;
});
// Three attempts, no second prompt, nothing replaced.
expect(uploadFileToContainer).toHaveBeenCalledTimes(3);
expect(uploadFileToContainer.mock.calls.some((c) => c[3] === true)).toBe(false);
expect(result.current.conflict).toBeNull();
expect(result.current.completed).toContain("skipped 3");
});
it("puts a picked file through exactly the road a dropped one takes", async () => {
// The Upload button and the native drop listener are one routine —
// `uploadPaths` — so the prompt, the retry and the blanket answers cannot
// drift apart between them. This is that claim, from the picker end.
openDialog.mockResolvedValueOnce(["/host/a.txt", "/host/b.txt"]);
uploadFileToContainer
.mockRejectedValueOnce(clash("a.txt"))
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(undefined);
const { result } = renderHook(() => useFileManager("p1"));
let picked!: Promise<void>;
await act(async () => {
picked = result.current.uploadFile();
await Promise.resolve();
});
await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt"));
await act(async () => {
result.current.resolveConflict("replace");
await picked;
});
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
expect(uploadFileToContainer).toHaveBeenNthCalledWith(3, "p1", "/host/b.txt", "/workspace");
});
it("does not leave the batch waiting for an answer that can never arrive", async () => {
// The pane unmounted mid-prompt (tab closed, container stopped). The upload
// promise has to settle, or `busy` never clears and the loop leaks.
uploadFileToContainer.mockRejectedValueOnce(clash("a.txt"));
const { result, unmount } = renderHook(() => useFileManager("p1"));
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt"]);
unmount();
await expect(batch).resolves.toBeUndefined();
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace", "new");
});
});
@@ -567,8 +259,6 @@ describe("useFileManager overwrite prompt closes the loop", () => {
* reported that way is a sentence nobody reads.
*/
describe("useFileManager surfaces written refusals as prose", () => {
const hiddenFolder =
'".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.';
const outsideRoots =
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
@@ -576,10 +266,10 @@ describe("useFileManager surfaces written refusals as prose", () => {
const lastToast = () => pushToast.mock.calls.at(-1)?.[0];
it("puts the write-root refusal in the headline, not behind Details", async () => {
uploadFileToContainer.mockRejectedValueOnce(outsideRoots);
createContainerDirectory.mockRejectedValueOnce(outsideRoots);
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadPaths(["/host/a.txt"]);
await result.current.createFolder("new");
});
expect(lastToast().message).toBe(outsideRoots);
@@ -587,39 +277,285 @@ describe("useFileManager surfaces written refusals as prose", () => {
expect(lastToast().message).not.toMatch(/^Error:/);
});
it("says it once for a whole batch that failed the same way", async () => {
// The refusal is about the target directory, so every file in the drop
// fails identically — three copies of the same sentence is not detail.
uploadFileToContainer.mockRejectedValue(outsideRoots);
it("unwraps an `Error` rather than stamping \"Error:\" on prose", async () => {
renameContainerPath.mockRejectedValueOnce(new Error(outsideRoots));
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]);
await result.current.renameEntry(file("a.txt"), "b.txt");
});
expect(lastToast().message).toBe(outsideRoots);
expect(lastToast().detail).toBeUndefined();
});
it("does the same for a refused save to the host", async () => {
save.mockResolvedValue("/home/me/.ssh/a.txt");
downloadContainerFile.mockRejectedValueOnce(new Error(hiddenFolder));
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.downloadFile(file("a.txt"));
});
// Unwrapped: an `Error` on the way through must not stamp "Error:" on prose.
expect(lastToast().message).toBe(hiddenFolder);
});
it("keeps the hook's own headline when the failure is not a written refusal", async () => {
uploadFileToContainer.mockRejectedValueOnce("no space left on device");
createContainerDirectory.mockRejectedValueOnce("no space left on device");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadPaths(["/host/a.txt"]);
await result.current.createFolder("new");
});
expect(lastToast().message).toBe("A file could not be uploaded");
expect(lastToast().message).toBe('Could not create "new"');
expect(lastToast().detail).toBe("no space left on device");
});
});
/**
* Both of these actions are *dialog-driven from Rust* the hook passes a
* project and a directory and gets back an answer, and there is deliberately no
* host path anywhere in this file. What is worth pinning is the vocabulary of
* that answer, because two of its values look like failure and are not: `null`
* means the user dismissed the picker, and `0` bytes means an empty file was
* saved successfully.
*/
describe("useFileManager saving to the host", () => {
it("treats a zero-byte save as a success", async () => {
// The bug this exists for: `if (!bytes) return` reads a genuine
// zero-length file — an empty `.gitkeep`, a truncated log — as a
// dismissal, so the file lands on the host and the app says nothing at
// all. The sentinel is `null`, and only `null`.
downloadContainerFile.mockResolvedValueOnce(0);
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.saveToHost(file("empty.txt"));
});
expect(result.current.completed).toContain("empty.txt");
expect(pushToast).not.toHaveBeenCalled();
});
it("says nothing at all when the dialog is dismissed", async () => {
downloadContainerFile.mockResolvedValueOnce(null);
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.saveToHost(file("a.txt"));
});
expect(result.current.completed).toBeNull();
expect(pushToast).not.toHaveBeenCalled();
});
it("names the file in a refusal", async () => {
downloadContainerFile.mockRejectedValueOnce("/etc/shadow is not readable");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.saveToHost(file("secret.txt"));
});
expect(toastText()).toContain("secret.txt");
});
});
describe("useFileManager uploading from the host", () => {
it("uploads into the directory on screen and shows the result", async () => {
uploadFilesToContainer.mockResolvedValueOnce({
uploaded: ["/workspace/app/one.txt", "/workspace/app/two.txt"],
failures: [],
});
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/workspace/app");
});
listContainerFiles.mockClear();
await act(async () => {
await result.current.uploadFiles();
});
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/app");
expect(result.current.completed).toContain("2 files");
// The directory is named. `target` is captured at click time and the
// picker is a modal dialog, so "Uploaded 2 files." on its own can be shown
// in front of a grid those files are not in.
expect(result.current.completed).toContain("/workspace/app");
// The new files are only on screen if the listing was asked for again.
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/app");
});
it("reports every file that failed, not just a count", async () => {
// "3 of 5 uploaded" without naming the two is not a report — the user
// cannot tell which ones to retry, or why.
uploadFilesToContainer.mockResolvedValueOnce({
uploaded: ["/workspace/ok.txt"],
failures: [
"/home/j/Pictures is a folder — upload its files individually.",
"/home/j/vm.img is too large to upload (900 MB; limit 256 MB).",
],
});
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadFiles();
});
expect(pushToast).toHaveBeenCalledTimes(2);
expect(toastText()).toContain("is a folder");
expect(toastText()).toContain("too large");
// A partial batch still succeeded partially, and the pane must show it.
expect(result.current.completed).toContain("1 file");
});
it("does not refresh when the picker was dismissed", async () => {
uploadFilesToContainer.mockResolvedValueOnce(null);
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/workspace/app");
});
listContainerFiles.mockClear();
await act(async () => {
await result.current.uploadFiles();
});
expect(listContainerFiles).not.toHaveBeenCalled();
expect(pushToast).not.toHaveBeenCalled();
expect(result.current.completed).toBeNull();
});
it("reports a refusal that happened before the picker once, not per file", async () => {
// No container, not running, or a directory this pane may not write to.
// There is no selection yet, so there is nothing to enumerate.
uploadFilesToContainer.mockRejectedValueOnce(
"Start the project before uploading files — it runs inside the running container.",
);
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadFiles();
});
expect(pushToast).toHaveBeenCalledTimes(1);
expect(toastText()).toContain("Start the project");
});
it("does not drag the pane back when the user navigated during the upload", async () => {
// The same rule rename and new-folder follow: a slow operation must not
// relist a directory the user has already left.
let release: (v: unknown) => void = () => {};
uploadFilesToContainer.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve;
}),
);
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.navigate("/workspace/app");
});
let uploading: Promise<void>;
act(() => {
uploading = result.current.uploadFiles();
});
await act(async () => {
await result.current.navigate("/workspace/other");
});
listContainerFiles.mockClear();
await act(async () => {
release({ uploaded: ["/workspace/app/one.txt"], failures: [] });
await uploading;
});
expect(listContainerFiles).not.toHaveBeenCalled();
expect(result.current.currentPath).toBe("/workspace/other");
});
});
describe("useFileManager transfer state", () => {
it("marks an upload in flight for as long as it runs", async () => {
// Without this the button stays live: a second click opens a second OS
// dialog and runs a second concurrent exec, and a slow transfer looks
// exactly like a click that did nothing.
let release: (v: unknown) => void = () => {};
uploadFilesToContainer.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve;
}),
);
const { result } = renderHook(() => useFileManager("p1"));
expect(result.current.uploading).toBe(false);
let uploading: Promise<void>;
act(() => {
uploading = result.current.uploadFiles();
});
expect(result.current.uploading).toBe(true);
await act(async () => {
release({ uploaded: [], failures: [] });
await uploading;
});
expect(result.current.uploading).toBe(false);
});
it("stays in flight through the refresh, not just the transfer", async () => {
// Clearing the flag the moment the command settled put the button back
// while the re-listing was still running, so a second click landed
// mid-refresh on a grid that was still showing the old contents.
uploadFilesToContainer.mockResolvedValueOnce({
uploaded: ["/workspace/a.txt"],
failures: [],
});
let finishListing: (v: unknown) => void = () => {};
listContainerFiles.mockReturnValueOnce(
new Promise((resolve) => {
finishListing = resolve;
}),
);
const { result } = renderHook(() => useFileManager("p1"));
let uploading: Promise<void>;
act(() => {
uploading = result.current.uploadFiles();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
// The transfer is done; the listing it triggered is not.
expect(result.current.uploading).toBe(true);
await act(async () => {
finishListing([file("a.txt")]);
await uploading;
});
expect(result.current.uploading).toBe(false);
});
it("clears the upload flag when the transfer fails", async () => {
// The `catch` returns early, so without a `finally` the button is disabled
// for the rest of the session — the failure mode is a pane that can never
// upload again, with no error left on screen to explain it.
uploadFilesToContainer.mockRejectedValueOnce("Start the project first");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.uploadFiles();
});
expect(result.current.uploading).toBe(false);
});
it("tracks each save separately, so one finishing does not free another", async () => {
// The bug this exists for: `savingPath` was a single string. Starting a
// second save overwrote it, so the first row went live again mid-transfer,
// and whichever save settled first cleared the flag for both — dismissing
// the second dialog was enough. A set is what the design needs, because
// "only the row being saved is disabled" is exactly what makes a second
// save startable.
let releaseBig: (v: unknown) => void = () => {};
let releaseSmall: (v: unknown) => void = () => {};
downloadContainerFile
.mockReturnValueOnce(new Promise((r) => { releaseBig = r; }))
.mockReturnValueOnce(new Promise((r) => { releaseSmall = r; }));
const { result } = renderHook(() => useFileManager("p1"));
let big: Promise<void>;
let small: Promise<void>;
act(() => { big = result.current.saveToHost(file("big.bin")); });
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
act(() => { small = result.current.saveToHost(file("notes.txt")); });
// Both, at once — a scalar could only hold the second.
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
expect(result.current.savingPaths.has("/workspace/notes.txt")).toBe(true);
// The second one finishing must not re-enable the first, which is still
// streaming. `null` is the dismissal path, which is how this was cheapest
// to trigger in practice.
await act(async () => { releaseSmall(null); await small; });
expect(result.current.savingPaths.has("/workspace/notes.txt")).toBe(false);
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
await act(async () => { releaseBig(10); await big; });
expect(result.current.savingPaths.size).toBe(0);
});
it("clears a row's saving flag when its save fails", async () => {
downloadContainerFile.mockRejectedValueOnce("Permission denied");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.saveToHost(file("b.txt"));
});
expect(result.current.savingPaths.size).toBe(0);
});
});
+128 -235
View File
@@ -1,36 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
import { useCallback, useRef, useState } from "react";
import type { FileEntry } from "../lib/types";
import * as commands from "../lib/tauri-commands";
import { useAppState } from "../store/appState";
import {
errorText,
fileExistsPath,
isFileExistsError,
readableRefusal,
type OverwriteChoice,
} from "../lib/uploadErrors";
/**
* One upload waiting on the user to say whether it may replace what is there.
* `remaining` is how many files are queued behind this one, which is what
* decides whether the blanket answers are worth offering.
*/
export interface UploadConflict {
/** Host file being uploaded. */
hostPath: string;
/** Bare name, for the prompt. */
name: string;
/** Container directory it is going into. */
directory: string;
remaining: number;
}
/** `/a/b/c.txt` and `C:\a\b\c.txt` both give `c.txt`. */
function baseName(path: string): string {
const parts = path.split(/[\\/]/);
return parts[parts.length - 1] || path;
}
import { errorText, readableRefusal } from "../lib/refusalText";
import { formatBytes } from "../lib/formatBytes";
/**
* ## Where failures are reported
@@ -41,22 +14,19 @@ function baseName(path: string): string {
* (empty) grid. It is on screen, it is in context, it explains why there are
* no rows, and it is not transient it stands until the directory lists.
*
* Every **transient operation** failure upload, rename, create folder,
* save-to-host goes to `ToastHost` instead. Those used to land
* in the same inline `error` div, which is the first child of the *scrolling*
* list: three hundred rows down, a refused rename produced no visible change
* at all, just a rename box that stayed open for no stated reason. Worse, the
* file viewer routes its "Save to host…" through the same call, and the viewer
* is a `fixed inset-0` portal at `z-50` so that failure reported *behind* the
* dialog that caused it. The toast host is a persistent `aria-live` region at
* `z-[60]`, i.e. the one place in the app that is above a modal and does not
* scroll away.
* Every **transient operation** failure rename, create folder, upload, save
* to host goes to `ToastHost` instead. Those used to land in the same inline `error` div, which
* is the first child of the *scrolling* list: three hundred rows down, a
* refused rename produced no visible change at all, just a rename box that
* stayed open for no stated reason. The toast host is a persistent `aria-live`
* region at `z-[60]`, i.e. the one place in the app that is above a modal and
* does not scroll away.
*
* ## Where the current directory lives
*
* `currentPath` is state (the UI renders it) *and* a ref (async work reads it
* after an await). Every long operation captures the directory it targets at
* the start and compares it against the ref at the end: a 200 MB upload into
* the start and compares it against the ref at the end: a slow rename in
* `/workspace` must not drag the pane back out of `src/` because that is where
* the closure happened to be created. The ref moves at the *start* of a
* navigation rather than when the listing lands, because the question being
@@ -68,14 +38,33 @@ export function useFileManager(projectId: string) {
const [entries, setEntries] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Transient "uploading 3 files…" style note, shown beside the breadcrumb. */
const [busy, setBusy] = useState<string | null>(null);
/**
* What just finished. A live region that only ever says "uploading…" tells a
* screen reader user when to start waiting and never when to stop.
* What just finished, for the live region a rename or a new folder is a
* change a sighted user sees in the grid and a screen reader user does not.
*/
const [completed, setCompleted] = useState<string | null>(null);
const [conflict, setConflict] = useState<UploadConflict | null>(null);
/**
* Which host transfers are in flight.
*
* Both actions open an OS dialog and can then run for a long time on a large
* file, with nothing on screen to say so. Without this the buttons stay live:
* a second click opens a second dialog and runs a second concurrent exec
* against the same file, and a multi-gigabyte save is indistinguishable from
* a click that did nothing.
*
* `savingPaths` is a **set**, not one path. Keeping only the row being
* disabled is what makes the pane usable during a big transfer and that is
* precisely what makes a *second* save startable, so the state has to be able
* to hold two. As a scalar it could not: starting a save on `notes.txt` while
* `big.bin` was still streaming overwrote it, so `big.bin`'s button went live
* again mid-transfer; and whichever save finished first cleared the flag for
* both. Dismissing the second dialog was enough to do it.
*
* Paths are unique within a listing, so a path is a usable key `FilesTab`
* relies on the same fact for its row keys.
*/
const [uploading, setUploading] = useState(false);
const [savingPaths, setSavingPaths] = useState<ReadonlySet<string>>(new Set());
const currentPathRef = useRef(currentPath);
@@ -87,45 +76,29 @@ export function useFileManager(projectId: string) {
*/
const navGeneration = useRef(0);
const startWork = useCallback((note: string) => {
setBusy(note);
setCompleted(null);
}, []);
/**
* Report a failed operation, given the headline this hook would write and the
* raw failures behind it.
* raw failure behind it.
*
* The headline is what the *hook* knows ("Could not rename …"); it is a
* category, not an explanation. Some backend refusals are already a finished
* sentence written for the person reading it a hidden host folder, a
* container path outside the roots this panel may change and those used to
* sentence written for the person reading it a container path outside the
* roots this panel may change, a name it will not create and those used to
* arrive as the toast's `detail`, which `ToastHost` renders as collapsed
* monospace behind a "Details" button. So the sentence that said what was
* wrong and what to do about it was hidden under a headline that said
* neither. When every failure reduces to the *same* such sentence which is
* the normal case, since these refusals are about the target directory and so
* fail identically for every file in a batch it becomes the headline and
* there is nothing left to hide.
* neither. When there is such a sentence it becomes the headline, and there
* is nothing left to hide.
*/
const report = useCallback((message: string, ...causes: unknown[]) => {
const refusals = causes.map(readableRefusal);
const shared =
causes.length > 0 && refusals.every((r) => r !== null)
? [...new Set(refusals as string[])]
: [];
const promoted = shared.length === 1 ? shared[0] : null;
const report = useCallback((message: string, cause: unknown) => {
const promoted = readableRefusal(cause);
useAppState.getState().pushToast({
kind: "error",
message: promoted ?? message,
detail: promoted || causes.length === 0 ? undefined : causes.map(errorText).join("\n"),
detail: promoted ? undefined : errorText(cause),
});
}, []);
const confirm = useCallback((message: string) => {
useAppState.getState().pushToast({ kind: "success", message });
}, []);
const navigate = useCallback(
async (path: string) => {
const mine = ++navGeneration.current;
@@ -163,164 +136,6 @@ export function useFileManager(projectId: string) {
navigate(currentPathRef.current);
}, [navigate]);
/** Copy an entry out to a host path the user picks. */
const downloadFile = useCallback(
async (entry: FileEntry) => {
try {
const hostPath = await save({ defaultPath: entry.name });
if (!hostPath) return;
// Every sibling operation sets `busy`; this one did not, so a 200 MB
// copy was a click, then a frozen-looking pane, then nothing.
startWork(`Saving "${entry.name}" to the host…`);
try {
await commands.downloadContainerFile(projectId, entry.path, hostPath);
setCompleted(`Saved "${entry.name}" to ${hostPath}.`);
confirm(`Saved "${entry.name}" to the host.`);
} finally {
setBusy(null);
}
} catch (e) {
report(`Could not save "${entry.name}" to the host`, e);
}
},
[projectId, startWork, report, confirm],
);
/**
* The pending answer to `conflict`. Kept in a ref rather than state because
* the upload loop is `await`ing it it needs the resolver, not a re-render.
*/
const conflictResolver = useRef<((choice: OverwriteChoice) => void) | null>(null);
const resolveConflict = useCallback((choice: OverwriteChoice) => {
const resolve = conflictResolver.current;
conflictResolver.current = null;
setConflict(null);
resolve?.(choice);
}, []);
// A pane unmounted mid-prompt (the tab was closed, the container stopped)
// would otherwise leave the upload loop awaiting an answer that can never
// come. Skipping is the safe reading of "the dialog went away".
useEffect(
() => () => {
conflictResolver.current?.("skip-all");
conflictResolver.current = null;
},
[],
);
const askOverwrite = useCallback(
(hostPath: string, directory: string, remaining: number, containerPath: string | null) =>
new Promise<OverwriteChoice>((resolve) => {
// One batch asks one question at a time — the loop awaits each answer —
// so a resolver still sitting here belongs to a *different* batch (two
// drops in flight at once, or a drop landing while the Upload button's
// batch is still copying). Installing over it would leave that batch
// awaiting an answer no dialog can ever produce: a silent hang, with
// its file neither uploaded nor skipped. Skipping it is the same
// reading of "the dialog went away" the unmount cleanup uses.
conflictResolver.current?.("skip");
conflictResolver.current = resolve;
setConflict({
hostPath,
name: baseName(containerPath ?? hostPath),
directory,
remaining,
});
}),
[],
);
/**
* Copy host files into the current directory. Shared by the Upload button and
* the native drag-drop listener, so a dropped file and a picked one take the
* same path including the one refresh at the end rather than one per file.
*
* The backend refuses to overwrite unless asked to, so a name clash is not a
* failure here: it is a question, and the answer can be given once for the
* whole batch.
*/
const uploadPaths = useCallback(
async (hostPaths: string[]) => {
if (hostPaths.length === 0) return;
// The directory this upload is *for*. Compared against the live ref at
// the end, because the user is free to walk away while it copies.
const target = currentPathRef.current;
startWork(`Uploading ${hostPaths.length} item${hostPaths.length > 1 ? "s" : ""}`);
/** Raw failures, kept unstringified so `report` can read their shape. */
const failures: unknown[] = [];
let uploaded = 0;
let skipped = 0;
/** A "…all" answer, applied to every remaining clash without asking. */
let blanket: OverwriteChoice | null = null;
try {
for (let i = 0; i < hostPaths.length; i++) {
const hostPath = hostPaths[i];
try {
await commands.uploadFileToContainer(projectId, hostPath, target);
uploaded++;
continue;
} catch (e) {
if (!isFileExistsError(e)) {
failures.push(e);
continue;
}
const choice: OverwriteChoice =
blanket ??
(await askOverwrite(
hostPath,
target,
hostPaths.length - i - 1,
fileExistsPath(e),
));
if (choice === "replace-all" || choice === "skip-all") blanket = choice;
if (choice === "skip" || choice === "skip-all") {
skipped++;
continue;
}
}
try {
await commands.uploadFileToContainer(projectId, hostPath, target, true);
uploaded++;
} catch (e) {
failures.push(e);
}
}
} finally {
setBusy(null);
}
const summary =
`Uploaded ${uploaded} item${uploaded === 1 ? "" : "s"}` +
(skipped > 0 ? `, skipped ${skipped}` : "") +
(failures.length > 0 ? `, ${failures.length} failed` : "") +
".";
setCompleted(summary);
if (failures.length > 0) {
report(
failures.length === 1 ? "A file could not be uploaded" : `${failures.length} files could not be uploaded`,
...failures,
);
}
// Only re-list if the user is still looking at the directory this went
// into. Navigating away during a slow copy used to drag the pane back.
if (currentPathRef.current === target) await navigate(target);
},
[projectId, navigate, startWork, report, askOverwrite],
);
const uploadFile = useCallback(async () => {
try {
const selected = await openDialog({ multiple: true, directory: false });
if (!selected) return;
await uploadPaths(Array.isArray(selected) ? selected : [selected as string]);
} catch (e) {
report("Could not open the file picker", e);
}
}, [uploadPaths, report]);
/**
* Rename in place. `newName` is a bare name Rust rejects anything with a
* `/` in it, so this can never turn into a move. Resolves true on success so
@@ -362,26 +177,104 @@ export function useFileManager(projectId: string) {
[projectId, navigate, report],
);
/**
* Copy host files into the directory on screen.
*
* The picker is opened by **Rust**, not here `upload_files_to_container`
* shows it, reads what the user chose and never lets a host path near IPC.
* So this passes a directory and gets back an outcome; `null` means the user
* dismissed the dialog, which is not a failure and says nothing.
*
* One dialog can select several files and they need not agree, hence two
* lists. Every failure is reported, because "3 of 5 uploaded" without saying
* which two is not a report. The listing is refreshed once, at the end, and
* only if the user is still looking at the directory that was targeted.
*/
const uploadFiles = useCallback(async () => {
const target = currentPathRef.current;
setUploading(true);
try {
let outcome;
try {
outcome = await commands.uploadFilesToContainer(projectId, target);
} catch (e) {
// A failure *before* the picker: no container, not running, or a
// directory this pane may not write to. One toast, not one per file.
report("Could not upload", e);
return;
}
if (!outcome) return;
for (const failure of outcome.failures) {
useAppState.getState().pushToast({ kind: "error", message: failure });
}
if (outcome.uploaded.length === 0) return;
// The directory is named, not implied. `target` is captured at click time
// and the picker is a modal OS dialog — the user has all the time in the
// world to browse somewhere else while it is open, and the files land
// where they started. "Uploaded 2 files." in front of a grid that does
// not contain them is a worse answer than no message at all.
const count = outcome.uploaded.length;
setCompleted(
`Uploaded ${count === 1 ? "1 file" : `${count} files`} to ${target}.`,
);
if (currentPathRef.current === target) await navigate(target);
} finally {
// Around the *whole* body, refresh included. Clearing it the moment the
// command settled put the button back before the re-listing had run, so
// a second click landed mid-refresh on a grid that was still the old one.
setUploading(false);
}
}, [projectId, navigate, report]);
/**
* Save one file out to the host, with Rust opening the save dialog.
*
* No refresh: nothing in the container changed. The save dialog is also what
* asks about overwriting an existing host file, which is why the backend has
* no collision handling of its own to get wrong. `null` is a dismissal.
*/
const saveToHost = useCallback(
async (entry: FileEntry) => {
setSavingPaths((live) => new Set(live).add(entry.path));
try {
const bytes = await commands.downloadContainerFile(projectId, entry.path);
// `0` is a real answer — an empty file saved is a success — so this
// tests for the dismissal sentinel, not for falsiness.
if (bytes === null) return;
setCompleted(`Saved "${entry.name}" (${formatBytes(bytes)}).`);
} catch (e) {
report(`Could not save "${entry.name}"`, e);
} finally {
// Remove only this one. A save that finishes while another is still
// streaming must not re-enable the other's row.
setSavingPaths((live) => {
const next = new Set(live);
next.delete(entry.path);
return next;
});
}
},
[projectId, report],
);
return {
currentPath,
entries,
loading,
/** Inline, in-context: why the listing on screen is empty. */
error,
busy,
/** What the last operation finished doing, for the live region. */
completed,
/** An upload waiting for a Replace / Skip answer, or `null`. */
conflict,
resolveConflict,
setError,
navigate,
goUp,
refresh,
downloadFile,
uploadFile,
uploadPaths,
renameEntry,
createFolder,
uploadFiles,
saveToHost,
/** A host transfer is in flight — see the state declarations above. */
uploading,
savingPaths,
};
}
+23 -4
View File
@@ -3,6 +3,7 @@ import { save } from "@tauri-apps/plugin-dialog";
import type { Project } from "../lib/types";
import * as commands from "../lib/tauri-commands";
import { formatBytes } from "../lib/formatBytes";
import { describeResetLeftovers, resetLeftoverPronoun } from "../lib/resetOutcome";
import { useAppState } from "../store/appState";
import { useProjects } from "./useProjects";
import { useTerminal } from "./useTerminal";
@@ -28,13 +29,14 @@ export function useProjectActions(project: Project) {
);
const run = useCallback(
async (label: string, fn: () => Promise<unknown>) => {
async <T,>(label: string, fn: () => Promise<T>): Promise<T | undefined> => {
setBusy(true);
setContainerProgress(project.id, null);
try {
await fn();
return await fn();
} catch (e) {
fail(`${label} failed for “${project.name}`, e);
return undefined;
} finally {
setContainerProgress(project.id, null);
setBusy(false);
@@ -54,8 +56,25 @@ export function useProjectActions(project: Project) {
);
const handleReset = useCallback(
() => run("Reset", () => rebuild(project.id)),
[run, rebuild, project.id],
() =>
run("Reset", async () => {
const outcome = await rebuild(project.id);
if (outcome.leftover_image || outcome.leftover_volumes.length > 0) {
// Not "run `docker volume rm`" — by the time this renders, the new
// container this same call just started already has the leftover
// volume mounted, so that command would just hit the same 409
// Reset did. Stopping the project first is what actually frees it.
pushToast({
kind: "error",
message: `Reset for “${project.name}” did not fully clean up`,
detail: `Triple-C could not remove ${describeResetLeftovers(outcome)} from before the reset, so \
the new container may still be built from, or contain, old data. Stop the project, then try \
Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
});
}
return outcome;
}),
[run, rebuild, project.id, project.name, pushToast],
);
const openClaudeTerminal = useCallback(async () => {
+24
View File
@@ -140,3 +140,27 @@ describe("useProjects puts the status back when a refused command never ran", ()
expect(statusOf()).toBe("stopped");
});
});
describe("useProjects.rebuild on success", () => {
it("puts the outcome's project, not the whole outcome, into the list", async () => {
const rebuilt = project("running");
rebuildProjectContainer.mockResolvedValue({
project: rebuilt,
leftover_image: null,
leftover_volumes: [],
});
const { result } = renderHook(() => useProjects());
let outcome!: Awaited<ReturnType<typeof result.current.rebuild>>;
await act(async () => {
outcome = await result.current.rebuild("p1");
});
// A regression here would put the `{ project, leftover_image,
// leftover_volumes }` wrapper into the projects list instead of the
// `Project` it wraps — a shape mismatch `tsc` would not catch inside a
// callback typed to take `unknown` per Tauri's `invoke`.
expect(useAppState.getState().projects.find((p) => p.id === "p1")).toEqual(rebuilt);
expect(outcome.leftover_volumes).toEqual([]);
});
});
+5 -4
View File
@@ -44,8 +44,9 @@ export function useProjects() {
const remove = useCallback(
async (id: string) => {
await commands.removeProject(id);
const report = await commands.removeProject(id);
removeProjectFromList(id);
return report;
},
[removeProjectFromList],
);
@@ -135,9 +136,9 @@ export function useProjects() {
const rebuild = useCallback(
(id: string) =>
withOptimisticStatus(id, "starting", async () => {
const updated = await commands.rebuildProjectContainer(id);
updateProjectInList(updated);
return updated;
const outcome = await commands.rebuildProjectContainer(id);
updateProjectInList(outcome.project);
return outcome;
}),
[updateProjectInList, withOptimisticStatus],
);
+4
View File
@@ -36,5 +36,9 @@ export function useSettings() {
appSettings,
loadSettings,
saveSettings,
/** For a command that already returns the new `AppSettings` itself
* (settings import) updates the store without a redundant
* `updateSettings` round trip through the backend. */
setAppSettings,
};
}
+6 -3
View File
@@ -7,8 +7,11 @@
*
* 1. **Which pane is this drop for?** Geometry, and nothing else: is the
* payload position inside my rect? A hidden pane is `display:none` and so
* has a zero-size rect, which is what stops `TerminalView` and `FilesTab`
* both claiming the same drop.
* has a zero-size rect, which is what stops two panes both claiming the
* same drop. `TerminalView` is the only pane that takes dropped files
* today the Files pane copies files through buttons and a backend-opened
* dialog, not through a drop but the routing is what keeps it honest when
* a second one appears.
* 2. **Should the app accept a drop at all right now?** `dropIsBlocked`
* document-wide, no geometry, no z-order. While a modal or a blocking
* overlay is on screen anywhere, every drop is refused.
@@ -16,7 +19,7 @@
* ## Why there is no z-order test here, and must not be one
*
* A drop that lands underneath a dialog and silently uploads into the
* directory the dialog is covering is the failure mode that matters: it is
* container behind it is the failure mode that matters: it is
* invisible, it writes to the container, and the user did not ask for it.
* Every attempt to be *precise* about which points a dialog covers has gone
* wrong, twice, in opposite directions:
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { errorText, readableRefusal } from "./refusalText";
/**
* Refusals that are a sentence the backend wrote for the person reading it.
* They used to arrive as a toast's `detail`, which renders as collapsed
* monospace behind a "Details" button so the only part of the message that
* explained anything was the part nobody saw.
*/
describe("readableRefusal", () => {
const hidden =
'the path goes through ".ssh", a hidden folder — Triple-C will not save anything whose folders are not all visible. Choose a visible location.';
const outside =
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
it("recognises the hidden-host-folder refusal, in both directions", () => {
expect(readableRefusal(hidden)).toBe(hidden);
expect(
readableRefusal(
'the path goes through ".aws", a hidden folder — Triple-C will not read anything whose folders are not all visible. Choose a visible location.',
),
).toContain("hidden folder");
});
it("recognises the container write-root refusal", () => {
expect(readableRefusal(outside)).toBe(outside);
});
it("strips a wrapper a JS layer put in front of the sentence", () => {
// `invoke` rejects with the bare string today, but an `Error` anywhere in
// between would otherwise put "Error: " in front of prose meant to be read.
expect(readableRefusal(new Error(hidden))).toBe(hidden);
expect(readableRefusal(`Error: ${hidden}`)).toBe(hidden);
expect(readableRefusal(`Uncaught (in promise) Error: ${outside}`)).toBe(outside);
expect(readableRefusal({ message: `invoke failed: ${outside}` })).toBe(outside);
});
it("says nothing about failures that are not a written refusal", () => {
// Promotion is an improvement, not a fallback: anything unrecognised keeps
// reporting exactly as it did before.
expect(readableRefusal("File too large to upload (900 MB; limit 256 MB)")).toBeNull();
expect(readableRefusal("FILE_EXISTS: /workspace/a.txt already exists")).toBeNull();
expect(readableRefusal("cp: Permission denied")).toBeNull();
expect(readableRefusal(null)).toBeNull();
});
});
describe("errorText", () => {
it("keeps an ordinary message intact", () => {
expect(errorText("cp: cannot create regular file: Permission denied")).toBe(
"cp: cannot create regular file: Permission denied",
);
});
it("reads a message out of a shape `String()` would render as [object Object]", () => {
expect(errorText({ message: "Container not running" })).toBe("Container not running");
expect(errorText({ kind: "NotRunning" })).toBe("NotRunning");
expect(errorText(new Error("Failed to upload file to container: no space left"))).toBe(
"Failed to upload file to container: no space left",
);
});
it("prefers the written refusal when there is one", () => {
expect(errorText(new Error("Folder path is outside the folders this panel can change (/workspace): /etc"))).toBe(
"Folder path is outside the folders this panel can change (/workspace): /etc",
);
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Turning a backend refusal into the sentence a person reads.
*
* Tauri command errors cross the IPC boundary as whatever `serde` made of them:
* a bare string from `Err(String)`, an object from a `#[derive(Serialize)]`
* error enum, or an `Error` if a JS layer wrapped it on the way through. All
* three are the same refusal, and the UI must not read differently depending on
* which one a future refactor produces so everything here is tolerant about
* the *shape* of an error and picks the most human string out of it.
*/
/**
* Field names a serialised Rust error realistically uses for its discriminant
* and for its human text. `error` is listed as a discriminant field and yet
* routinely carries a whole sentence, which is why a kind string is read as
* prose too.
*/
const KIND_FIELDS = ["kind", "code", "type", "error", "reason"] as const;
const MESSAGE_FIELDS = ["message", "msg", "detail", "description"] as const;
function asRecord(e: unknown): Record<string, unknown> | null {
return typeof e === "object" && e !== null ? (e as Record<string, unknown>) : null;
}
/**
* Every string an error carries, flattened: the error itself if it is one, its
* message-ish fields, and its kind-ish fields. Nesting is followed one level
* because a wrapped error (`{ error: { message: … } }`) is the same refusal.
*/
function stringsIn(e: unknown, depth = 0): string[] {
if (typeof e === "string") return [e];
if (e instanceof Error) return [e.message, e.name];
const record = asRecord(e);
if (!record || depth > 1) return [];
const out: string[] = [];
const walk = (value: unknown) => {
if (typeof value === "string") out.push(value);
else if (value !== undefined) out.push(...stringsIn(value, depth + 1));
};
for (const field of KIND_FIELDS) walk(record[field]);
for (const field of MESSAGE_FIELDS) walk(record[field]);
return out;
}
/**
* Fragments that identify a refusal the backend already wrote **for a person**.
*
* The file commands guard two policies that a user can trip over by accident,
* and both answer with a finished sentence that names the offending path and
* says what to do instead:
*
* the path goes through ".ssh", a hidden folder Triple-C will not save
* Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc
*
* Those sentences were being used as the *detail* of a generic toast, and
* `ToastHost` renders a detail as collapsed monospace behind a "Details"
* button so the one part of the message that explained anything was the part
* nobody saw. Matching them here lets the caller promote the sentence to the
* toast's headline.
*
* Matched on a stable fragment rather than the whole string, because the path
* and the verb ("save"/"read", "file"/"folder") vary per call. Deliberately a
* short list: an error that is *not* recognised still reports exactly as it
* did before, so a wrong guess here can only fail to promote, never mangle.
*/
const REFUSAL_MARKERS = [
// `validate_host_path` — hidden host component, and system locations.
"Triple-C will not",
// `validate_container_write_path` — outside /workspace, /home/claude, /tmp.
"outside the folders this panel can change",
] as const;
/**
* `Error: …`, `TypeError: …`, `invoke failed: …` wrappers a JS layer may have
* put in front of the backend's sentence on the way through. Stripped so the
* prose starts where the backend started it; applied twice at most, because a
* doubly-wrapped error is the realistic worst case and looping on user text is
* not.
*/
const WRAPPER_PREFIX = /^(?:uncaught\s*(?:\(in promise\)\s*)?)?(?:[a-z]*error|invoke(?:\s+failed)?)\s*:\s*/i;
function stripWrapper(text: string): string {
let out = text.trim();
for (let i = 0; i < 2; i++) {
const next = out.replace(WRAPPER_PREFIX, "").trim();
if (next === out) break;
out = next;
}
return out;
}
/**
* The backend's own user-facing sentence, when this failure is one otherwise
* `null`, and the caller reports it however it reported everything else.
*/
export function readableRefusal(e: unknown): string | null {
for (const s of stringsIn(e)) {
const text = stripWrapper(s);
if (REFUSAL_MARKERS.some((marker) => text.includes(marker))) return text;
}
return null;
}
/**
* The most human form of any failure, for the places that show one verbatim.
*
* `String(e)` is what these used to be, which turns a serialised error object
* into `[object Object]` and leaves a JS wrapper prefix on a sentence that
* reads perfectly well without it.
*/
export function errorText(e: unknown): string {
const readable = readableRefusal(e);
if (readable) return readable;
if (typeof e === "string") return stripWrapper(e);
if (e instanceof Error) return stripWrapper(e.message);
const record = asRecord(e);
if (record) {
for (const field of [...MESSAGE_FIELDS, ...KIND_FIELDS]) {
const value = record[field];
if (typeof value === "string" && value.trim().length > 0) return stripWrapper(value);
}
}
return String(e);
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { describeResetLeftovers, resetLeftoverPronoun } from "./resetOutcome";
import type { ProjectResetOutcome } from "./types";
function outcome(overrides: Partial<ProjectResetOutcome> = {}): ProjectResetOutcome {
return {
project: {} as ProjectResetOutcome["project"],
leftover_image: null,
leftover_volumes: [],
...overrides,
};
}
describe("describeResetLeftovers", () => {
it("names the image first, then the volumes", () => {
expect(describeResetLeftovers(outcome({ leftover_image: "x" }))).toBe(
"its previous container image",
);
expect(describeResetLeftovers(outcome({ leftover_volumes: ["v1"] }))).toBe("a volume");
expect(describeResetLeftovers(outcome({ leftover_volumes: ["v1", "v2"] }))).toBe("2 volumes");
expect(
describeResetLeftovers(outcome({ leftover_image: "x", leftover_volumes: ["v1", "v2"] })),
).toBe("its previous container image and 2 volumes");
});
});
describe("resetLeftoverPronoun", () => {
it("is singular for exactly one leftover", () => {
expect(resetLeftoverPronoun(outcome({ leftover_image: "x" }))).toBe("it");
expect(resetLeftoverPronoun(outcome({ leftover_volumes: ["v1"] }))).toBe("it");
});
it("is plural once more than one thing survived", () => {
expect(resetLeftoverPronoun(outcome({ leftover_image: "x", leftover_volumes: ["v1"] }))).toBe(
"them",
);
expect(resetLeftoverPronoun(outcome({ leftover_volumes: ["v1", "v2"] }))).toBe("them");
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { ProjectResetOutcome } from "./types";
/**
* Names what a `ProjectResetOutcome` says Reset could not clear, for
* `useProjectActions`'s Reset toast.
*
* The image is named first and phrased as "its previous container image"
* rather than folded in with the volumes it is the more serious of the
* two: the new container is built from it whenever it exists, so a
* surviving image means Reset silently rebuilt the exact system layer it
* was asked to discard, while a surviving volume only means old data rides
* along.
*/
export function describeResetLeftovers(outcome: ProjectResetOutcome): string {
const parts: string[] = [];
if (outcome.leftover_image) parts.push("its previous container image");
if (outcome.leftover_volumes.length === 1) parts.push("a volume");
else if (outcome.leftover_volumes.length > 1) parts.push(`${outcome.leftover_volumes.length} volumes`);
return parts.join(" and ");
}
/** How many distinct things `describeResetLeftovers` is describing the
* image counts as one, however many volumes are named alongside it. */
function resetLeftoverCount(outcome: ProjectResetOutcome): number {
return (outcome.leftover_image ? 1 : 0) + outcome.leftover_volumes.length;
}
/** Pronoun agreement for referring back to `describeResetLeftovers`'s
* output "remove it manually" for one thing, "remove them" for more. */
export function resetLeftoverPronoun(outcome: ProjectResetOutcome): "it" | "them" {
return resetLeftoverCount(outcome) === 1 ? "it" : "them";
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, it, expect } from "vitest";
import { describeImport, describeImportWarnings } from "./settingsImportPreview";
import type { SettingsImportPreview } from "./types";
function preview(overrides: Partial<SettingsImportPreview> = {}): SettingsImportPreview {
return {
exported_at: "2026-08-27T00:00:00Z",
app_version: "0.4.14",
custom_env_var_count: 0,
gateway_model_count: 0,
has_claude_code_settings: false,
has_claude_oauth_token: false,
has_gateway_api_key: false,
has_gateway_master_key: false,
has_web_terminal_access_token: false,
enables_web_terminal: false,
ollama_base_url: null,
llamacpp_base_url: null,
openai_compatible_base_url: null,
gateway_api_base: null,
image_source: "registry",
custom_image_name: null,
...overrides,
};
}
describe("describeImport", () => {
it("always names the settings replacement, even with nothing else set", () => {
expect(describeImport(preview())).toEqual([
"Your global settings (all of them — this replaces what's here now)",
]);
});
it("singularizes a count of exactly one", () => {
const items = describeImport(preview({ custom_env_var_count: 1, gateway_model_count: 1 }));
expect(items).toContain("1 global custom env var");
expect(items).toContain("1 gateway model");
});
it("pluralizes counts greater than one", () => {
const items = describeImport(preview({ custom_env_var_count: 3, gateway_model_count: 2 }));
expect(items).toContain("3 global custom env vars");
expect(items).toContain("2 gateway models");
});
it("names every present secret and setting without naming absent ones", () => {
const items = describeImport(
preview({
has_claude_code_settings: true,
has_claude_oauth_token: true,
has_gateway_api_key: true,
has_gateway_master_key: true,
}),
);
expect(items).toContain("Global Claude Code settings");
expect(items).toContain("Your shared Claude login");
expect(items).toContain("The gateway provider API key");
expect(items).toContain("The gateway master key");
// None of the count-based items, since both counts are 0.
expect(items.some((i) => i.includes("env var"))).toBe(false);
expect(items.some((i) => i.includes("gateway model"))).toBe(false);
});
it("names the web terminal access token like any other present secret", () => {
const items = describeImport(preview({ has_web_terminal_access_token: true }));
expect(items).toContain("The web terminal access token");
});
it("names custom base URLs verbatim, since they're endpoints rather than secrets", () => {
const items = describeImport(
preview({
ollama_base_url: "http://10.0.0.5:11434",
gateway_api_base: "https://gateway.example/v1",
}),
);
expect(items).toContain("Ollama server: http://10.0.0.5:11434");
expect(items).toContain("Gateway upstream: https://gateway.example/v1");
expect(items.some((i) => i.includes("llama.cpp"))).toBe(false);
expect(items.some((i) => i.includes("OpenAI-compatible"))).toBe(false);
});
it("names a custom Docker image when set, falling back to a placeholder if unnamed", () => {
expect(
describeImport(preview({ image_source: "custom", custom_image_name: "ghcr.io/me/triple-c" })),
).toContain("Docker image: ghcr.io/me/triple-c");
expect(describeImport(preview({ image_source: "custom", custom_image_name: null }))).toContain(
"Docker image: (no image name set)",
);
expect(describeImport(preview({ image_source: "registry" })).some((i) => i.includes("Docker image"))).toBe(
false,
);
});
});
describe("describeImportWarnings", () => {
it("is empty when nothing about the import needs extra attention", () => {
expect(describeImportWarnings(preview())).toEqual([]);
});
it("warns when the import enables the web terminal, regardless of the token", () => {
// `enabled` and the token are independent — the warning is about the
// service turning on, whether or not a token came with it.
expect(describeImportWarnings(preview({ enables_web_terminal: true }))).toEqual([
"Enables the remote web terminal, which listens on your network.",
]);
expect(
describeImportWarnings(
preview({ enables_web_terminal: true, has_web_terminal_access_token: true }),
),
).toHaveLength(1);
});
it("warns about a dormant web terminal token even while the terminal stays off", () => {
expect(describeImportWarnings(preview({ has_web_terminal_access_token: true }))).toEqual([
"Includes a web terminal access token that will activate the next time the web terminal is turned on.",
]);
});
it("warns about a custom Docker image every time, not only when it changes", () => {
expect(
describeImportWarnings(preview({ image_source: "custom", custom_image_name: "evil:latest" })),
).toEqual(["Runs every project container from a custom Docker image: evil:latest."]);
expect(describeImportWarnings(preview({ image_source: "registry" }))).toEqual([]);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { SettingsImportPreview } from "./types";
/** Named things a `SettingsImportPreview` says an import will change, for
* `ImportSettingsModal`'s confirmation list. Does not include anything
* `describeImportWarnings` covers those get their own, more visible
* treatment rather than blending into this list. */
export function describeImport(preview: SettingsImportPreview): string[] {
const items: string[] = ["Your global settings (all of them — this replaces what's here now)"];
if (preview.custom_env_var_count > 0) {
items.push(
`${preview.custom_env_var_count} global custom env var${preview.custom_env_var_count === 1 ? "" : "s"}`,
);
}
if (preview.has_claude_code_settings) items.push("Global Claude Code settings");
if (preview.gateway_model_count > 0) {
items.push(`${preview.gateway_model_count} gateway model${preview.gateway_model_count === 1 ? "" : "s"}`);
}
if (preview.has_claude_oauth_token) items.push("Your shared Claude login");
if (preview.has_gateway_api_key) items.push("The gateway provider API key");
if (preview.has_gateway_master_key) items.push("The gateway master key");
if (preview.has_web_terminal_access_token) items.push("The web terminal access token");
if (preview.ollama_base_url) items.push(`Ollama server: ${preview.ollama_base_url}`);
if (preview.llamacpp_base_url) items.push(`llama.cpp server: ${preview.llamacpp_base_url}`);
if (preview.openai_compatible_base_url) {
items.push(`OpenAI-compatible server: ${preview.openai_compatible_base_url}`);
}
if (preview.gateway_api_base) items.push(`Gateway upstream: ${preview.gateway_api_base}`);
if (preview.image_source === "custom") {
items.push(`Docker image: ${preview.custom_image_name ?? "(no image name set)"}`);
}
return items;
}
/**
* Things about an import that deserve more attention than a bullet in a
* long list deliberately its own function rather than a flag inside
* `describeImport`: a setting that turns on a network-listening service is
* exactly the kind of change a "your settings were replaced" summary is bad
* at surfacing, on purpose or (if the file came from someone else) not.
*
* A token that arrives with the terminal left *off* gets its own warning
* too, distinct from the "enables it now" one: `start_web_terminal` only
* mints a fresh token when none is already set, so a planted token here
* would silently become live the next time someone flips the terminal on
* through the UI, with no import-time signal that it wasn't freshly
* generated.
*
* A custom Docker image gets a warning every time, not just on change: it's
* the image every project container is created from, so it's worth calling
* out regardless of what was configured before the import.
*/
export function describeImportWarnings(preview: SettingsImportPreview): string[] {
const warnings: string[] = [];
if (preview.enables_web_terminal) {
warnings.push("Enables the remote web terminal, which listens on your network.");
} else if (preview.has_web_terminal_access_token) {
warnings.push(
"Includes a web terminal access token that will activate the next time the web terminal is turned on.",
);
}
if (preview.image_source === "custom") {
warnings.push(
`Runs every project container from a custom Docker image: ${preview.custom_image_name ?? "(no image name set)"}.`,
);
}
return warnings;
}
+31 -22
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -7,15 +7,13 @@ export const checkImageExists = () => invoke<boolean>("check_image_exists");
export const buildImage = () => invoke<void>("build_image");
export const getContainerInfo = (projectId: string) =>
invoke<ContainerInfo | null>("get_container_info", { projectId });
export const listSiblingContainers = () =>
invoke<SiblingContainer[]>("list_sibling_containers");
// Projects
export const listProjects = () => invoke<Project[]>("list_projects");
export const addProject = (name: string, paths: ProjectPath[]) =>
invoke<Project>("add_project", { name, paths });
export const removeProject = (projectId: string) =>
invoke<void>("remove_project", { projectId });
invoke<ProjectRemovalReport>("remove_project", { projectId });
export const updateProject = (project: Project) =>
invoke<Project>("update_project", { project });
export const startProjectContainer = (projectId: string) =>
@@ -23,7 +21,7 @@ export const startProjectContainer = (projectId: string) =>
export const stopProjectContainer = (projectId: string) =>
invoke<void>("stop_project_container", { projectId });
export const rebuildProjectContainer = (projectId: string) =>
invoke<Project>("rebuild_project_container", { projectId });
invoke<ProjectResetOutcome>("rebuild_project_container", { projectId });
export const reconcileProjectStatuses = () =>
invoke<Project[]>("reconcile_project_statuses");
@@ -44,6 +42,15 @@ export const inspectCaCertPath = (path: string) =>
export const detectHostTimezone = () =>
invoke<string>("detect_host_timezone");
// Settings export/import — `false`/`null` mean the save/open dialog was
// dismissed, not an error.
export const exportSettings = (password: string) =>
invoke<boolean>("export_settings", { password });
export const previewSettingsImport = (password: string) =>
invoke<SettingsImportPreview | null>("preview_settings_import", { password });
export const applySettingsImport = (password: string) =>
invoke<SettingsImportOutcome>("apply_settings_import", { password });
// AWS
export const awsSsoRefresh = (projectId: string) =>
invoke<void>("aws_sso_refresh", { projectId });
@@ -71,25 +78,27 @@ export const stopAudioBridge = (sessionId: string) =>
// Files
export const listContainerFiles = (projectId: string, path: string) =>
invoke<FileEntry[]>("list_container_files", { projectId, path });
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
/**
* Save one container file to the host.
*
* The **backend** opens the save dialog, so this call cannot name a place on
* the host that is the point (see `pick_save_path` in `file_commands.rs`).
* Paths do come *back* inside error text; what is closed is the inbound
* direction.
* Resolves to the number of bytes written, or `null` if the user dismissed the
* dialog. Zero bytes is a success: an empty file is a file.
*/
export const downloadContainerFile = (projectId: string, containerPath: string) =>
invoke<number | null>("download_container_file", { projectId, containerPath });
/**
* Upload host files into `containerDir`, with the backend opening the file
* picker. Resolves to `null` if the user dismissed it, otherwise to what
* happened one dialog can select several files and they need not all succeed.
*/
export const uploadFilesToContainer = (projectId: string, containerDir: string) =>
invoke<UploadOutcome | null>("upload_files_to_container", { projectId, containerDir });
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
/**
* Copy a host file into a container directory.
*
* `overwrite` is opt-in because a drop is aimed with a mouse: the backend
* refuses by default when the name is already taken (see `lib/uploadErrors.ts`
* for the marker that refusal carries), and the caller re-runs with `true`
* only once the user has said "Replace" to that specific file. Leaving it off
* is the safe default every existing caller gets.
*/
export const uploadFileToContainer = (
projectId: string,
hostPath: string,
containerDir: string,
overwrite?: boolean,
) => invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir, overwrite });
export const readContainerFile = (projectId: string, path: string, maxBytes?: number) =>
invoke<FileContents>("read_container_file", { projectId, path, maxBytes });
/** `toPath` is the new *name*, not a destination — renames never move. */
+100 -15
View File
@@ -77,6 +77,37 @@ export type ProjectStatus =
| "stopping"
| "error";
/** What `removeProject` could not delete. The project is removed from the
* sidebar either way. When `retry_scheduled` is true, anything named here
* was recorded on the host and will be retried automatically the next time
* the app starts; when false, the record itself could not be saved and
* nothing will retry it. `retry_scheduled` is meaningless when nothing was
* left behind. */
export interface ProjectRemovalReport {
container: string | null;
image: string | null;
volumes: string[];
retry_scheduled: boolean;
}
/** True when a `ProjectRemovalReport` left nothing behind. Mirrors the
* Rust-side `ProjectRemovalReport::is_clean`. */
export function projectRemovalIsClean(report: ProjectRemovalReport): boolean {
return !report.container && !report.image && report.volumes.length === 0;
}
/** What Reset (`rebuildProjectContainer`) produced: the project as it stands
* after restarting, and any volume Reset could not clear which is reused
* as-is by the new container instead of starting clean. */
export interface ProjectResetOutcome {
project: Project;
/** The saved container image, if Reset could not remove it the new
* container is built from it whenever it exists, so this means Reset
* silently rebuilt the system layer it was asked to discard. */
leftover_image: string | null;
leftover_volumes: string[];
}
export type Backend =
| "anthropic"
| "bedrock"
@@ -162,23 +193,28 @@ export interface OpenAiCompatibleConfig {
* project that is "inherit the global value", and on the global settings it is
* "leave Claude Code's own default alone". `false` is a deliberate off, which
* is what lets a project turn a globally-enabled setting back off.
*
* Every field is optional as well as nullable: the Rust struct skips
* serialising a field it has no value for, so an object with nothing set at
* this level arrives as `{}`. Absent and `null` mean the same thing, which is
* why every read of one of these has to use `== null` rather than `=== null`.
*/
export interface ClaudeCodeSettings {
/** `null` = let Claude Code choose the renderer; `"default"` = classic, `"fullscreen"` = alt-screen. */
tui_mode: string | null;
tui_mode?: string | null;
/** `null` = unset, else `"low" | "medium" | "high" | "xhigh"`. Written as `effortLevel`. */
effort: string | null;
auto_scroll_disabled: boolean | null;
effort?: string | null;
auto_scroll_disabled?: boolean | null;
/** Written as `viewMode: "focus"`. */
focus_mode: boolean | null;
show_thinking_summaries: boolean | null;
focus_mode?: boolean | null;
show_thinking_summaries?: boolean | null;
/**
* Turns the session recap **off**. Held in the disabled sense because Claude
* Code's recap is on by default see the Rust doc on `ClaudeCodeSettings`.
*/
session_recap_disabled: boolean | null;
env_scrub: boolean | null;
prompt_caching_1h: boolean | null;
session_recap_disabled?: boolean | null;
env_scrub?: boolean | null;
prompt_caching_1h?: boolean | null;
}
export interface ContainerInfo {
@@ -188,13 +224,6 @@ export interface ContainerInfo {
image: string;
}
export interface SiblingContainer {
id: string;
names: string[] | null;
image: string;
state: string;
status: string;
}
export interface TerminalSession {
id: string;
@@ -263,6 +292,46 @@ export interface AppSettings {
global_claude_code_settings: ClaudeCodeSettings | null;
}
/** What `preview_settings_import` returns before anything is applied
* counts and presence flags only, never a secret value itself. Built from
* this, not from the raw import file, which the frontend never sees. */
export interface SettingsImportPreview {
exported_at: string;
app_version: string;
custom_env_var_count: number;
gateway_model_count: number;
has_claude_code_settings: boolean;
has_claude_oauth_token: boolean;
has_gateway_api_key: boolean;
has_gateway_master_key: boolean;
has_web_terminal_access_token: boolean;
/** Whether the import turns the web terminal on surfaced separately
* from the token above since either can be true without the other, and
* "this enables a service that listens on your network" must not hide
* inside a generic "settings replaced" summary. */
enables_web_terminal: boolean;
/** Non-blank custom base URLs the import would set endpoints, not
* secrets, so shown verbatim to disclose a redirect of model traffic. */
ollama_base_url: string | null;
llamacpp_base_url: string | null;
openai_compatible_base_url: string | null;
gateway_api_base: string | null;
/** Whether the import sets a custom Docker image, and its name if so
* this is the image every project container is created from, so worth
* more attention than an ordinary setting. */
image_source: ImageSource;
custom_image_name: string | null;
}
/** What `apply_settings_import` returns: the settings that were actually
* saved, plus a note for each keychain secret the import carried but could
* not be restored (a partial keychain failure must not read as unqualified
* success just because the settings half went through). */
export interface SettingsImportOutcome {
settings: AppSettings;
secret_restore_warnings: string[];
}
/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride
* in the payload rather than rejecting, so the field can render them inline
* while the user is still typing. */
@@ -367,6 +436,22 @@ export interface FileEntry {
permissions: string;
}
/**
* What one upload dialog's worth of files did mirrors `UploadOutcome` in
* `commands/file_commands.rs`.
*
* Two lists rather than a count and a flag, because one dialog can select
* several files and they do not have to agree: a folder among the selection, or
* a file over the size ceiling, must not cost the user the ones either side of
* it. Each `failures` entry is already a finished sentence naming its file.
*/
export interface UploadOutcome {
/** In-container paths, in the order they landed. */
uploaded: string[];
/** One sentence per file that did not. */
failures: string[];
}
/** A file read out of the container for the in-app viewer. */
export interface FileContents {
/** Base64 — a byte array would cross IPC as JSON numbers. */
-184
View File
@@ -1,184 +0,0 @@
import { describe, expect, it } from "vitest";
import {
errorText,
FILE_EXISTS_MARKER,
fileExistsPath,
isFileExistsError,
readableRefusal,
} from "./uploadErrors";
/**
* The shapes here are the point of the module.
*
* A Tauri command error crosses the IPC boundary as whatever `serde` made of
* it, and the Rust side is free to change from `Err(String)` to a serialised
* error enum without anyone thinking of this file. Every one of these has to
* keep meaning "that name is taken", or an upload that could have been
* retried with `overwrite: true` degrades into a raw string in a toast.
*/
describe("isFileExistsError", () => {
it("recognises the agreed prose form", () => {
expect(isFileExistsError("FILE_EXISTS: /workspace/notes.txt already exists")).toBe(true);
});
it("recognises a bare marker", () => {
expect(isFileExistsError(FILE_EXISTS_MARKER)).toBe(true);
});
it("recognises a serialised error enum, whatever case it is written in", () => {
expect(isFileExistsError({ kind: "FileExists", path: "/workspace/a.txt" })).toBe(true);
expect(isFileExistsError({ code: "file-exists" })).toBe(true);
expect(isFileExistsError({ type: "file_exists" })).toBe(true);
});
it("recognises it inside a message field", () => {
expect(isFileExistsError({ message: "upload refused: FILE_EXISTS" })).toBe(true);
expect(isFileExistsError(new Error("FILE_EXISTS: /workspace/a.txt"))).toBe(true);
});
it("looks one level into a wrapped error", () => {
expect(isFileExistsError({ error: { kind: "FileExists" } })).toBe(true);
});
it("says no to every other failure, which must not raise an overwrite prompt", () => {
expect(isFileExistsError("File too large to upload (900 MB; limit 256 MB)")).toBe(false);
expect(isFileExistsError("cp: cannot create regular file: Permission denied")).toBe(false);
expect(isFileExistsError({ kind: "NotRunning" })).toBe(false);
expect(isFileExistsError(null)).toBe(false);
expect(isFileExistsError(undefined)).toBe(false);
expect(isFileExistsError(42)).toBe(false);
expect(isFileExistsError({})).toBe(false);
});
it("cannot be forged by the name of the file being uploaded", () => {
// The one that mattered. Matching `fileexists` anywhere in a normalised
// error meant a host file called `file-exists.txt` turned *every* failure
// into a collision: the overwrite prompt appeared over a permission error,
// and Replace re-invoked the upload with `overwrite: true`, clobbering
// whatever shared that name in the container.
expect(
isFileExistsError("Failed to upload /host/file-exists.txt: Permission denied"),
).toBe(false);
expect(
isFileExistsError({
message: "cp: cannot create regular file '/workspace/FILE_EXISTS.txt'",
}),
).toBe(false);
expect(isFileExistsError("no space left on device: /host/File Exists.png")).toBe(
false,
);
// A path that merely ends in the marker is a path, not the marker.
expect(isFileExistsError("cannot stat /workspace/FILE_EXISTS: no such file")).toBe(
false,
);
// …while the contract's own shape still reads as the refusal it is.
expect(
isFileExistsError("FILE_EXISTS: /workspace/file-exists.txt already exists"),
).toBe(true);
});
it("still reads a wrapped error whose `error` field is a whole sentence", () => {
// `error` is listed as a discriminant field but routinely carries prose,
// so it is held to both standards.
expect(
isFileExistsError({ error: "FILE_EXISTS: /workspace/a.txt already exists" }),
).toBe(true);
expect(isFileExistsError({ error: "upload of file-exists.txt failed" })).toBe(false);
});
});
describe("fileExistsPath", () => {
it("reads the path out of the agreed prose form", () => {
expect(fileExistsPath("FILE_EXISTS: /workspace/notes.txt already exists")).toBe(
"/workspace/notes.txt",
);
});
it("prefers a structured field", () => {
expect(fileExistsPath({ kind: "FileExists", path: "/workspace/a.txt" })).toBe(
"/workspace/a.txt",
);
expect(fileExistsPath({ kind: "FileExists", container_path: "/workspace/b.txt" })).toBe(
"/workspace/b.txt",
);
});
it("finds one in a wrapped error", () => {
expect(fileExistsPath({ error: { kind: "FileExists", path: "/workspace/c.txt" } })).toBe(
"/workspace/c.txt",
);
});
it("returns null rather than guessing", () => {
// The caller falls back to the host path it was uploading, which is always
// known — so "no path" is a perfectly good answer.
expect(fileExistsPath("FILE_EXISTS")).toBeNull();
expect(fileExistsPath({ kind: "FileExists" })).toBeNull();
expect(fileExistsPath(null)).toBeNull();
});
});
/**
* The other half of the contract: refusals that are *not* a name clash, but are
* a sentence the backend wrote for the person reading it. They used to arrive
* as a toast's `detail`, which renders as collapsed monospace behind a
* "Details" button so the only part of the message that explained anything
* was the part nobody saw.
*/
describe("readableRefusal", () => {
const hidden =
'".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.';
const outside =
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
it("recognises the hidden-host-folder refusal, in both directions", () => {
expect(readableRefusal(hidden)).toBe(hidden);
expect(
readableRefusal('".aws" is a hidden folder — Triple-C will not read there. Choose a visible location.'),
).toContain("hidden folder");
});
it("recognises the container write-root refusal", () => {
expect(readableRefusal(outside)).toBe(outside);
});
it("strips a wrapper a JS layer put in front of the sentence", () => {
// `invoke` rejects with the bare string today, but an `Error` anywhere in
// between would otherwise put "Error: " in front of prose meant to be read.
expect(readableRefusal(new Error(hidden))).toBe(hidden);
expect(readableRefusal(`Error: ${hidden}`)).toBe(hidden);
expect(readableRefusal(`Uncaught (in promise) Error: ${outside}`)).toBe(outside);
expect(readableRefusal({ message: `invoke failed: ${outside}` })).toBe(outside);
});
it("says nothing about failures that are not a written refusal", () => {
// Promotion is an improvement, not a fallback: anything unrecognised keeps
// reporting exactly as it did before.
expect(readableRefusal("File too large to upload (900 MB; limit 256 MB)")).toBeNull();
expect(readableRefusal("FILE_EXISTS: /workspace/a.txt already exists")).toBeNull();
expect(readableRefusal("cp: Permission denied")).toBeNull();
expect(readableRefusal(null)).toBeNull();
});
});
describe("errorText", () => {
it("keeps an ordinary message intact", () => {
expect(errorText("cp: cannot create regular file: Permission denied")).toBe(
"cp: cannot create regular file: Permission denied",
);
});
it("reads a message out of a shape `String()` would render as [object Object]", () => {
expect(errorText({ message: "Container not running" })).toBe("Container not running");
expect(errorText({ kind: "NotRunning" })).toBe("NotRunning");
expect(errorText(new Error("Failed to upload file to container: no space left"))).toBe(
"Failed to upload file to container: no space left",
);
});
it("prefers the written refusal when there is one", () => {
expect(errorText(new Error("Folder path is outside the folders this panel can change (/workspace): /etc"))).toBe(
"Folder path is outside the folders this panel can change (/workspace): /etc",
);
});
});
-264
View File
@@ -1,264 +0,0 @@
/**
* The one place the frontend agrees with Rust about "that name is taken".
*
* `upload_file_to_container` used to clobber whatever was already at the
* destination, which is the wrong default for a drop: a drag is aimed with a
* mouse, and the file it lands on is frequently not the file the user meant to
* replace. So the backend refuses by default and the frontend asks but only
* if it can tell *this* refusal apart from "permission denied" or "no space
* left", because an overwrite prompt raised over an unrelated failure would
* offer a button that cannot possibly work.
*
* **This module is the contract point, and the Rust half has to hold up its
* end**: `upload_file_to_container` must put `FILE_EXISTS_MARKER` in the error
* it returns when the destination already exists, ideally in the agreed shape
*
* FILE_EXISTS: /workspace/notes.txt already exists
*
* and must accept an `overwrite: bool` argument that skips the check. Nothing
* here parses a human sentence the marker is the whole agreement, and the
* path is a bonus that is only used to name the file in the prompt.
*
* The predicate is deliberately tolerant about the *shape* of the error rather
* than its wording, because a Tauri command error crosses the IPC boundary as
* whatever `serde` made of it: a bare string from `Err(String)`, an object from
* a `#[derive(Serialize)]` error enum, or an `Error` if a JS layer wrapped it
* on the way through. All three are the same refusal, and the UI must not
* behave differently depending on which one a future refactor produces.
*
* **Tolerant about shape is not the same as tolerant about content.** This
* used to normalise the whole error (lower-case, `_`/`-` stripped) and ask
* whether `fileexists` appeared *anywhere* in it which a host file named
* `file-exists.txt` satisfies on its way through any error at all. Uploading
* that file and hitting "permission denied" therefore raised the overwrite
* prompt, and answering Replace re-invoked the upload with `overwrite: true`:
* an unrelated failure silently promoted into an overwrite of whatever shared
* the name in the container. So the marker now has to appear in a form a
* *filename* cannot produce:
*
* - in prose, the canonical `FILE_EXISTS` (or `FILE-EXISTS`) in upper case,
* standing alone end of string, or followed by the `:`/`=` of the agreed
* `FILE_EXISTS: <path>` form. `file-exists.txt`, `FILE_EXISTS.txt` and
* `/workspace/FILE_EXISTS` all fail that, because a filename brings its own
* extension, quote or path separator along with it.
* - in a discriminant field, the *whole* value, case- and separator-insensitive
* (`FileExists`, `file_exists`, `file-exists`, `FileExistsError`) a
* discriminant is a variant name, not a sentence, so equality is the right
* test and a filename never gets to be one.
*/
/** Marker the backend puts in the error for "a file with this name is already there". */
export const FILE_EXISTS_MARKER = "FILE_EXISTS";
/**
* Structured error shapes carry the marker in a discriminant rather than in
* prose. These are the field names a serialised Rust error realistically uses;
* matching is case-insensitive and ignores `_`/`-` so `FileExists`,
* `file_exists` and `FILE-EXISTS` all read as the same variant.
*/
const KIND_FIELDS = ["kind", "code", "type", "error", "reason"] as const;
const MESSAGE_FIELDS = ["message", "msg", "detail", "description"] as const;
const PATH_FIELDS = ["path", "container_path", "containerPath", "target", "file"] as const;
/** `FileExists` / `file-exists` / `FILE_EXISTS` all normalise to `fileexists`. */
function normaliseKind(value: string): string {
return value.toLowerCase().replace(/[\s_-]/g, "");
}
const KIND_NEEDLE = normaliseKind(FILE_EXISTS_MARKER);
/**
* The marker standing on its own inside a sentence.
*
* Derived from `FILE_EXISTS_MARKER` so the two cannot drift. Upper case is
* load-bearing (a lower-case `file-exists` is a plausible filename, the
* upper-case token is not), and so is the lookahead: the marker must end the
* string or be followed by the `:`/`=` that introduces the path. That is what
* a path or a filename cannot forge `FILE_EXISTS.txt`, `"FILE_EXISTS"` and
* `/workspace/FILE_EXISTS` are each rejected by one end or the other.
*/
const PROSE_MARKER = new RegExp(
`(?:^|[\\s:;(\\[{"'\`])${FILE_EXISTS_MARKER.replace(/_/g, "[_-]")}(?=$|[\\s:=])`,
);
/** A discriminant *is* the refusal, rather than mentioning it. */
function isFileExistsDiscriminant(value: string): boolean {
const normalised = normaliseKind(value);
return normalised === KIND_NEEDLE || normalised === `${KIND_NEEDLE}error`;
}
function asRecord(e: unknown): Record<string, unknown> | null {
return typeof e === "object" && e !== null ? (e as Record<string, unknown>) : null;
}
/**
* Every string an error carries, flattened: the error itself if it is one, its
* message-ish fields, and its kind-ish fields. Nesting is followed one level
* because a wrapped error (`{ error: { kind: … } }`) is the same refusal.
*/
function stringsIn(e: unknown, depth = 0): string[] {
const { prose, kinds } = partitionStrings(e, depth);
return [...prose, ...kinds];
}
/**
* The same flattening, but keeping track of *where* each string came from.
*
* A discriminant field and a message field are held to different standards
* (see the module comment), so they cannot be pooled. `error` is listed as a
* discriminant field and yet routinely carries a whole sentence, which is why
* a kind string is tested against both rules and a prose string only against
* the prose one.
*/
function partitionStrings(
e: unknown,
depth = 0,
): { prose: string[]; kinds: string[] } {
if (typeof e === "string") return { prose: [e], kinds: [] };
if (e instanceof Error) return { prose: [e.message], kinds: [e.name] };
const record = asRecord(e);
if (!record || depth > 1) return { prose: [], kinds: [] };
const prose: string[] = [];
const kinds: string[] = [];
const walk = (value: unknown, into: string[]) => {
if (typeof value === "string") into.push(value);
else if (value !== undefined) {
const nested = partitionStrings(value, depth + 1);
prose.push(...nested.prose);
kinds.push(...nested.kinds);
}
};
for (const field of KIND_FIELDS) walk(record[field], kinds);
for (const field of MESSAGE_FIELDS) walk(record[field], prose);
return { prose, kinds };
}
/**
* True when the backend refused an upload because the destination is taken.
*
* Accepts a bare string, an `Error`, or an object with a `kind`/`code`
* discriminant or a `message` see the module comment for why all three have
* to work.
*/
export function isFileExistsError(e: unknown): boolean {
const { prose, kinds } = partitionStrings(e);
return (
kinds.some((s) => isFileExistsDiscriminant(s) || PROSE_MARKER.test(s)) ||
prose.some((s) => PROSE_MARKER.test(s))
);
}
/**
* The container path the conflict is about, when the error carries one used
* only to name the file in the prompt, so `null` is a perfectly good answer
* and the caller falls back to the host path it was uploading.
*/
export function fileExistsPath(e: unknown): string | null {
const record = asRecord(e);
if (record) {
for (const field of PATH_FIELDS) {
const value = record[field];
if (typeof value === "string" && value.length > 0) return value;
}
// One level down, for `{ error: { path } }`.
for (const field of KIND_FIELDS) {
const nested = fileExistsPath(record[field]);
if (nested) return nested;
}
}
for (const s of stringsIn(e)) {
// The agreed prose form: `FILE_EXISTS: <path>` — everything up to the
// first space after the marker.
const match = new RegExp(`${FILE_EXISTS_MARKER}\\s*[:=]\\s*(\\S+)`).exec(s);
if (match) return match[1];
}
return null;
}
/**
* What the user answered to one conflict. The blanket answers exist because a
* ten-file drop onto a populated directory is ten prompts otherwise, which is
* the kind of dialog people dismiss without reading.
*/
export type OverwriteChoice = "replace" | "skip" | "replace-all" | "skip-all";
/**
* Fragments that identify a refusal the backend already wrote **for a person**.
*
* The file commands guard two policies that a user can trip over by accident,
* and both answer with a finished sentence that names the offending path and
* says what to do instead:
*
* ".ssh" is a hidden folder Triple-C will not save there. Choose a visible location.
* Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc
*
* Those sentences were being used as the *detail* of a generic toast
* ("A file could not be uploaded"), and `ToastHost` renders a detail as
* collapsed monospace behind a "Details" button so the one part of the
* message that explained anything was the part nobody saw. Matching them here
* lets the caller promote the sentence to the toast's headline.
*
* Matched on a stable fragment rather than the whole string, because the path
* and the verb ("save"/"read", "file"/"folder") vary per call. Deliberately a
* short list: an error that is *not* recognised still reports exactly as it
* did before, so a wrong guess here can only fail to promote, never mangle.
*/
const REFUSAL_MARKERS = [
// `validate_host_path` — hidden host component, and system locations.
"Triple-C will not",
// `validate_container_write_path` — outside /workspace, /home/claude, /tmp.
"outside the folders this panel can change",
] as const;
/**
* `Error: …`, `TypeError: …`, `invoke failed: …` wrappers a JS layer may have
* put in front of the backend's sentence on the way through. Stripped so the
* prose starts where the backend started it; applied twice at most, because a
* doubly-wrapped error is the realistic worst case and looping on user text is
* not.
*/
const WRAPPER_PREFIX = /^(?:uncaught\s*(?:\(in promise\)\s*)?)?(?:[a-z]*error|invoke(?:\s+failed)?)\s*:\s*/i;
function stripWrapper(text: string): string {
let out = text.trim();
for (let i = 0; i < 2; i++) {
const next = out.replace(WRAPPER_PREFIX, "").trim();
if (next === out) break;
out = next;
}
return out;
}
/**
* The backend's own user-facing sentence, when this failure is one otherwise
* `null`, and the caller reports it however it reported everything else.
*/
export function readableRefusal(e: unknown): string | null {
for (const s of stringsIn(e)) {
const text = stripWrapper(s);
if (REFUSAL_MARKERS.some((marker) => text.includes(marker))) return text;
}
return null;
}
/**
* The most human form of any failure, for the places that show one verbatim.
*
* `String(e)` is what these used to be, which turns a serialised error object
* into `[object Object]` and leaves a JS wrapper prefix on a sentence that
* reads perfectly well without it.
*/
export function errorText(e: unknown): string {
const readable = readableRefusal(e);
if (readable) return readable;
if (typeof e === "string") return stripWrapper(e);
if (e instanceof Error) return stripWrapper(e.message);
const record = asRecord(e);
if (record) {
for (const field of [...MESSAGE_FIELDS, ...KIND_FIELDS]) {
const value = record[field];
if (typeof value === "string" && value.trim().length > 0) return stripWrapper(value);
}
}
return String(e);
}
+33 -11
View File
@@ -454,27 +454,48 @@ fi
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
# volume, so without this the container keeps trying to run the SSO refresh even
# after switching to a non-SSO backend (Anthropic/Ollama) or to static creds.
# Replace ~/.claude.json atomically: write a sibling temp file, then rename.
#
# `> "$CLAUDE_JSON"` truncates before it writes, so a write that fails part-way
# — a full home volume being the obvious way, and bounding that volume is what
# half this release is about — leaves the file unparseable. It holds the OAuth
# account, and the damage does not self-heal: the next start's `jq` fails on the
# corrupt file, `MERGED` comes back empty, and the `[ -n "$MERGED" ]` guard
# skips the write that would have repaired it. `triple-c-task-runner` has done
# it this way all along.
write_claude_json() {
_wcj_tmp="${CLAUDE_JSON}.triple-c-tmp"
if printf '%s\n' "$1" > "$_wcj_tmp" 2>/dev/null; then
mv -f "$_wcj_tmp" "$CLAUDE_JSON" 2>/dev/null || rm -f "$_wcj_tmp"
else
rm -f "$_wcj_tmp"
echo "entrypoint: warning — could not write $CLAUDE_JSON (leaving it as it was)"
return 1
fi
# By name, after the rename, so these land on the new inode.
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
}
CLAUDE_JSON="/home/claude/.claude.json"
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
if [ -f "$CLAUDE_JSON" ]; then
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
write_claude_json "$MERGED"
fi
else
printf '{"awsAuthRefresh":"%s"}\n' "$AWS_SSO_AUTH_REFRESH_CMD" > "$CLAUDE_JSON"
# No existing file, so there is nothing to destroy — but go through the
# same helper so the owner and mode are set in one place.
write_claude_json "$(printf '{"awsAuthRefresh":"%s"}' "$AWS_SSO_AUTH_REFRESH_CMD")"
fi
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
unset AWS_SSO_AUTH_REFRESH_CMD
elif [ -f "$CLAUDE_JSON" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
# Only rewrite when the key is actually present, to avoid a needless jq
# reformat of ~/.claude.json on every start of a non-SSO backend.
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
write_claude_json "$MERGED"
fi
fi
@@ -491,16 +512,17 @@ if [ -f "$CLAUDE_JSON" ]; then
# Only rewrite when the value isn't already true, to avoid a needless jq
# reformat of ~/.claude.json on every single start.
if ! grep -q '"shiftEnterKeyBindingInstalled"[[:space:]]*:[[:space:]]*true' "$CLAUDE_JSON" 2>/dev/null; then
# Atomic, via `write_claude_json` — see its comment for why a plain
# `>` on this file can permanently destroy the OAuth login.
MERGED=$(jq '.shiftEnterKeyBindingInstalled = true' "$CLAUDE_JSON" 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
write_claude_json "$MERGED"
fi
fi
else
printf '{"shiftEnterKeyBindingInstalled":true}\n' > "$CLAUDE_JSON"
# Nothing to destroy, but the helper owns the owner/mode too.
write_claude_json '{"shiftEnterKeyBindingInstalled":true}'
fi
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
# ── Docker socket permissions ────────────────────────────────────────────────
if [ -S /var/run/docker.sock ]; then
+86
View File
@@ -0,0 +1,86 @@
# Maintainer: Triple-C Contributors
#
# This file is regenerated by .gitea/workflows/publish-arch-package.yml on every
# publish — pkgver, the source URL and sha256sums are rewritten from the real,
# already-uploaded release asset, never guessed. Editing pkgver/source/
# sha256sums by hand here only matters until the next automated run overwrites
# them; everything else (depends, pkgdesc, package()) is meant to be hand-
# maintained normally.
#
# "-bin" rather than building from source: this repackages the same .deb
# build-app.yml already produces and publishes, so a user gets exactly the
# binary the project ships and tests, and `makepkg` never needs a Rust
# toolchain, Node, or the dozen -dev packages CLAUDE.md lists for building
# Triple-C itself. The trade-off is the one every "-bin" package makes: it
# assumes the glibc the CI runner (Ubuntu 24.04) linked against is compatible
# with the installing system's — true for essentially every currently
# supported Arch install, since Arch tracks glibc newer than Ubuntu 24.04
# ships, and forward compatibility is the direction that holds.
pkgname=triple-c-bin
pkgver=0.4.0
pkgrel=1
pkgdesc="Sandbox Claude Code inside Docker containers"
arch=('x86_64')
url="https://github.com/shadowdao/triple-c"
license=('MIT')
# Verified against a real release asset (v0.4.14), not Tauri's generic docs:
# downloaded Triple-C_0.4.14_amd64.deb, installed each of these into a real
# Arch container, and re-ran `ldd` on the actual binary until nothing came
# back "not found". `pango` and `libayatana-appindicator` were both in an
# earlier draft — pango isn't directly linked (gtk3 already pulls it in
# transitively, and namcap correctly flags declaring it as redundant), and
# libayatana-appindicator is in Tauri's own linux dependency list but this
# binary never links it at all: there is no tray icon or menu in this app
# (see CLAUDE.md's note that `core:menu`/`core:tray` are dropped for the
# same reason), so it was never a real dependency to begin with.
depends=('cairo' 'desktop-file-utils' 'gdk-pixbuf2' 'glib2' 'gtk3'
'hicolor-icon-theme' 'libsoup3' 'webkit2gtk-4.1')
optdepends=('docker: to actually run the sandboxed containers'
'xdg-utils: opening links from the app in your default browser')
provides=('triple-c')
conflicts=('triple-c')
# !strip: the upstream .deb's binary is already the release build Tauri
# produced and tested; re-stripping a prebuilt binary is unnecessary risk for
# no benefit. It's also what actually suppresses makepkg's debug-package
# machinery here (debug-package extraction requires strip; verified in a
# real build — with !strip alone, no debug package is produced at all).
# !debug is kept anyway, explicit about intent rather than relying on that
# side effect. Without either, makepkg built a usr/src/debug/triple-c-bin
# tree containing a dangling .build-id symlink, which is a real namcap
# error (not just the empty-directory warning it looks like) — there is no
# debug info in this release binary for the machinery to have extracted in
# the first place.
options=('!strip' '!debug')
# Tauri names the asset after `productName` verbatim ("Triple-C"), not the
# lowercase Cargo binary name — verified against the real release, not
# assumed; a lowercase guess here would 404. The LICENSE fetch is separate
# because the .deb itself carries no license file — namcap flags an MIT
# package with nothing under /usr/share/licenses/ as an error, correctly.
source=("Triple-C_${pkgver}_amd64.deb::https://github.com/shadowdao/triple-c/releases/download/v${pkgver}/Triple-C_${pkgver}_amd64.deb"
"LICENSE::https://raw.githubusercontent.com/shadowdao/triple-c/v${pkgver}/LICENSE")
sha256sums=('SKIP'
'SKIP')
package() {
cd "$srcdir"
# A .deb is an ar archive of debian-binary, control.tar.*, data.tar.* — `ar`
# (part of base-devel's binutils) pulls just the payload out. Extracting
# that tar directly into $pkgdir works here with no path rewriting at all:
# verified against the real archive, whose entire payload is
# usr/bin/triple-c, usr/share/applications/Triple-C.desktop and
# usr/share/icons/hicolor/*/apps/triple-c.png — Tauri's Linux bundle for
# this app carries no separate resource directory under usr/lib/, so there
# is nothing that could disagree between Debian's and Arch's package trees
# for it to land in the wrong place.
#
# Globbed rather than named literally: the publish workflow discovers the
# real asset name from the release itself specifically so a Tauri bundler
# naming change can't silently break this — naming the file again here
# would throw that away and fail this one line with an opaque "No such
# file or directory" instead. `source=()` above guarantees exactly one
# `*_amd64.deb` entry, so the glob can only ever match that one file.
ar x ./*_amd64.deb
tar xf data.tar.* -C "$pkgdir"
install -Dm644 "$srcdir/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+52
View File
@@ -0,0 +1,52 @@
# Arch / CachyOS package
`PKGBUILD` here is the `triple-c-bin` package's template — see triple-c#34
(the "I would like to also have an Arch/CachyOS native version" part of it).
It's written to AUR conventions (and may go there eventually — see
"Publishing" below) but isn't published to the AUR yet.
## Why "-bin"
It repackages the same `.deb` `build-app.yml` already produces, rather than
building from source. That means `makepkg` never needs a Rust toolchain,
Node, or the dozen `-dev` packages CLAUDE.md lists for building Triple-C
itself — and a user gets exactly the binary the project ships and tests,
built on Ubuntu 24.04 in CI. Verified end to end against a real release
(v0.4.14): downloaded the actual `.deb`, confirmed every `depends` entry
against a real `ldd` of the actual binary (two packages that looked right
from Tauri's own docs — `pango`, `libayatana-appindicator` — turned out not
to be real dependencies of *this* binary and were dropped), and ran a real
`makepkg`/`namcap`/`pacman -U` cycle rather than guessing at the shape.
## Publishing
`.gitea/workflows/publish-arch-package.yml` does the actual work: given a
version (or "latest" if none is given), it finds that release's real Linux
asset on GitHub, downloads it, computes real checksums, renders this
template into a version-specific PKGBUILD, validates it with `makepkg` and
`namcap` inside a real Arch container, and attaches the resulting
`.pkg.tar.zst` to that same GitHub release as a downloadable asset —
installable by hand with `sudo pacman -U`.
It is `workflow_dispatch`-only, deliberately — see the workflow file's own
header comment for why an automatic trigger isn't safe here (the same reason
`sync-release.yml` didn't work and was removed in triple-c#32).
**Not on the AUR yet.** Publishing there would need a maintainer AUR account
and its SSH key added as a secret on this repo — both manual, one-time steps
on https://aur.archlinux.org that only a maintainer can do. The workflow's
git history still has the AUR-push step from before this was descoped, if
that setup happens later and it's worth reinstating.
## What's hand-maintained vs. generated
`pkgver`/`pkgrel`/`source`/`sha256sums` in this file are placeholders —
the workflow rewrites them for every real publish and never commits the
result back here, so don't read this file's `pkgver` as "the last published
version." Everything else (`depends`, `pkgdesc`, `package()`) is meant to be
edited by hand normally, the same as any other PKGBUILD.
**A hand-edit made to the rendered PKGBUILD attached to a GitHub release is
not this file.** Every run renders fresh from *this* repo's template, so a
packaging fix belongs here, not in a downloaded copy — the next dispatch for
that version would just overwrite it anyway.
+111
View File
@@ -0,0 +1,111 @@
#!/bin/sh
# Refuse to let a live credential into the repository.
#
# Written after one got in: `the_custom_env_fingerprint_never_carries_the_value`
# used the maintainer's real Gitea site-admin token as its fixture. It survived
# 92 commits and fourteen days in a public mirror, past five audit rounds and two
# independent reviews — because every one of those looked at the code under
# change, and this sat in a test nobody had reason to open. A grep would have
# caught it on the first day. This is that grep.
#
# Usage:
# scan-secrets.sh --staged what `git commit` is about to record (the hook)
# scan-secrets.sh --range A..B every line added between two commits (CI)
# scan-secrets.sh --tracked every tracked file, as it stands now
#
# Exit 0 clean, 1 on a finding, 2 on misuse.
#
# ## Why it scans *added lines* and not the whole file
#
# The repository already contains long opaque strings — 317 literals of 32+
# characters, almost all of them legitimate. A scanner that failed on those would
# be turned off within a day, which is the normal way this kind of check dies.
# Judging only what a commit *adds* keeps the signal where a person can act on it.
#
# ## Escape hatch
#
# A line carrying `pragma: allowlist secret` is skipped. Deliberately wordy: it
# should be uncomfortable enough to type that it is read as a claim, and it
# leaves something greppable behind.
set -eu
MODE="${1:---staged}"
RANGE="${2:-}"
case "$MODE" in
--staged) ADDED=$(git diff --cached --unified=0 --no-color -- . 2>/dev/null || true) ;;
--range) [ -n "$RANGE" ] || { echo "scan-secrets: --range needs A..B" >&2; exit 2; }
ADDED=$(git diff --unified=0 --no-color "$RANGE" -- . 2>/dev/null || true) ;;
--tracked)
# `grep -Iq .` first: without it a binary blob's bytes reach the
# rules below, and GNU grep answers "binary file matches" instead
# of the line — so a real finding inside one would be reported as
# a sentence nobody can act on, and a stray NUL can end the scan
# early. Text files only; binaries are not where source secrets
# live, and `--staged` never sees them either (git emits
# "Binary files differ", not content).
ADDED=$(git ls-files -z \
| xargs -0 -I{} sh -c 'grep -Iq . "{}" 2>/dev/null && sed "s/^/+/" "{}" 2>/dev/null' \
|| true) ;;
*) echo "scan-secrets: unknown mode $MODE" >&2; exit 2 ;;
esac
# Only added lines; drop diff headers (+++ b/path) so a filename never matches.
CANDIDATES=$(printf '%s\n' "$ADDED" \
| grep '^+' \
| grep -v '^+++' \
| grep -v 'pragma: allowlist secret' \
|| true)
[ -n "$CANDIDATES" ] || exit 0
FOUND=0
report() {
FOUND=1
printf '\n %s\n' "$1"
printf '%s\n' "$2" | sed 's/^/ /' | cut -c1-160
}
# --- Rule 1: vendor-issued credentials. Shape alone identifies these, so there
# --- is no false-positive story to tell and no identifier context needed.
VENDOR=$(printf '%s\n' "$CANDIDATES" | grep -nE \
'gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9]{32,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|ya29\.[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{35}|npm_[A-Za-z0-9]{36}|dckr_pat_[A-Za-z0-9_-]{20,}' \
|| true)
[ -n "$VENDOR" ] && report "A vendor-issued credential (its prefix identifies the provider):" "$VENDOR"
# --- Rule 2: private key material.
KEYS=$(printf '%s\n' "$CANDIDATES" | grep -nE -- '-----BEGIN [A-Z ]*PRIVATE KEY-----' || true)
[ -n "$KEYS" ] && report "Private key material:" "$KEYS"
# --- Rule 3: an opaque literal assigned to a secret-shaped name.
#
# This is the rule that would have caught the Gitea token — `let secret =
# "<40 hex>"`. Both halves are required, and that is what keeps it usable:
# the *name* must read as a credential, and the *whole literal* must be hex or
# base64 with no word structure. `"aws-secret-access-key"` is a keychain key
# name sitting right next to the word `secret`, and its hyphens are what keep it
# out; measured against the tree, name-proximity alone flagged four lines of
# which three were that shape.
OPAQUE=$(printf '%s\n' "$CANDIDATES" | grep -niE \
'(secret|token|api[_-]?key|apikey|passwo?rd|passwd|credential|auth[_-]?(key|token))[^A-Za-z0-9]{0,12}[:=][^"'"'"']{0,12}["'"'"']([0-9a-fA-F]{32,}|[A-Za-z0-9+/]{40,}={0,2})["'"'"']' \
|| true)
[ -n "$OPAQUE" ] && report "An opaque literal assigned to a secret-shaped name:" "$OPAQUE"
if [ "$FOUND" -eq 1 ]; then
cat >&2 <<'MSG'
────────────────────────────────────────────────────────────────────────
Refusing the commit: it adds something shaped like a live credential.
If it IS live: do not amend and move on. Rotate it first — anything that
reaches a branch is on the mirror, and the mirror is public.
If it is genuinely not a secret — a fixture, a public key id, test data —
make the literal obviously fake ("not-a-real-token-0000…"), or append
`pragma: allowlist secret` to the line to say so on the record.
────────────────────────────────────────────────────────────────────────
MSG
exit 1
fi
exit 0