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
27 changed files with 2569 additions and 113 deletions
@@ -1,37 +1,39 @@
name: Publish AUR Package
name: Publish Arch Package
# Builds and pushes the `triple-c-bin` AUR package (packaging/arch/PKGBUILD)
# for a given release, or the latest one if none is given. 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
# 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)
# and pushes the rendered PKGBUILD plus a regenerated `.SRCINFO` to AUR. It
# does NOT commit anything back to this repo — `packaging/arch/PKGBUILD` stays
# a hand-maintained template with a placeholder version, and every real,
# published version lives only in AUR's own git history, which is where a
# PKGBUILD's revision history is expected to live. A corollary worth knowing:
# a hand-edit made directly in the AUR repo (outside this workflow) is
# silently overwritten the next time this runs, since every run renders fresh
# from this repo's template rather than starting from AUR's current state.
# 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.
#
# ## Required secret
# ## Not published to the AUR (yet)
#
# `AUR_SSH_PRIVATE_KEY` — an SSH private key registered against an AUR
# account that has already created (or been given co-maintainer access to)
# the `triple-c-bin` package. This workflow cannot create that AUR account or
# register the key for you — both are manual, one-time steps on
# https://aur.archlinux.org. Until this secret exists, every run fails at the
# "Push to AUR" step with a clear error rather than silently doing nothing.
# 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:
@@ -43,7 +45,8 @@ on:
env:
GITHUB_REPO: shadowdao/triple-c
AUR_REPO: ssh://aur@aur.archlinux.org/triple-c-bin.git
GITEA_URL: ${{ gitea.server_url }}
REPO: ${{ gitea.repository }}
jobs:
publish:
@@ -97,10 +100,20 @@ jobs:
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
@@ -160,10 +173,23 @@ jobs:
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 = (
f'source=("Triple-C_${{pkgver}}_amd64.deb::'
f'https://github.com/{github_repo}/releases/download/v${{pkgver}}/'
f'Triple-C_${{pkgver}}_amd64.deb"'
"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}::'
@@ -184,6 +210,7 @@ jobs:
! grep -q "SKIP" PKGBUILD
- name: Validate with makepkg and namcap
id: build
run: |
set -euo pipefail
@@ -205,6 +232,13 @@ jobs:
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"
@@ -225,50 +259,110 @@ jobs:
# 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
- name: Push to AUR
echo "pkg_file=${PKG_FILE}" >> "$GITHUB_OUTPUT"
- name: Attach the package to the GitHub release
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
VERSION: ${{ steps.resolve.outputs.version }}
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 "${AUR_SSH_PRIVATE_KEY}" ]; then
echo "AUR_SSH_PRIVATE_KEY is not set — see this workflow file's header comment for" >&2
echo "the one-time AUR account setup this needs before it can publish anything." >&2
if [ -z "${GH_PAT}" ]; then
echo "GH_PAT is not set — this step needs it to attach a release asset." >&2
exit 1
fi
mkdir -p ~/.ssh
# Created with the final mode before any bytes land in it, rather
# than a plain redirect followed by chmod, which leaves the key
# world-readable for whatever window falls between the two calls.
install -m 600 /dev/null ~/.ssh/aur
echo "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
# TOFU, not verification — accepted here because pinning AUR's
# actual host key needs a value fetched from somewhere trusted
# ahead of time, which this workflow doesn't have, and getting a
# pinned value wrong fails every future run rather than just this
# one. A keyscan failure below surfaces later as an opaque
# "Host key verification failed" rather than a clear one here.
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null
export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o IdentitiesOnly=yes -o UserKnownHostsFile=~/.ssh/known_hosts"
git clone "${AUR_REPO}" aur-repo
cp rendered/PKGBUILD rendered/.SRCINFO aur-repo/
cd aur-repo
git config user.name "Triple-C CI"
git config user.email "noreply@triple-c.invalid"
git add PKGBUILD .SRCINFO
if git diff --cached --quiet; then
echo "No change from what's already published on AUR for ${VERSION}"
exit 0
# 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
git commit -m "triple-c-bin: update to ${VERSION}"
# AUR itself uses `master`, which is what a fresh, not-yet-created
# AUR package's empty repo advertises on clone — but the *local*
# branch name after cloning an empty repo falls back to whatever
# this runner's `init.defaultBranch` is if the server sends no
# symref, so naming the destination explicitly is what keeps this
# working if that default is ever `main` instead of `master`.
git push origin HEAD:master
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
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
## Settings export/import
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that
live in the OS keychain instead: the shared Claude Code OAuth login and the model gateway's two
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are
deliberately out of scope — this is not a project backup.
- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
credential for a server that binds every interface — exporting `AppSettings` wholesale would
have carried it along as if it were as inert as a port number, and importing it would have
applied `web_terminal.enabled` and the token together with no more warning than any other
setting, letting a crafted export silently stand up a LAN-listening terminal on the next launch.
`export_settings`/`apply_settings_import` carve this one field out into `ExportedSecrets`
instead, with the same "only overwrite what the import actually has" treatment as the other
three secrets — except "leave it alone" has to be done by hand in `apply_settings_import`, since
unlike the keychain secrets this one lives inside the `AppSettings` blob that gets replaced
wholesale. `SettingsImportPreview::enables_web_terminal` also exists because of this: `enabled`
and the token are independent fields, and "this turns on a listening service" must not hide
inside a generic "settings replaced" summary. Read this as the standing example of the class of
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
like config but is actually a live credential would have the same problem.
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
encryption. A wrong password fails GCM's authentication tag rather than producing silent
garbage. The salt and nonce are not secret and are written in the clear in the file's own
header — the salt's job is only to make two exports of the same password derive different keys,
and the nonce's only requirement is per-encryption uniqueness, which a fresh random draw on
every export already gives it.
- **The save/open dialogs are opened from Rust**, the same boundary `file_commands.rs`'s
`pick_save_path`/`pick_files_to_upload` draw and document at length: a frontend-driven dialog
handing Rust a host path string is the exact shape of bug that produced this app's past
criticals. `preview_settings_import` resolves the chosen path itself and remembers it
(`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without
a path ever crossing back over IPC. It also pins a hash of the file's ciphertext next to that
path, and `apply_settings_import` refuses to proceed if the file on disk no longer matches it —
otherwise confirming a preview would not actually be binding on what gets applied, which matters
given this feature's own threat model: a file shared between people may sit in a synced or
otherwise shared directory that changes between the two calls.
- **The decrypted payload is not cached between preview and apply — only the password is reused.**
The frontend holds the password in React state and passes it to both calls; nothing in Rust
holds decrypted plaintext — secrets included — in memory for longer than one command's
execution, so `apply_settings_import` always re-decrypts rather than reusing anything
`preview_settings_import` computed. `preview_settings_import` returns counts and presence flags
only (`SettingsImportPreview`), never a secret value, so it's safe to hand to the frontend and
render directly.
- **Import replaces settings wholesale, but only writes secrets actually present in the file.**
An import is "restore this environment," so the settings half is a full replace, not a
field-by-field merge. Secrets are different on purpose: an absent secret in the export means
"the source machine never had this configured," not "delete this on import" — a user who wants
to clear a secret already has dedicated UI for that (signing out of shared auth, clearing the
gateway key). Secrets are restored *before* the settings replace runs, not after — replacing
settings is what triggers `reconcile_gateway`, and restoring the other way round leaves a real
window where a gateway recreation happens against the destination's old keys.
- **A restored gateway secret nudges a running gateway container to recreate itself, even when
nothing about the gateway's *shape* changed.** `reconcile_gateway`'s `gateway_shape_changed` only
compares port/provider/base URL/models — deliberately, since that's what's rendered into the
container's config — so a secret-only change (same shape, new key) is invisible to it. Left
alone, a running container would keep serving the old key material indefinitely after an import
that restored a new one. `apply_settings_import` tracks whether either gateway secret was
actually written and, if the gateway is enabled and its container both exists and is running,
calls `docker::gateway::ensure_gateway_running` directly afterward — its own fingerprint already
includes the secret rotation id (`storage::secure::get_gateway_secret_version`), so it recreates
exactly when it should and no more.
- **A keychain write failing during import is reported back, not only logged.** Each of the three
`secure::store_*` calls collects its error into `SettingsImportOutcome::secret_restore_warnings`
in addition to logging it — an import that silently restores two of three secrets but not the
third must not read as unqualified success just because the settings half of the import (which
runs after, and is validated before any of this) went through. `apply_settings_import` returns
`SettingsImportOutcome { settings, secret_restore_warnings }` rather than bare `AppSettings` for
this reason; `ImportSettingsModal` shows any warnings alongside the "Settings imported" message.
- **The imported settings are validated *before* any secret is written, not just before the
settings replace.** `apply_settings_import` calls
`settings_commands::validate_settings_update(&current, &settings)` — the same checks
`update_settings` runs internally, pulled out into its own function specifically so this caller
can run them first — and only proceeds to the three keychain writes if that passes. A review
caught the earlier ordering: writing secrets first meant a rejected import (a bad env var name, a
disallowed host path) still left the keychain overwritten with the file's secrets while the
settings themselves stayed unchanged, a silently half-applied state the error message gave no
hint of.
- **`read_and_decrypt` checks `format_version` before attempting to parse the full payload, not
after.** A version bump that isn't deserialize-compatible is exactly the case that check exists
for, and parsing the full struct first would fail on the shape mismatch before the version check
ever ran. Neither error path interpolates what `serde_json` actually says into the message
shown to the user — its type-mismatch errors quote the offending value inline, and the plaintext
here can hold a live credential.
- **The 8-character password minimum is enforced in `export_settings` itself, not only in the
export modal.** The frontend minimum is a UX nudge; the Rust command is the actual boundary a
weak password has to cross, and Argon2id's memory-hardness buys little against an attacker who
can just try a short password directly. Measured with `.chars().count()` (Unicode scalar values)
rather than `.len()` (bytes), to stay as close as this pair of languages allows to the frontend's
`.length` check (UTF-16 code units) — the two only diverge on astral-plane characters. The
derived key and both plaintext buffers — the payload built for export, and whatever `decrypt`
recovers on import — are wrapped in `zeroize::Zeroizing` for the same reason every other secret
in this codebase gets handled carefully — cheap insurance (`zeroize` is already pulled in
transitively via `aes-gcm`) for material that exists only to hold or produce live credentials.
- **The preview also discloses non-blank custom base URLs** (`global_ollama`, `global_llamacpp`,
`global_openai_compatible`, `gateway.api_base`) so an import that would redirect model traffic to
a different server is visible in the confirmation dialog rather than discovered later — these are
endpoints, not secrets, so `SettingsImportPreview` carries and `describeImport` renders the actual
URL rather than just a presence flag. `describeImportWarnings` additionally calls out a web
terminal token that arrives with the terminal left *off*: `start_web_terminal` only mints a fresh
token when none is already set, so a planted token would otherwise activate silently the next
time someone turns the terminal on, with no import-time signal that it wasn't freshly generated.
- **The preview also discloses a custom Docker image, and warns on one every time — not just on
change.** `custom_image_name`/`image_source` weren't in scope for the base-URL disclosure above,
but a review pointed out they're a sharper version of the same problem: this is the image *every*
project container is created from (`models::container_config::resolve_image_name`), so a crafted
export pointing it at an attacker-controlled image is a path to running arbitrary code with
whatever a project's containers are allowed to reach, not merely a redirected API endpoint.
`describeImportWarnings` fires on `image_source == Custom` unconditionally rather than only when
it differs from the destination's current value, since re-importing the same risky configuration
is still worth surfacing every time a user confirms an import.
- **Every free-form string a preview surfaces is sanitized and length-capped before it's built.**
`SettingsImportPreview::from_payload`'s `sanitize_for_preview` strips control characters and caps
at 100 characters (`MAX_PREVIEW_STRING_LEN`) for every base URL and the custom image name — a
review noted that, unlike the count- and boolean-derived fields the preview started with, these
are verbatim strings from a not-yet-trusted decrypted payload rendered directly into the
confirmation dialog. Unbounded, a single pathological value (very long, or holding embedded
newlines) could push the security warnings above the scroll fold in the dialog that exists
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
as a second layer against the same failure mode.
## Testing
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
+24
View File
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
## Table of Contents
- [Installation](#installation)
- [Prerequisites](#prerequisites)
- [First Launch](#first-launch)
- [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
### 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.
### 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.
+2 -2
View File
@@ -418,10 +418,10 @@ triple-c/
│ ├── build-stt.yml # Build the STT image
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
│ ├── cleanup-releases.yml # Prune old releases
│ └── publish-aur-package.yml # Publish triple-c-bin to the AUR (packaging/arch/)
│ └── publish-arch-package.yml # Build triple-c-bin, attach it to the GitHub release (packaging/arch/)
├── packaging/
│ └── arch/ # AUR triple-c-bin package — see packaging/arch/README.md
│ └── arch/ # triple-c-bin Arch package — see packaging/arch/README.md
│ ├── PKGBUILD
│ └── README.md
+144
View File
@@ -8,6 +8,41 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -47,6 +82,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -280,6 +327,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -310,6 +363,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -569,6 +631,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "combine"
version = "4.6.7"
@@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -753,6 +826,15 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.20.11"
@@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@@ -1550,6 +1633,16 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gio"
version = "0.18.4"
@@ -2114,6 +2207,15 @@ dependencies = [
"cfb",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.11.0"
@@ -2831,6 +2933,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.3"
@@ -2913,6 +3021,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -3194,6 +3313,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -5149,6 +5280,8 @@ dependencies = [
name = "triple-c"
version = "0.4.0"
dependencies = [
"aes-gcm",
"argon2",
"axum",
"base64 0.22.1",
"bollard",
@@ -5174,6 +5307,7 @@ dependencies = [
"tokio",
"tower-http",
"uuid",
"zeroize",
]
[[package]]
@@ -5287,6 +5421,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]]
name = "untrusted"
version = "0.9.0"
+3
View File
@@ -36,6 +36,9 @@ tower-http = { version = "0.6", features = ["cors"] }
base64 = "0.22"
rand = "0.9"
local-ip-address = "0.6"
argon2 = "0.5"
aes-gcm = "0.10"
zeroize = "1"
[dev-dependencies]
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
+1
View File
@@ -10,6 +10,7 @@ pub mod install_helper_commands;
pub mod migration_commands;
pub mod project_commands;
pub mod settings_commands;
pub mod settings_export_commands;
pub mod stt_commands;
pub mod terminal_commands;
pub mod update_commands;
+47 -16
View File
@@ -10,19 +10,24 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, Str
Ok(state.settings_store.get())
}
#[tauri::command]
pub async fn update_settings(
settings: AppSettings,
state: State<'_, AppState>,
) -> Result<AppSettings, String> {
let before = state.settings_store.get();
/// Everything `update_settings` refuses a save over, run against the store's
/// *current* value and the incoming one.
///
/// Pulled out so a caller that does other, harder-to-undo work alongside a
/// settings save — `settings_export_commands::apply_settings_import`
/// restores three keychain secrets in the same command — can run this
/// *first* and bail before touching anything, rather than discovering the
/// rejection only when `update_settings` itself runs partway through.
pub fn validate_settings_update(
before: &AppSettings,
incoming: &AppSettings,
) -> Result<(), String> {
// The global half of the same rule the project half gets in
// `update_project`: a global custom env var is merged into every project's
// container environment, so an unchecked name here reaches all of them.
crate::models::validate_env_vars_update(
&before.global_custom_env_vars,
&settings.global_custom_env_vars,
&incoming.global_custom_env_vars,
)?;
// 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(
"SSH key path",
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(
"CA certificate path",
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)?;
// 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 => {
log::info!("Model gateway disabled in settings — stopping the container");
if let Err(e) = docker::gateway::stop_gateway_container().await {
log::error!("Failed to stop the model gateway after it was disabled: {}", e);
log::error!(
"Failed to stop the model gateway after it was disabled: {}",
e
);
}
}
GatewayAction::RestartIfRunning => {
@@ -138,10 +169,7 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
}
#[tauri::command]
pub async fn pull_image(
image_name: String,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
use tauri::Emitter;
docker::pull_image(&image_name, move |msg| {
let _ = app_handle.emit("image-pull-progress", msg);
@@ -334,7 +362,10 @@ mod tests {
let before = enabled_gateway();
let mut after = before.clone();
after.enabled = false;
assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
assert_eq!(
gateway_action(&before, &after),
GatewayAction::StopIfRunning
);
// Still true when it was already off — a stray running container is
// still a container that shouldn't be up.
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
@@ -0,0 +1,654 @@
//! Settings export/import — see triple-c#35.
//!
//! Exports the *host* environment (global `AppSettings` plus the global
//! secrets kept in the OS keychain: the shared Claude Code OAuth login and
//! the model gateway's two keys), encrypted with a user-chosen password —
//! see `storage::settings_crypto` for the actual cryptography. Deliberately
//! out of scope: per-project settings, per-project secrets, and anything
//! living in a project's Docker volumes.
//!
//! **The save/open dialogs are opened from Rust**, the same pattern
//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already
//! establish and document at length: a frontend-driven dialog handing Rust a
//! host path string is the exact shape of bug that produced this app's past
//! criticals, so the boundary here is drawn the same place. The frontend can
//! ask for a picker; it cannot name a host path as an *input*. `preview_
//! settings_import` resolves the chosen path itself and remembers it
//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads
//! the same file without the path ever crossing back over IPC.
//!
//! The *decrypted payload* is not cached between preview and apply — the
//! password the frontend passes to each call is what it already held for
//! the first, not a fresh secret extracted from the user, but nothing here
//! keeps the plaintext itself — export/import secrets included — around for
//! longer than one command's execution; `apply_settings_import` re-decrypts
//! the file rather than reusing anything `preview_settings_import` computed.
//!
//! **This is new attack surface**: a settings export is a file one person
//! can hand another and ask them to import, together with a password, and
//! `apply_settings_import` applies whatever `AppSettings` it decrypts to
//! wholesale — see the module doc on `models::settings_export` for the
//! `web_terminal.access_token` carve-out a review of this feature found,
//! and treat that as the standing example of the class of thing to keep
//! checking for here, not a one-off fixed bug.
#[cfg(test)]
use std::path::Path;
use std::path::PathBuf;
use sha2::{Digest, Sha256};
use tauri::State;
use tauri_plugin_dialog::DialogExt;
use zeroize::Zeroizing;
use crate::models::{
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
};
use crate::storage::{secure, settings_crypto};
use crate::AppState;
/// What `preview_settings_import` pins so `apply_settings_import` can tell
/// whether the file it's about to re-read is the same one the user actually
/// saw a preview of. Confirming a preview is only meaningful if it's binding
/// on what gets applied — without this, a file replaced on disk between the
/// two calls (this app's own stated threat model is a file shared between
/// people, which may sit in a synced or shared directory) would decrypt and
/// apply silently different content than what the confirmation dialog showed.
#[derive(Debug, Clone)]
pub struct PendingSettingsImport {
path: PathBuf,
ciphertext_hash: [u8; 32],
}
fn hash_ciphertext(data: &[u8]) -> [u8; 32] {
Sha256::digest(data).into()
}
const FILE_EXTENSION: &str = "triplec";
/// Enforced here, not only in the export modal: the frontend's minimum is a
/// UX nudge, but `export_settings` is the actual boundary a weak password
/// has to cross, and Argon2id's memory-hardness buys little against an
/// attacker who can just try a three-character password directly.
const MIN_PASSWORD_LEN: usize = 8;
fn suggested_export_name() -> String {
// Timestamped so exporting more than once doesn't silently overwrite an
// earlier file just because the save dialog defaults to the same name.
format!(
"triple-c-settings-{}.{}",
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
FILE_EXTENSION
)
}
async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
let (tx, rx) = tokio::sync::oneshot::channel();
window
.dialog()
.file()
.set_parent(window)
.set_title("Export Triple-C settings")
.set_file_name(suggested)
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
.save_file(move |picked| {
let _ = tx.send(picked);
});
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
}
async fn pick_import_open_path(window: &tauri::Window) -> Option<PathBuf> {
let (tx, rx) = tokio::sync::oneshot::channel();
window
.dialog()
.file()
.set_parent(window)
.set_title("Import Triple-C settings")
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
.pick_file(move |picked| {
let _ = tx.send(picked);
});
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
}
/// Gather the current global secrets, and hand back the `AppSettings` to
/// export with the web-terminal token blanked out of it — see the module
/// doc comment on `models::settings_export` for why that field cannot
/// travel through `settings` like the rest of this struct.
///
/// A missing keychain secret reads as `None` — a keychain read failure is
/// treated as "nothing to export" for that one entry rather than aborting
/// the whole export, matching how the rest of this app degrades a keychain
/// error to "absent" (`has_claude_oauth_token`, `has_gateway_api_key`)
/// rather than surfacing it as a hard failure.
fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSecrets) {
let mut settings = current;
let web_terminal_access_token = settings.web_terminal.access_token.take();
let secrets = ExportedSecrets {
claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(),
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
web_terminal_access_token,
};
(settings, secrets)
}
/// Export the current global settings and secrets to a password-encrypted
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
/// deliberately distinguishable from one so the frontend shows nothing
/// rather than a "failed" toast for a plain cancel.
#[tauri::command]
pub async fn export_settings(
password: String,
window: tauri::Window,
state: State<'_, AppState>,
) -> Result<bool, String> {
// `.chars().count()` — Unicode scalar values, not bytes — to stay as
// close as this pair of languages allows to the frontend's `.length`
// check (UTF-16 code units); the two only diverge on astral-plane
// characters, which no reasonable password touches.
if password.chars().count() < MIN_PASSWORD_LEN {
return Err(format!(
"Use a password of at least {} characters.",
MIN_PASSWORD_LEN
));
}
let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
return Ok(false);
};
let (settings, secrets) = split_settings_and_secrets(state.settings_store.get());
if secrets.is_empty() {
log::info!("Exporting settings with no global secrets configured on this machine");
}
let payload = SettingsExportPayload {
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
exported_at: chrono::Utc::now().to_rfc3339(),
app_version: env!("CARGO_PKG_VERSION").to_string(),
settings,
secrets,
};
let plaintext = Zeroizing::new(
serde_json::to_vec(&payload)
.map_err(|e| format!("Failed to prepare settings for export: {}", e))?,
);
let encrypted = settings_crypto::encrypt(&plaintext, &password)?;
std::fs::write(&dest, &encrypted).map_err(|e| format!("Failed to write export file: {}", e))?;
Ok(true)
}
/// Open a file picker, decrypt the chosen file with `password`, and return a
/// preview (counts and presence flags only — never a secret value) for a
/// confirmation UI. `Ok(None)` means the picker was dismissed.
///
/// Remembers the resolved path *and a hash of the file's ciphertext* in
/// `AppState::pending_settings_import` for `apply_settings_import` to check
/// against — does **not** remember the decrypted payload itself, so the
/// password must be supplied again to actually apply it — seeing the preview
/// is not the same as committing to it. The hash exists so it also can't be
/// swapped out from under that commitment: `apply_settings_import` refuses to
/// proceed if the file on disk no longer matches what was just previewed.
#[tauri::command]
pub async fn preview_settings_import(
password: String,
window: tauri::Window,
state: State<'_, AppState>,
) -> Result<Option<SettingsImportPreview>, String> {
if password.is_empty() {
return Err("A password is required to open a settings export.".to_string());
}
let Some(path) = pick_import_open_path(&window).await else {
return Ok(None);
};
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
let preview = SettingsImportPreview::from_payload(&payload);
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
path,
ciphertext_hash: hash_ciphertext(&encrypted),
});
Ok(Some(preview))
}
/// Apply the import a prior `preview_settings_import` call resolved a path
/// for. Fails if no preview is pending — this is not a general "decrypt and
/// apply this file" entry point, deliberately: seeing the preview first is
/// required, not just encouraged, since it is the only place a user is told
/// what an import is about to touch before it touches it. That requirement
/// is only real if the file can't change out from under it, so this also
/// refuses to proceed if the file's ciphertext no longer matches the hash
/// `preview_settings_import` pinned — a file replaced on disk between the
/// two calls (this feature's own threat model is a file shared between
/// people, which may sit in a synced or shared directory) must not be able
/// to apply silently different content than what the confirmation dialog
/// showed.
///
/// Global settings are replaced wholesale — an import is "restore this
/// environment," not a field-by-field merge. Global secrets are handled
/// differently and on purpose: **only secrets actually present in the
/// import are written**; a secret the export doesn't have is left alone on
/// this machine rather than cleared, because an absent secret in the export
/// means "the source machine never had this configured," not "delete this
/// on import." A user who wants to clear a secret already has dedicated UI
/// for that (signing out of shared auth, clearing the gateway key).
///
/// Order matters here, twice over.
///
/// First: the imported settings are **validated before any secret is
/// written**, using the same checks `update_settings` itself runs
/// (`settings_commands::validate_settings_update`). Restoring a secret is
/// hard to undo unnoticed — a stale env-var-name rejection or a disallowed
/// host path used to be caught only when `update_settings` ran, by which
/// point the three keychain secrets below were already overwritten with the
/// file's, each with a fresh rotation id, silently flagging every project
/// container for recreation — while the error the user saw talked only
/// about the rejected setting and said nothing about the credentials that
/// had already moved. Failing this check first makes a rejected import
/// leave nothing touched, matching what "the import failed" is supposed to
/// mean.
///
/// Second, among the things that *do* get written: secrets are restored
/// **before** the settings replace runs (which is what triggers
/// `reconcile_gateway`), so a gateway recreation that replace provokes sees
/// the final key material rather than racing it — restoring the other way
/// round left a real window where the running gateway and the keychain
/// briefly disagreed. A gateway *secret* alone (same shape, new key) is
/// invisible to `reconcile_gateway`'s shape comparison, so this additionally
/// nudges a running gateway container to recreate itself whenever a secret
/// this import carried was actually written — otherwise the running
/// container keeps serving the old key material indefinitely while every
/// project container is handed the new one.
///
/// A keychain write failing is reported back rather than only logged: an
/// import that silently restores two of three secrets but not the third
/// must not read as unqualified success.
///
/// The pending import is only cleared on success. A failure here (rejected
/// by the validation above, a stale-file mismatch, or some other error)
/// leaves it pending so the frontend can let the user retry `apply` without
/// making them pick the file and re-enter the password again — the
/// preview's job was confirming *what* to import, not spending the one
/// attempt at applying it.
#[tauri::command]
pub async fn apply_settings_import(
password: String,
state: State<'_, AppState>,
) -> Result<SettingsImportOutcome, String> {
if password.is_empty() {
return Err("A password is required to import settings.".to_string());
}
let pending = state
.pending_settings_import
.lock()
.await
.clone()
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
let encrypted = std::fs::read(&pending.path)
.map_err(|e| format!("Failed to read export file: {}", e))?;
if hash_ciphertext(&encrypted) != pending.ciphertext_hash {
return Err(
"This file changed since you reviewed it — choose it again to see an up-to-date preview."
.to_string(),
);
}
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
let current = state.settings_store.get();
// The web-terminal token lives inside `AppSettings` itself rather than
// the keychain, so "leave an absent secret alone" has to be done by
// hand here: carry the destination's current token forward when the
// import doesn't have one, instead of letting the wholesale replace
// below blank it (every export writes `None` there — see
// `split_settings_and_secrets`).
let mut settings = payload.settings;
settings.web_terminal.access_token = non_blank(payload.secrets.web_terminal_access_token)
.or_else(|| current.web_terminal.access_token.clone());
crate::commands::settings_commands::validate_settings_update(&current, &settings)?;
let mut secret_restore_warnings = Vec::new();
let mut gateway_secret_changed = false;
if let Some(token) = non_blank(payload.secrets.claude_oauth_token) {
if let Err(e) = secure::store_claude_oauth_token(&token) {
log::warn!(
"Settings import: could not restore the shared Claude login: {}",
e
);
secret_restore_warnings
.push(format!("Could not restore your shared Claude login: {}", e));
}
}
if let Some(key) = non_blank(payload.secrets.gateway_api_key) {
match secure::store_gateway_api_key(&key) {
Ok(()) => gateway_secret_changed = true,
Err(e) => {
log::warn!(
"Settings import: could not restore the gateway provider API key: {}",
e
);
secret_restore_warnings.push(format!(
"Could not restore the gateway provider API key: {}",
e
));
}
}
}
if let Some(key) = non_blank(payload.secrets.gateway_master_key) {
match secure::store_gateway_master_key(&key) {
Ok(()) => gateway_secret_changed = true,
Err(e) => {
log::warn!(
"Settings import: could not restore the gateway master key: {}",
e
);
secret_restore_warnings
.push(format!("Could not restore the gateway master key: {}", e));
}
}
}
let saved =
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
// *shape* — port, provider, base URL, models — because that's what's
// rendered into the container's config. A secret changing with the shape
// held constant is invisible to it, so a running gateway container would
// otherwise keep serving the old key material forever after an import
// that restored a new one, while `docker::gateway`'s own fingerprint
// (which does include the secret rotation id) means the *next* unrelated
// settings save would suddenly and confusingly recreate it instead.
if gateway_secret_changed && saved.gateway.enabled {
match crate::docker::gateway::gateway_container_presence().await {
Ok((true, true)) => {
if let Err(e) = crate::docker::gateway::ensure_gateway_running(&saved.gateway).await
{
log::error!(
"Settings import: could not apply the restored gateway credentials to the running gateway container: {}",
e
);
}
}
Ok(_) => {}
Err(e) => log::debug!("Settings import: gateway reconcile skipped ({})", e),
}
}
state.pending_settings_import.lock().await.take();
Ok(SettingsImportOutcome {
settings: saved,
secret_restore_warnings,
})
}
fn non_blank(value: Option<String>) -> Option<String> {
value.filter(|v| !v.trim().is_empty())
}
/// Only the field `read_and_decrypt` needs before deciding whether the rest
/// of the payload is even worth attempting to parse.
#[derive(serde::Deserialize)]
struct FormatVersionProbe {
format_version: u32,
}
/// Read and decrypt an export file at `path`, then parse it — see
/// `read_and_decrypt_bytes` for why the format-version check runs before the
/// full parse. Every real caller already has the file's bytes in hand by the
/// time it needs this (`preview_settings_import`/`apply_settings_import`
/// both hash the ciphertext first) and calls `read_and_decrypt_bytes`
/// directly to avoid reading the file twice; this path-based wrapper only
/// exists now for tests that don't need that.
#[cfg(test)]
fn read_and_decrypt(path: &Path, password: &str) -> Result<SettingsExportPayload, String> {
let encrypted =
std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?;
read_and_decrypt_bytes(&encrypted, password)
}
/// Decrypt and parse an already-read export file's bytes, checking the
/// format version **before** attempting to deserialize the full payload.
///
/// That ordering is not just tidiness: a version bump that isn't
/// deserialize-compatible (a field's type changes, not just a new
/// `#[serde(default)]`-covered one) is exactly the case this check exists
/// for, and parsing the full struct first would fail on the shape mismatch
/// before the version check ever ran, surfacing a raw parse error instead
/// of "update Triple-C" — and, more seriously, `serde_json`'s type-mismatch
/// errors quote the offending value inline. This file is not attacker
/// content in the usual sense (it must still decrypt under the right
/// password), but the plaintext it decrypts to can hold a live credential,
/// so neither error path below ever interpolates what `serde_json`
/// actually says — only a fixed, generic message.
fn read_and_decrypt_bytes(encrypted: &[u8], password: &str) -> Result<SettingsExportPayload, String> {
let plaintext = settings_crypto::decrypt(encrypted, password)?;
let probe: FormatVersionProbe = serde_json::from_slice(&plaintext)
.map_err(|_| "This file doesn't look like a valid settings export.".to_string())?;
if probe.format_version > SETTINGS_EXPORT_FORMAT_VERSION {
return Err(format!(
"This export was made by a newer version of Triple-C (format {}, this app supports up to {}). \
Update Triple-C before importing it.",
probe.format_version, SETTINGS_EXPORT_FORMAT_VERSION
));
}
serde_json::from_slice(&plaintext).map_err(|_| {
"This file doesn't look like a valid settings export (unexpected shape).".to_string()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_blank_treats_whitespace_only_as_absent() {
assert_eq!(non_blank(Some(" ".to_string())), None);
assert_eq!(non_blank(Some("".to_string())), None);
assert_eq!(non_blank(None), None);
assert_eq!(non_blank(Some(" a ".to_string())), Some(" a ".to_string()));
}
#[test]
fn ciphertext_hashing_is_deterministic_and_tamper_sensitive() {
// What `apply_settings_import` compares against the pinned hash from
// `preview_settings_import` to detect a file swapped out from under a
// pending import — this only defends anything if identical bytes
// always hash identically and any change to those bytes changes the
// hash.
let bytes = b"pretend this is an encrypted export file";
assert_eq!(hash_ciphertext(bytes), hash_ciphertext(bytes));
let mut tampered = bytes.to_vec();
tampered[0] ^= 0xFF;
assert_ne!(hash_ciphertext(bytes), hash_ciphertext(&tampered));
}
fn write_export(
dir: &std::path::Path,
name: &str,
payload: &SettingsExportPayload,
password: &str,
) -> PathBuf {
write_raw_export(dir, name, &serde_json::to_value(payload).unwrap(), password)
}
/// Like `write_export`, but takes an arbitrary `serde_json::Value` rather
/// than a real `SettingsExportPayload` — for fixtures that are
/// deliberately not shape-compatible, which the typed helper above can't
/// produce at all.
fn write_raw_export(
dir: &std::path::Path,
name: &str,
value: &serde_json::Value,
password: &str,
) -> PathBuf {
let plaintext = serde_json::to_vec(value).unwrap();
let encrypted = settings_crypto::encrypt(&plaintext, password).unwrap();
let path = dir.join(name);
std::fs::write(&path, &encrypted).unwrap();
path
}
#[test]
fn splitting_settings_moves_the_web_terminal_token_out_rather_than_copying_it() {
let mut settings = AppSettings::default();
settings.web_terminal.access_token = Some("super-secret-token".to_string());
let (settings, secrets) = split_settings_and_secrets(settings);
assert_eq!(settings.web_terminal.access_token, None);
assert_eq!(
secrets.web_terminal_access_token,
Some("super-secret-token".to_string())
);
}
#[test]
fn splitting_settings_with_no_token_leaves_it_absent_on_both_sides() {
let (settings, secrets) = split_settings_and_secrets(AppSettings::default());
assert_eq!(settings.web_terminal.access_token, None);
assert_eq!(secrets.web_terminal_access_token, None);
}
fn sample_payload(format_version: u32) -> SettingsExportPayload {
SettingsExportPayload {
format_version,
exported_at: "2026-08-27T00:00:00Z".to_string(),
app_version: "0.4.14".to_string(),
settings: AppSettings::default(),
secrets: ExportedSecrets::default(),
}
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"triple-c-settings-export-test-{}-{}",
name,
uuid::Uuid::new_v4().simple()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_file_from_a_newer_format_is_refused_before_the_full_shape_is_parsed() {
// Shape-incompatible with the *current* `SettingsExportPayload` (a
// future version could easily have changed `settings` from an object
// to something else) as well as newer — so this only passes under
// the probe-first ordering. Parsing the full struct first (the old
// behavior) would fail on the shape mismatch and never reach the
// version check, producing the "unexpected shape" message instead of
// "newer version" / "Update Triple-C".
let dir = temp_dir("newer-format");
let path = write_raw_export(
&dir,
"export.triplec",
&serde_json::json!({
"format_version": SETTINGS_EXPORT_FORMAT_VERSION + 1,
"exported_at": "2026-08-27T00:00:00Z",
"app_version": "9.9.9",
"settings": "this-app-version-stores-settings-differently",
"secrets": {},
}),
"correct password",
);
let err = read_and_decrypt(&path, "correct password").unwrap_err();
assert!(err.contains("newer version"), "unexpected message: {}", err);
assert!(err.contains("Update Triple-C"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_file_at_the_current_format_is_accepted() {
let dir = temp_dir("current-format");
let path = write_export(
&dir,
"export.triplec",
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
"correct password",
);
let payload = read_and_decrypt(&path, "correct password").unwrap();
assert_eq!(payload.format_version, SETTINGS_EXPORT_FORMAT_VERSION);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_malformed_payload_produces_a_generic_error_not_a_raw_serde_message() {
// A `format_version` the probe accepts, but a `settings` field of
// the wrong *type* rather than just a missing field — this is what
// makes `serde_json` produce an "invalid type: string `...`, expected
// struct AppSettings" error that quotes the offending value
// verbatim. That value here stands in for plaintext that, in a real
// export, could be a live credential — the assertion below is only
// meaningful against a fixture that actually exercises serde's
// value-quoting behavior, which a merely-missing-field fixture does
// not.
let dir = temp_dir("malformed");
let path = write_raw_export(
&dir,
"export.triplec",
&serde_json::json!({
"format_version": SETTINGS_EXPORT_FORMAT_VERSION,
"exported_at": "2026-08-27T00:00:00Z",
"app_version": "0.4.14",
"settings": "NOT-A-REAL-CREDENTIAL-abc123",
"secrets": {},
}),
"correct password",
);
let err = read_and_decrypt(&path, "correct password").unwrap_err();
assert!(
!err.contains("NOT-A-REAL-CREDENTIAL-abc123"),
"leaked plaintext into the error: {}",
err
);
assert!(err.contains("doesn't look like a valid settings export"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_wrong_password_is_reported_without_a_version_check_ever_running() {
let dir = temp_dir("wrong-password");
let path = write_export(
&dir,
"export.triplec",
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
"correct password",
);
let err = read_and_decrypt(&path, "wrong password").unwrap_err();
assert!(
err.contains("Wrong password"),
"unexpected message: {}",
err
);
std::fs::remove_dir_all(&dir).ok();
}
}
+20
View File
@@ -29,6 +29,21 @@ pub struct AppState {
pub auth_bridge: Arc<AuthBridgeManager>,
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
pub lifecycle: Arc<Lifecycle>,
/// The file `preview_settings_import` last decrypted successfully, held
/// so `apply_settings_import` can re-read and re-decrypt the same file
/// without the frontend ever passing a host path back to Rust as an
/// argument — see the doc comment on `commands::settings_export_commands`
/// for why that direction specifically is the one this app treats as
/// dangerous. Deliberately re-decrypted rather than cached in plaintext:
/// nothing here holds a decrypted secret in memory for longer than one
/// command's execution.
///
/// Also pins a hash of the file's ciphertext at preview time, so
/// `apply_settings_import` can refuse to proceed if the file on disk
/// changed underneath the pending import — otherwise confirming a
/// preview is not actually binding on what gets applied.
pub pending_settings_import:
Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>,
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -222,6 +237,7 @@ pub fn run() {
auth_bridge,
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
lifecycle,
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
})
.setup(move |app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
@@ -494,6 +510,10 @@ pub fn run() {
commands::settings_commands::inspect_ca_cert_path,
commands::settings_commands::list_aws_profiles,
commands::settings_commands::detect_host_timezone,
// Settings export/import
commands::settings_export_commands::export_settings,
commands::settings_export_commands::preview_settings_import,
commands::settings_export_commands::apply_settings_import,
// Terminal
commands::terminal_commands::open_terminal_session,
commands::terminal_commands::terminal_input,
+2
View File
@@ -3,6 +3,7 @@ pub mod container_config;
pub mod app_settings;
pub mod gateway_settings;
pub mod migration;
pub mod settings_export;
pub mod update_info;
pub use project::*;
@@ -10,4 +11,5 @@ pub use container_config::*;
pub use app_settings::*;
pub use gateway_settings::*;
pub use migration::*;
pub use settings_export::*;
pub use update_info::*;
+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 projects_store;
pub mod secure;
pub mod settings_crypto;
pub mod settings_store;
#[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
/// configures one.
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
if !existing.trim().is_empty() {
return Ok(existing);
}
if let Some(existing) = get_gateway_master_key()? {
return Ok(existing);
}
regenerate_gateway_master_key()
}
/// Read the gateway master key without minting one if none exists yet.
/// Distinct from [`get_or_create_gateway_master_key`], which mints as a side
/// effect the read half of that function must not have — settings export
/// (triple-c#35) needs "is there one, and if so what is it", not "make sure
/// one exists".
pub fn get_gateway_master_key() -> Result<Option<String>, String> {
Ok(read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")?
.filter(|k| !k.trim().is_empty()))
}
/// Mint a new gateway master key, invalidating the old one. Projects using the
/// previous value must be updated.
pub fn regenerate_gateway_master_key() -> Result<String, String> {
// LiteLLM requires the master key to start with `sk-`.
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
store_gateway_master_key(&key)?;
Ok(key)
}
/// Store an exact given gateway master key, replacing any previous one.
///
/// Distinct from [`regenerate_gateway_master_key`], which always mints a
/// fresh random value: this exists for settings import (triple-c#35), where
/// restoring the *same* key an export captured is the point — projects on
/// the destination machine may not exist yet, but a project migrated or
/// re-added later that still has the old key pasted into its config must
/// keep working against it. Blank input is rejected rather than silently
/// stored, matching every other `store_*` function in this module.
pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
if key.trim().is_empty() {
return Err("Refusing to store an empty gateway master key.".to_string());
}
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
entry
.set_password(&key)
.set_password(key.trim())
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
bump_gateway_secret_version()?;
Ok(key)
bump_gateway_secret_version()
}
@@ -0,0 +1,184 @@
//! Password-based encryption for the settings export/import file — see
//! triple-c#35.
//!
//! The exported payload can carry live credentials (the shared Claude OAuth
//! token, the gateway provider/master keys — see
//! `commands::settings_export_commands`), so this is not encryption for its
//! own sake; a wrong or missing key here is a real credential leak, not a
//! cosmetic bug. Argon2id derives a 256-bit key from the password (memory-
//! hard, meaningfully resistant to GPU/ASIC brute-forcing in a way PBKDF2 at
//! any reasonable iteration count is not), and AES-256-GCM is what actually
//! encrypts — authenticated, so a wrong password is detected by a failed tag
//! check rather than producing silent garbage.
//!
//! File format: `MAGIC (4 bytes) | salt (16 bytes) | nonce (12 bytes) |
//! ciphertext+tag`. The salt and nonce are not secret — they are written in
//! the clear right here, on purpose. The salt's only job is to make two
//! exports with the same password derive different keys (defeats a
//! precomputed-table attack against the password alone); the nonce's job is
//! GCM's requirement that a (key, nonce) pair never repeat. Both hold
//! because a fresh random value is drawn for each, on every call to
//! [`encrypt`].
//!
//! The whole header (magic + salt + nonce) is passed to AES-GCM as
//! associated data, not just placed alongside the ciphertext — free to do,
//! and it makes tampering with any header byte fail the same authentication
//! check the ciphertext gets, by construction rather than as a side effect
//! of the salt/nonce also feeding key derivation and the cipher.
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Nonce};
use argon2::{Algorithm, Argon2, Params, Version};
use rand::RngCore;
use zeroize::Zeroizing;
/// Identifies the file as a Triple-C settings export and pins the format —
/// a change to the salt/nonce lengths or the KDF/cipher choice below needs a
/// new magic value, not a silent reinterpretation of old bytes.
const MAGIC: &[u8; 4] = b"TCX1";
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const KEY_LEN: usize = 32;
const HEADER_LEN: usize = MAGIC.len() + SALT_LEN + NONCE_LEN;
/// Argon2id parameters: memory cost in KiB, time cost (iterations),
/// parallelism. `(19 MiB, 2, 1)` is OWASP's documented minimum recommendation
/// for Argon2id — deliberately heavier than a login-flow KDF would use, since
/// this runs once per export/import rather than on every request, so trading
/// roughly a second of wall time for real brute-force resistance costs
/// nothing a user would notice.
fn argon2_params() -> Params {
Params::new(19 * 1024, 2, 1, Some(KEY_LEN)).expect("hardcoded Argon2 params are valid")
}
/// The derived key is wrapped in `Zeroizing` so it is overwritten with zeros
/// when it drops rather than left in freed memory for whatever reuses that
/// stack slot next — cheap insurance (`zeroize` is already in the dependency
/// tree via `aes-gcm`) for material that exists only to decrypt live
/// credentials.
fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, String> {
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params());
let mut key = Zeroizing::new([0u8; KEY_LEN]);
argon2
.hash_password_into(password.as_bytes(), salt, &mut *key)
.map_err(|e| format!("Failed to derive encryption key: {}", e))?;
Ok(key)
}
/// Encrypt `plaintext` with a key derived from `password`. Returns the whole
/// file's bytes (header + ciphertext) — see the module doc for the layout.
pub fn encrypt(plaintext: &[u8], password: &str) -> Result<Vec<u8>, String> {
let mut salt = [0u8; SALT_LEN];
rand::rng().fill_bytes(&mut salt);
let key = derive_key(password, &salt)?;
let mut nonce_bytes = [0u8; NONCE_LEN];
rand::rng().fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let mut header = Vec::with_capacity(HEADER_LEN);
header.extend_from_slice(MAGIC);
header.extend_from_slice(&salt);
header.extend_from_slice(&nonce_bytes);
let cipher = Aes256Gcm::new_from_slice(&*key)
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
// The header (magic + salt + nonce) is authenticated as associated data
// even though none of it is secret: it costs nothing extra here, and it
// means tampering with any header byte is caught by the same tag check
// that already covers the ciphertext, by construction rather than as a
// side effect of the header also feeding key/nonce derivation.
let ciphertext = cipher
.encrypt(nonce, Payload { msg: plaintext, aad: &header })
.map_err(|e| format!("Encryption failed: {}", e))?;
let mut out = header;
out.extend_from_slice(&ciphertext);
Ok(out)
}
/// Decrypt a file produced by [`encrypt`]. The one error this returns for a
/// wrong password is deliberately generic ("wrong password, or the file is
/// corrupted") rather than distinguishing the two: GCM's authentication tag
/// fails to verify for the wrong key on essentially any ciphertext, so there
/// is no reliable way to tell "wrong password" from "corrupted file" apart,
/// and guessing would be worse than saying so.
///
/// Returns `Zeroizing<Vec<u8>>` rather than a plain `Vec<u8>` — the plaintext
/// this recovers is the whole settings-plus-secrets payload, so it gets the
/// same "wipe it when it drops" treatment as the derived key in
/// [`derive_key`].
pub fn decrypt(data: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>, String> {
if data.len() < HEADER_LEN {
return Err("This does not look like a Triple-C settings export (file too short).".to_string());
}
if &data[..MAGIC.len()] != MAGIC {
return Err("This does not look like a Triple-C settings export (unrecognized file).".to_string());
}
let header = &data[..HEADER_LEN];
let salt = &data[MAGIC.len()..MAGIC.len() + SALT_LEN];
let nonce_bytes = &data[MAGIC.len() + SALT_LEN..HEADER_LEN];
let ciphertext = &data[HEADER_LEN..];
let key = derive_key(password, salt)?;
let cipher = Aes256Gcm::new_from_slice(&*key)
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.decrypt(nonce, Payload { msg: ciphertext, aad: header })
.map(Zeroizing::new)
.map_err(|_| "Wrong password, or the file is corrupted.".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_round_trip_with_the_right_password_recovers_the_plaintext() {
let plaintext = b"{\"settings\": \"whatever\"}";
let encrypted = encrypt(plaintext, "correct horse battery staple").unwrap();
let decrypted = decrypt(&encrypted, "correct horse battery staple").unwrap();
assert_eq!(&*decrypted, plaintext);
}
#[test]
fn the_wrong_password_fails_rather_than_returning_garbage() {
let encrypted = encrypt(b"secret payload", "correct password").unwrap();
let result = decrypt(&encrypted, "wrong password");
assert!(result.is_err(), "decrypting with the wrong password must fail, not silently succeed");
}
#[test]
fn two_exports_of_the_same_plaintext_and_password_produce_different_files() {
// If this ever failed it would mean the salt or nonce stopped being
// randomized — either one repeating is a real security regression
// (a fixed salt lets an attacker precompute against the password
// alone; a repeated (key, nonce) pair breaks GCM's guarantees
// outright), not just a cosmetic one.
let a = encrypt(b"same plaintext", "same password").unwrap();
let b = encrypt(b"same plaintext", "same password").unwrap();
assert_ne!(a, b, "two independent exports must not be byte-identical");
}
#[test]
fn corrupting_a_single_byte_of_ciphertext_is_detected() {
let mut encrypted = encrypt(b"tamper-evident payload", "a password").unwrap();
let last = encrypted.len() - 1;
encrypted[last] ^= 0xFF;
assert!(decrypt(&encrypted, "a password").is_err());
}
#[test]
fn a_file_that_is_too_short_is_rejected_cleanly_not_by_panicking() {
assert!(decrypt(b"short", "any password").is_err());
assert!(decrypt(b"", "any password").is_err());
}
#[test]
fn a_file_with_the_wrong_magic_is_rejected() {
let mut encrypted = encrypt(b"payload", "password").unwrap();
encrypted[0] = b'X';
assert!(decrypt(&encrypted, "password").is_err());
}
}
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import ExportSettingsModal from "./ExportSettingsModal";
const exportSettings = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
exportSettings: (password: string) => exportSettings(password),
}));
beforeEach(() => {
vi.clearAllMocks();
});
function fillPasswords(password: string, confirm: string) {
fireEvent.change(screen.getByLabelText("Password"), { target: { value: password } });
fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: confirm } });
}
describe("ExportSettingsModal", () => {
it("keeps the submit button disabled until the passwords are long enough and match", () => {
render(<ExportSettingsModal onClose={vi.fn()} />);
const submit = screen.getByRole("button", { name: /choose where to save/i });
expect(submit).toBeDisabled();
fillPasswords("short", "short");
expect(submit).toBeDisabled();
expect(screen.getByText(/use at least 8 characters/i)).toBeInTheDocument();
fillPasswords("longenoughpassword", "different");
expect(submit).toBeDisabled();
expect(screen.getByText(/don't match/i)).toBeInTheDocument();
fillPasswords("longenoughpassword", "longenoughpassword");
expect(submit).not.toBeDisabled();
});
it("exports with the entered password and shows success", async () => {
exportSettings.mockResolvedValue(true);
render(<ExportSettingsModal onClose={vi.fn()} />);
fillPasswords("longenoughpassword", "longenoughpassword");
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
await waitFor(() => expect(exportSettings).toHaveBeenCalledWith("longenoughpassword"));
await waitFor(() => expect(screen.getByText(/settings exported/i)).toBeInTheDocument());
});
it("closes quietly when the save dialog is dismissed", async () => {
exportSettings.mockResolvedValue(false);
const onClose = vi.fn();
render(<ExportSettingsModal onClose={onClose} />);
fillPasswords("longenoughpassword", "longenoughpassword");
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(screen.queryByText(/settings exported/i)).not.toBeInTheDocument();
});
it("shows an error rather than closing when the export fails", async () => {
exportSettings.mockRejectedValue("Disk is full");
const onClose = vi.fn();
render(<ExportSettingsModal onClose={onClose} />);
fillPasswords("longenoughpassword", "longenoughpassword");
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
await waitFor(() => expect(screen.getByText("Disk is full")).toBeInTheDocument());
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
import { useState } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Field, { inputClass } from "../ui/Field";
import { exportSettings } from "../../lib/tauri-commands";
interface Props {
onClose: () => void;
}
const MIN_PASSWORD_LENGTH = 8;
/**
* Password entry for exporting global settings. The save dialog itself opens
* from Rust once a password is confirmed here see the doc comment on
* `commands::settings_export_commands` for why the host path never
* round-trips through this component.
*/
export default function ExportSettingsModal({ onClose }: Props) {
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const mismatch = confirmPassword.length > 0 && password !== confirmPassword;
const tooShort = password.length > 0 && password.length < MIN_PASSWORD_LENGTH;
const canSubmit = password.length >= MIN_PASSWORD_LENGTH && password === confirmPassword;
const handleExport = async () => {
setError(null);
setBusy(true);
try {
const saved = await exportSettings(password);
if (saved) setDone(true);
// `false` means the save dialog was dismissed — close quietly, same as
// if the user had cancelled the modal itself.
else onClose();
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<Modal
title="Export settings"
description="Saves your global settings and any stored credentials (a shared Claude login, gateway keys) to one encrypted file. Project-specific settings and container data are not included."
widthClassName="w-[28rem]"
dismissible={!busy}
onClose={onClose}
footer={
done ? (
<Button size="md" variant="primary" onClick={onClose}>
Done
</Button>
) : (
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
size="md"
variant="primary"
onClick={() => void handleExport()}
disabled={!canSubmit || busy}
>
{busy ? "Exporting…" : "Choose where to save…"}
</Button>
</>
)
}
>
{done ? (
<p className="text-[13px] text-[var(--success)]">
Settings exported. Keep the password somewhere safe there is no way to recover
the file without it.
</p>
) : (
<div className="space-y-3">
<Field label="Password" hint={`At least ${MIN_PASSWORD_LENGTH} characters. You'll need this exact password to import the file later.`}>
{(id) => (
<input
id={id}
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy}
className={inputClass}
/>
)}
</Field>
<Field label="Confirm password">
{(id) => (
<input
id={id}
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={busy}
className={inputClass}
/>
)}
</Field>
{tooShort && (
<p className="text-xs text-[var(--error)]">
Use at least {MIN_PASSWORD_LENGTH} characters.
</p>
)}
{mismatch && <p className="text-xs text-[var(--error)]">Passwords don't match.</p>}
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
</div>
)}
</Modal>
);
}
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import ImportSettingsModal from "./ImportSettingsModal";
import type { AppSettings, SettingsImportOutcome, SettingsImportPreview } from "../../lib/types";
const previewSettingsImport = vi.fn();
const applySettingsImport = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
previewSettingsImport: (password: string) => previewSettingsImport(password),
applySettingsImport: (password: string) => applySettingsImport(password),
}));
beforeEach(() => {
vi.clearAllMocks();
});
const samplePreview: SettingsImportPreview = {
exported_at: "2026-08-27T00:00:00Z",
app_version: "0.4.14",
custom_env_var_count: 2,
gateway_model_count: 0,
has_claude_code_settings: false,
has_claude_oauth_token: true,
has_gateway_api_key: false,
has_gateway_master_key: false,
has_web_terminal_access_token: false,
enables_web_terminal: false,
ollama_base_url: null,
llamacpp_base_url: null,
openai_compatible_base_url: null,
gateway_api_base: null,
image_source: "registry",
custom_image_name: null,
};
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
return { settings, secret_restore_warnings: secretRestoreWarnings };
}
describe("ImportSettingsModal", () => {
it("keeps 'Choose file' disabled until a password is entered", () => {
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
expect(screen.getByRole("button", { name: /choose file/i })).toBeDisabled();
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
expect(screen.getByRole("button", { name: /choose file/i })).not.toBeDisabled();
});
it("shows the preview and confirms with the same password used to open it", async () => {
previewSettingsImport.mockResolvedValue(samplePreview);
applySettingsImport.mockResolvedValue(outcome({} as AppSettings));
const onImported = vi.fn();
render(<ImportSettingsModal onClose={vi.fn()} onImported={onImported} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await waitFor(() => expect(previewSettingsImport).toHaveBeenCalledWith("hunter2"));
expect(await screen.findByText(/2 global custom env vars/i)).toBeInTheDocument();
expect(screen.getByText(/your shared claude login/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
await waitFor(() => expect(applySettingsImport).toHaveBeenCalledWith("hunter2"));
await waitFor(() => expect(onImported).toHaveBeenCalledWith({}));
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
});
it("shows a distinct warning when the import would enable the web terminal", async () => {
previewSettingsImport.mockResolvedValue({ ...samplePreview, enables_web_terminal: true });
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
});
it("warns about a custom Docker image every time, not just on change", async () => {
previewSettingsImport.mockResolvedValue({
...samplePreview,
image_source: "custom",
custom_image_name: "ghcr.io/attacker/triple-c:latest",
});
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
expect(
await screen.findByText(/custom docker image: ghcr\.io\/attacker\/triple-c:latest/i),
).toBeInTheDocument();
});
it("shows a secret-restore warning alongside success rather than hiding it", async () => {
previewSettingsImport.mockResolvedValue(samplePreview);
applySettingsImport.mockResolvedValue(
outcome({} as AppSettings, ["Could not restore the gateway master key: keychain locked"]),
);
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await screen.findByText(/2 global custom env vars/i);
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
expect(await screen.findByText(/could not restore the gateway master key/i)).toBeInTheDocument();
});
it("closes quietly when the file picker is dismissed", async () => {
previewSettingsImport.mockResolvedValue(null);
const onClose = vi.fn();
render(<ImportSettingsModal onClose={onClose} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("shows an error when the password is wrong rather than a blank preview", async () => {
previewSettingsImport.mockRejectedValue("Wrong password, or the file is corrupted.");
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "wrong" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
expect(await screen.findByText(/wrong password, or the file is corrupted/i)).toBeInTheDocument();
});
it("shows an error if applying the import fails, without claiming success", async () => {
previewSettingsImport.mockResolvedValue(samplePreview);
applySettingsImport.mockRejectedValue("Keychain write failed");
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
await screen.findByText(/2 global custom env vars/i);
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
expect(await screen.findByText("Keychain write failed")).toBeInTheDocument();
expect(screen.queryByText(/settings imported/i)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,166 @@
import { useState } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Field, { inputClass } from "../ui/Field";
import { applySettingsImport, previewSettingsImport } from "../../lib/tauri-commands";
import { describeImport, describeImportWarnings } from "../../lib/settingsImportPreview";
import type { AppSettings, SettingsImportPreview } from "../../lib/types";
interface Props {
onClose: () => void;
/** Fired once the import is actually applied, so the caller can refresh
* whatever reads settings from the store. */
onImported: (settings: AppSettings) => void;
}
/**
* Two phases: enter the password and pick the file (backend resolves the
* file dialog itself see `commands::settings_export_commands`), then
* confirm a preview before anything is actually applied. The same password
* is reused for the second call rather than asking again; nothing about
* that call needs a fresh secret; the backend just doesn't cache the
* *decrypted payload* between the two.
*/
export default function ImportSettingsModal({ onClose, onImported }: Props) {
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<SettingsImportPreview | null>(null);
const [applied, setApplied] = useState(false);
const [secretWarnings, setSecretWarnings] = useState<string[]>([]);
const handleChooseFile = async () => {
setError(null);
setBusy(true);
try {
const result = await previewSettingsImport(password);
if (result) setPreview(result);
else onClose(); // File picker dismissed.
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
const handleConfirm = async () => {
setError(null);
setBusy(true);
try {
const outcome = await applySettingsImport(password);
setApplied(true);
setSecretWarnings(outcome.secret_restore_warnings);
onImported(outcome.settings);
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
};
return (
<Modal
title="Import settings"
description={
preview
? "Review what this file will change before applying it."
: "Choose a Triple-C settings export and enter the password it was created with."
}
widthClassName="w-[28rem]"
dismissible={!busy}
onClose={onClose}
footer={
applied ? (
<Button size="md" variant="primary" onClick={onClose}>
Done
</Button>
) : preview ? (
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button size="md" variant="primary" onClick={() => void handleConfirm()} disabled={busy}>
{busy ? "Importing…" : "Import"}
</Button>
</>
) : (
<>
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
size="md"
variant="primary"
onClick={() => void handleChooseFile()}
disabled={!password || busy}
>
{busy ? "Opening…" : "Choose file…"}
</Button>
</>
)
}
>
{applied ? (
<div className="space-y-2">
<p className="text-[13px] text-[var(--success)]">Settings imported.</p>
{secretWarnings.map((warning) => (
<p
key={warning}
className="px-2.5 py-2 text-xs text-[var(--error)] bg-[var(--error-muted)] border border-[var(--error)]/40 rounded-[var(--radius-control)] leading-snug"
>
{warning}
</p>
))}
</div>
) : preview ? (
<div className="space-y-3">
<p className="text-xs text-[var(--text-secondary)]">
Exported {new Date(preview.exported_at).toLocaleString()} from Triple-C{" "}
{preview.app_version}.
</p>
{/* Warnings render before the replace list, deliberately: the list
* below can run long, and the one thing here that most needs to
* stay above the fold while scrolling is "this turns on a
* network-listening service" or "this runs a different image"
* not a bullet buried among ordinary settings. */}
{describeImportWarnings(preview).map((warning) => (
<p
key={warning}
className="px-2.5 py-2 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] leading-snug break-all"
>
{warning}
</p>
))}
<div>
<p className="text-[13px] font-medium text-[var(--text-primary)]">This will replace:</p>
<ul className="mt-1 list-disc pl-4 text-[13px] text-[var(--text-secondary)] space-y-0.5">
{describeImport(preview).map((item) => (
<li key={item} className="break-all">
{item}
</li>
))}
</ul>
</div>
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
</div>
) : (
<div className="space-y-3">
<Field label="Password">
{(id) => (
<input
id={id}
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy}
className={inputClass}
/>
)}
</Field>
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
</div>
)}
</Modal>
);
}
+38 -1
View File
@@ -19,9 +19,11 @@ import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
import CertificateSettings from "./CertificateSettings";
import ExportSettingsModal from "./ExportSettingsModal";
import ImportSettingsModal from "./ImportSettingsModal";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
const { appSettings, saveSettings, setAppSettings } = useSettings();
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
@@ -33,6 +35,8 @@ export default function SettingsPanel() {
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
// Sync local state when appSettings change
useEffect(() => {
@@ -269,6 +273,39 @@ export default function SettingsPanel() {
</div>
</AccordionSection>
<AccordionSection id="backup" title="Backup" defaultOpen={false}>
<div className="space-y-2">
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Export your global settings and stored credentials (a shared Claude login,
gateway keys) to one password-encrypted file, or restore them on a new machine.
Project-specific settings and container data are never included.
</p>
<div className="flex gap-2">
<button
onClick={() => setShowExportModal(true)}
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Export settings
</button>
<button
onClick={() => setShowImportModal(true)}
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
Import settings
</button>
</div>
</div>
</AccordionSection>
{showExportModal && <ExportSettingsModal onClose={() => setShowExportModal(false)} />}
{showImportModal && (
<ImportSettingsModal
onClose={() => setShowImportModal(false)}
onImported={(settings) => setAppSettings(settings)}
/>
)}
{showInstructionsModal && (
<ClaudeInstructionsModal
instructions={globalInstructions}
+4
View File
@@ -36,5 +36,9 @@ export function useSettings() {
appSettings,
loadSettings,
saveSettings,
/** For a command that already returns the new `AppSettings` itself
* (settings import) updates the store without a redundant
* `updateSettings` round trip through the backend. */
setAppSettings,
};
}
+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 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
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -42,6 +42,15 @@ export const inspectCaCertPath = (path: string) =>
export const detectHostTimezone = () =>
invoke<string>("detect_host_timezone");
// Settings export/import — `false`/`null` mean the save/open dialog was
// dismissed, not an error.
export const exportSettings = (password: string) =>
invoke<boolean>("export_settings", { password });
export const previewSettingsImport = (password: string) =>
invoke<SettingsImportPreview | null>("preview_settings_import", { password });
export const applySettingsImport = (password: string) =>
invoke<SettingsImportOutcome>("apply_settings_import", { password });
// AWS
export const awsSsoRefresh = (projectId: string) =>
invoke<void>("aws_sso_refresh", { projectId });
+40
View File
@@ -292,6 +292,46 @@ export interface AppSettings {
global_claude_code_settings: ClaudeCodeSettings | null;
}
/** What `preview_settings_import` returns before anything is applied
* counts and presence flags only, never a secret value itself. Built from
* this, not from the raw import file, which the frontend never sees. */
export interface SettingsImportPreview {
exported_at: string;
app_version: string;
custom_env_var_count: number;
gateway_model_count: number;
has_claude_code_settings: boolean;
has_claude_oauth_token: boolean;
has_gateway_api_key: boolean;
has_gateway_master_key: boolean;
has_web_terminal_access_token: boolean;
/** Whether the import turns the web terminal on surfaced separately
* from the token above since either can be true without the other, and
* "this enables a service that listens on your network" must not hide
* inside a generic "settings replaced" summary. */
enables_web_terminal: boolean;
/** Non-blank custom base URLs the import would set endpoints, not
* secrets, so shown verbatim to disclose a redirect of model traffic. */
ollama_base_url: string | null;
llamacpp_base_url: string | null;
openai_compatible_base_url: string | null;
gateway_api_base: string | null;
/** Whether the import sets a custom Docker image, and its name if so
* this is the image every project container is created from, so worth
* more attention than an ordinary setting. */
image_source: ImageSource;
custom_image_name: string | null;
}
/** What `apply_settings_import` returns: the settings that were actually
* saved, plus a note for each keychain secret the import carried but could
* not be restored (a partial keychain failure must not read as unqualified
* success just because the settings half went through). */
export interface SettingsImportOutcome {
settings: AppSettings;
secret_restore_warnings: string[];
}
/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride
* in the payload rather than rejecting, so the field can render them inline
* while the user is still typing. */
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Triple-C Contributors
#
# This file is regenerated by .gitea/workflows/publish-aur-package.yml on every
# 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
+16 -17
View File
@@ -1,7 +1,9 @@
# Arch / CachyOS package
`PKGBUILD` here is the AUR `triple-c-bin` package's template — see triple-c#34
`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"
@@ -18,24 +20,23 @@ to be real dependencies of *this* binary and were dropped), and ran a real
## Publishing
`.gitea/workflows/publish-aur-package.yml` does the actual work: given a
`.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 pushes the result to AUR.
`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).
**Before it can push anything**, an AUR account has to exist and the
`triple-c-bin` package has to have been created (or you added as a
co-maintainer) under it — both are one-time, manual steps on
https://aur.archlinux.org, since there's no API to automate creating an
account or a new package. Once that's done, add the account's SSH private
key as the `AUR_SSH_PRIVATE_KEY` secret on this repo. Until that secret
exists, the workflow fails at the "Push to AUR" step with a message saying
so, rather than silently doing nothing.
**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
@@ -45,9 +46,7 @@ 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 directly in the AUR repo is silently overwritten the
next time this workflow runs.** Every run renders fresh from *this*
repo's template rather than starting from whatever AUR's copy currently
looks like, so a quick fix pushed straight to AUR (bumping `pkgrel` for a
packaging-only issue, say) survives only until the next dispatch. Make
the fix here instead.
**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.