Compare commits

...
Author SHA1 Message Date
jknapp 88d6bed6db Merge pull request 'Document the Wayland icon-cache-needs-relogin gotcha' (#45) from docs/wayland-icon-cache-note into main
Secret Scan / scan (push) Successful in 6s
2026-08-27 23:15:00 +00:00
shadow-test 6cc48b3266 Document the Wayland icon-cache-needs-relogin gotcha
Secret Scan / scan (push) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
A user hit this after installing the new Arch/CachyOS package (triple-c#34):
icon missing in the app menu, taskbar, and titlebar alike, with no error
in the app's own log. Root cause has nothing to do with the app or its
packaging — GNOME/KDE cache the installed-app list and resolved icons in
the shell process's memory at startup, and Wayland has no equivalent to
X11's soft shell-restart trick to force a live reload. Logging out and
back in fixed it for them.
2026-08-27 15:53:19 -07:00
jknapp 0fad306c25 Merge pull request 'Add an Installation section to HOW-TO-USE.md' (#43) from docs/installation-instructions into main
Secret Scan / scan (push) Successful in 6s
2026-08-27 22:37:19 +00:00
jknapp 8beb62b12c Merge pull request 'Mirror the Arch package to the Gitea release too' (#44) from fix/arch-package-mirror-to-gitea into main
Secret Scan / scan (push) Successful in 4s
2026-08-27 22:21:58 +00:00
shadow-test f2cfc0be8f Also attach the Arch package to the matching Gitea release
Secret Scan / scan (push) Successful in 10s
Secret Scan / scan (pull_request) Successful in 7s
The workflow only ever uploaded to the GitHub release — the Gitea release
for the same version (the plain, unsuffixed vX.Y.Z tag build-app.yml's
Linux job creates, which already holds the .deb/.rpm/.AppImage) never got
it, so it looked missing to anyone checking releases on Gitea instead of
GitHub.

New step mirrors build-app.yml's own Gitea upload step exactly: same
get-or-create-by-tag, delete-existing-asset, upload-as-octet-stream shape,
same REGISTRY_TOKEN secret. Verified the read side (release lookup, asset
listing) against the real v0.4.16 release before writing this — resolves
to the correct release id and correctly finds no existing asset yet.
2026-08-27 15:14:54 -07:00
shadow-test 99c9dd3cc2 Add an Installation section — nothing told a new user how to get the app
Secret Scan / scan (push) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
HOW-TO-USE.md's Prerequisites jumped straight to Docker and a Claude Code
account, assuming Triple-C was already installed; the app itself had no
download/install instructions anywhere in the docs. Covers all six release
assets, including the new Arch/CachyOS .pkg.tar.zst (triple-c#34) that
publish-arch-package.yml now attaches to each release.
2026-08-27 15:06:43 -07:00
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
28 changed files with 2954 additions and 31 deletions
+368
View File
@@ -0,0 +1,368 @@
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 attaches
# the resulting `.pkg.tar.zst` — installable by hand with `pacman -U` — to
# *both* the GitHub release it was built from and the corresponding Gitea
# release (the plain, unsuffixed `vX.Y.Z` tag build-app.yml's Linux job
# creates; the `-win`/`-mac` suffixed Gitea releases are a different tag and
# don't get this asset). 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
GITEA_URL: ${{ gitea.server_url }}
REPO: ${{ gitea.repository }}
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} on GitHub"
- name: Attach the package to the Gitea release
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
TAG: ${{ steps.resolve.outputs.tag }}
PKG_FILE: ${{ steps.build.outputs.pkg_file }}
run: |
set -euo pipefail
# Same get-or-create-by-tag, delete-existing-asset,
# upload-as-octet-stream shape build-app.yml's own Gitea upload
# step already uses — this is expected to always hit the "reuse"
# branch, since build-app.yml's Linux job already created this
# exact release for this exact tag; the create fallback is here
# only so this doesn't hard-depend on that ordering.
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
case "${HTTP_CODE}" in
200)
echo "Release ${TAG} already exists on Gitea, reusing"
;;
404)
echo "Creating release ${TAG} on Gitea"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
;;
*)
echo "Unexpected ${HTTP_CODE} looking up release ${TAG} on Gitea:" >&2
cat release.json >&2
exit 1
;;
esac
RELEASE_ID=$(python3 -c "import json; print(json.load(open('release.json')).get('id',''))")
if [ -z "${RELEASE_ID}" ]; then
echo "No Gitea release id for ${TAG}; refusing to upload into nothing:" >&2
cat release.json >&2
exit 1
fi
EXISTING_ID=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${PKG_FILE}")
if [ -n "${EXISTING_ID}" ]; then
echo "Replacing the existing ${PKG_FILE} (asset id ${EXISTING_ID}) already on ${TAG}"
curl -fsS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
curl -fsS --http1.1 \
--retry 5 --retry-all-errors --retry-delay 5 \
--max-time 600 \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@rendered/${PKG_FILE}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${PKG_FILE}"
echo "Attached ${PKG_FILE} to ${TAG} on Gitea"
+125
View File
@@ -552,6 +552,131 @@ survived 92 commits and fourteen days in the public GitHub mirror, past five aud
independent reviews, because every one of them read the code under change and this sat in a test 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. 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 ## Testing
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file: Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
+24
View File
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
## Table of Contents ## Table of Contents
- [Installation](#installation)
- [Prerequisites](#prerequisites) - [Prerequisites](#prerequisites)
- [First Launch](#first-launch) - [First Launch](#first-launch)
- [The Interface](#the-interface) - [The Interface](#the-interface)
@@ -32,6 +33,23 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
--- ---
## Installation
Download the build for your platform from [GitHub Releases](https://github.com/shadowdao/triple-c/releases/latest).
| Platform | File | Install |
|----------|------|---------|
| **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. |
| **macOS** | `Triple-C_<version>_universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. |
| **Debian / Ubuntu** | `Triple-C_<version>_amd64.deb` | `sudo apt install ./Triple-C_<version>_amd64.deb` |
| **Fedora / RHEL** | `Triple-C-<version>-1.x86_64.rpm` | `sudo dnf install ./Triple-C-<version>-1.x86_64.rpm` |
| **Arch / CachyOS** | `triple-c-bin-<version>-1-x86_64.pkg.tar.zst` | `sudo pacman -U ./triple-c-bin-<version>-1-x86_64.pkg.tar.zst` |
| **Other Linux** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. |
> **macOS note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it — right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`.
> **Arch / CachyOS note:** This package is not on the AUR — it's a `pacman`-installable file built and attached to each GitHub release by a maintainer-triggered step (`.gitea/workflows/publish-arch-package.yml`), so it can lag behind the very latest release by a bit. See [`packaging/arch/README.md`](packaging/arch/README.md) for details, including why "-bin" and what's verified about it.
## Prerequisites ## Prerequisites
### Docker ### Docker
@@ -1536,3 +1554,9 @@ cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswit
``` ```
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup. This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
### App Icon Missing After Installing (Linux)
If Triple-C's icon shows as generic or blank right after installing — in the app menu, taskbar, and window titlebar alike — **log out and back in.**
Desktop shells (GNOME Shell, KDE Plasma) cache the list of installed apps and their resolved icons in memory when the shell starts, for performance. A freshly installed package's icon files land on disk correctly and its install hooks do rebuild the on-disk icon cache, but an already-running shell doesn't always notice — on X11 there used to be a way to soft-restart just the shell (GNOME's Alt+F2 → `r`) to force a reload, but under Wayland the shell *is* the compositor, so restarting it means ending the session. Logging out and back in starts a fresh shell that reads the current on-disk state, which picks the icon up.
+12 -6
View File
@@ -412,12 +412,18 @@ triple-c/
├── .gitea/ ├── .gitea/
│ └── workflows/ │ └── workflows/
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline │ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline
│ ├── build-app-preview.yml # Preview builds │ ├── build-app-preview.yml # Preview builds
│ ├── build.yml # Build container image (multi-arch) │ ├── build.yml # Build container image (multi-arch)
│ ├── build-stt.yml # Build the STT image │ ├── build-stt.yml # Build the STT image
│ ├── backfill-releases.yml # Bulk copy releases to GitHub │ ├── backfill-releases.yml # Bulk copy releases to GitHub
── cleanup-releases.yml # Prune old releases ── 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 └── app/ # Tauri v2 desktop application
├── package.json # React, xterm.js, zustand, tailwindcss ├── package.json # React, xterm.js, zustand, tailwindcss
+144
View File
@@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -47,6 +82,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 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]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@@ -280,6 +327,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -310,6 +363,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -569,6 +631,16 @@ dependencies = [
"windows-link 0.2.1", "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]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
@@ -753,6 +826,15 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@@ -1550,6 +1633,16 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "gio" name = "gio"
version = "0.18.4" version = "0.18.4"
@@ -2114,6 +2207,15 @@ dependencies = [
"cfb", "cfb",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.11.0" version = "2.11.0"
@@ -2831,6 +2933,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "open" name = "open"
version = "5.3.3" version = "5.3.3"
@@ -2913,6 +3021,17 @@ dependencies = [
"windows-link 0.2.1", "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]] [[package]]
name = "pathdiff" name = "pathdiff"
version = "0.2.3" version = "0.2.3"
@@ -3194,6 +3313,18 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@@ -5149,6 +5280,8 @@ dependencies = [
name = "triple-c" name = "triple-c"
version = "0.4.0" version = "0.4.0"
dependencies = [ dependencies = [
"aes-gcm",
"argon2",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"bollard", "bollard",
@@ -5174,6 +5307,7 @@ dependencies = [
"tokio", "tokio",
"tower-http", "tower-http",
"uuid", "uuid",
"zeroize",
] ]
[[package]] [[package]]
@@ -5287,6 +5421,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 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]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
+3
View File
@@ -36,6 +36,9 @@ tower-http = { version = "0.6", features = ["cors"] }
base64 = "0.22" base64 = "0.22"
rand = "0.9" rand = "0.9"
local-ip-address = "0.6" local-ip-address = "0.6"
argon2 = "0.5"
aes-gcm = "0.10"
zeroize = "1"
[dev-dependencies] [dev-dependencies]
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run # `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
+1
View File
@@ -10,6 +10,7 @@ pub mod install_helper_commands;
pub mod migration_commands; pub mod migration_commands;
pub mod project_commands; pub mod project_commands;
pub mod settings_commands; pub mod settings_commands;
pub mod settings_export_commands;
pub mod stt_commands; pub mod stt_commands;
pub mod terminal_commands; pub mod terminal_commands;
pub mod update_commands; pub mod update_commands;
+47 -16
View File
@@ -10,19 +10,24 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, Str
Ok(state.settings_store.get()) Ok(state.settings_store.get())
} }
#[tauri::command] /// Everything `update_settings` refuses a save over, run against the store's
pub async fn update_settings( /// *current* value and the incoming one.
settings: AppSettings, ///
state: State<'_, AppState>, /// Pulled out so a caller that does other, harder-to-undo work alongside a
) -> Result<AppSettings, String> { /// settings save — `settings_export_commands::apply_settings_import`
let before = state.settings_store.get(); /// 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 // 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 // `update_project`: a global custom env var is merged into every project's
// container environment, so an unchecked name here reaches all of them. // container environment, so an unchecked name here reaches all of them.
crate::models::validate_env_vars_update( crate::models::validate_env_vars_update(
&before.global_custom_env_vars, &before.global_custom_env_vars,
&settings.global_custom_env_vars, &incoming.global_custom_env_vars,
)?; )?;
// The same for the two host paths this struct owns. `update_project` // The same for the two host paths this struct owns. `update_project`
@@ -40,14 +45,37 @@ pub async fn update_settings(
crate::commands::project_commands::validate_mounted_host_path( crate::commands::project_commands::validate_mounted_host_path(
"SSH key path", "SSH key path",
before.default_ssh_key_path.as_deref(), before.default_ssh_key_path.as_deref(),
settings.default_ssh_key_path.as_deref(), incoming.default_ssh_key_path.as_deref(),
)?; )?;
crate::commands::project_commands::validate_mounted_host_path( crate::commands::project_commands::validate_mounted_host_path(
"CA certificate path", "CA certificate path",
before.ca_cert_path.as_deref(), before.ca_cert_path.as_deref(),
settings.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)?; let saved = state.settings_store.update(settings)?;
// Persisting a setting is not the same as applying it. The gateway is the // Persisting a setting is not the same as applying it. The gateway is the
@@ -122,7 +150,10 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
GatewayAction::StopIfRunning => { GatewayAction::StopIfRunning => {
log::info!("Model gateway disabled in settings — stopping the container"); log::info!("Model gateway disabled in settings — stopping the container");
if let Err(e) = docker::gateway::stop_gateway_container().await { 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 => { GatewayAction::RestartIfRunning => {
@@ -138,10 +169,7 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
} }
#[tauri::command] #[tauri::command]
pub async fn pull_image( pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
image_name: String,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
use tauri::Emitter; use tauri::Emitter;
docker::pull_image(&image_name, move |msg| { docker::pull_image(&image_name, move |msg| {
let _ = app_handle.emit("image-pull-progress", msg); let _ = app_handle.emit("image-pull-progress", msg);
@@ -334,7 +362,10 @@ mod tests {
let before = enabled_gateway(); let before = enabled_gateway();
let mut after = before.clone(); let mut after = before.clone();
after.enabled = false; 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 true when it was already off — a stray running container is
// still a container that shouldn't be up. // still a container that shouldn't be up.
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning); 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();
}
}
+20
View File
@@ -29,6 +29,21 @@ pub struct AppState {
pub auth_bridge: Arc<AuthBridgeManager>, pub auth_bridge: Arc<AuthBridgeManager>,
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>, pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
pub lifecycle: Arc<Lifecycle>, 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, auth_bridge,
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)), web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
lifecycle, lifecycle,
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
}) })
.setup(move |app| { .setup(move |app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) { match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
@@ -494,6 +510,10 @@ pub fn run() {
commands::settings_commands::inspect_ca_cert_path, commands::settings_commands::inspect_ca_cert_path,
commands::settings_commands::list_aws_profiles, commands::settings_commands::list_aws_profiles,
commands::settings_commands::detect_host_timezone, 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 // Terminal
commands::terminal_commands::open_terminal_session, commands::terminal_commands::open_terminal_session,
commands::terminal_commands::terminal_input, commands::terminal_commands::terminal_input,
+48
View File
@@ -1,6 +1,54 @@
// Prevents additional console window on Windows in release // Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![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() { fn main() {
#[cfg(target_os = "linux")]
apply_webkit_wayland_workaround();
triple_c_lib::run() triple_c_lib::run()
} }
+2
View File
@@ -3,6 +3,7 @@ pub mod container_config;
pub mod app_settings; pub mod app_settings;
pub mod gateway_settings; pub mod gateway_settings;
pub mod migration; pub mod migration;
pub mod settings_export;
pub mod update_info; pub mod update_info;
pub use project::*; pub use project::*;
@@ -10,4 +11,5 @@ pub use container_config::*;
pub use app_settings::*; pub use app_settings::*;
pub use gateway_settings::*; pub use gateway_settings::*;
pub use migration::*; pub use migration::*;
pub use settings_export::*;
pub use update_info::*; pub use update_info::*;
+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()
);
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod migration_store;
pub mod pending_cleanup; pub mod pending_cleanup;
pub mod projects_store; pub mod projects_store;
pub mod secure; pub mod secure;
pub mod settings_crypto;
pub mod settings_store; pub mod settings_store;
#[allow(unused_imports)] #[allow(unused_imports)]
+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 /// only enforces auth when a master key is configured, so Triple-C always
/// configures one. /// configures one.
pub fn get_or_create_gateway_master_key() -> Result<String, String> { 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 let Some(existing) = get_gateway_master_key()? {
if !existing.trim().is_empty() { return Ok(existing);
return Ok(existing);
}
} }
regenerate_gateway_master_key() 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 /// Mint a new gateway master key, invalidating the old one. Projects using the
/// previous value must be updated. /// previous value must be updated.
pub fn regenerate_gateway_master_key() -> Result<String, String> { pub fn regenerate_gateway_master_key() -> Result<String, String> {
// LiteLLM requires the master key to start with `sk-`. // LiteLLM requires the master key to start with `sk-`.
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple()); 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) let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?; .map_err(|e| format!("Keyring error: {}", e))?;
entry entry
.set_password(&key) .set_password(key.trim())
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?; .map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
bump_gateway_secret_version()?; bump_gateway_secret_version()
Ok(key)
} }
@@ -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());
}
}
@@ -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 SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings"; import SharedAuthSettings from "./SharedAuthSettings";
import CertificateSettings from "./CertificateSettings"; import CertificateSettings from "./CertificateSettings";
import ExportSettingsModal from "./ExportSettingsModal";
import ImportSettingsModal from "./ImportSettingsModal";
export default function SettingsPanel() { export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings(); const { appSettings, saveSettings, setAppSettings } = useSettings();
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates(); const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? ""); const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []); const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
@@ -33,6 +35,8 @@ export default function SettingsPanel() {
const [showInstructionsModal, setShowInstructionsModal] = useState(false); const [showInstructionsModal, setShowInstructionsModal] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false); const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false); const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
// Sync local state when appSettings change // Sync local state when appSettings change
useEffect(() => { useEffect(() => {
@@ -269,6 +273,39 @@ export default function SettingsPanel() {
</div> </div>
</AccordionSection> </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 && ( {showInstructionsModal && (
<ClaudeInstructionsModal <ClaudeInstructionsModal
instructions={globalInstructions} instructions={globalInstructions}
+4
View File
@@ -36,5 +36,9 @@ export function useSettings() {
appSettings, appSettings,
loadSettings, loadSettings,
saveSettings, 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,
}; };
} }
+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;
}
+10 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, 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, UploadOutcome } 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 // Docker
export const checkDocker = () => invoke<boolean>("check_docker"); export const checkDocker = () => invoke<boolean>("check_docker");
@@ -42,6 +42,15 @@ export const inspectCaCertPath = (path: string) =>
export const detectHostTimezone = () => export const detectHostTimezone = () =>
invoke<string>("detect_host_timezone"); 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 // AWS
export const awsSsoRefresh = (projectId: string) => export const awsSsoRefresh = (projectId: string) =>
invoke<void>("aws_sso_refresh", { projectId }); invoke<void>("aws_sso_refresh", { projectId });
+40
View File
@@ -292,6 +292,46 @@ export interface AppSettings {
global_claude_code_settings: ClaudeCodeSettings | null; 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 /** 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 * in the payload rather than rejecting, so the field can render them inline
* while the user is still typing. */ * while the user is still typing. */
+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.