Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd48baac8a | ||
|
|
e63318e04a | ||
|
|
adf9e7d603 | ||
|
|
3c8296843f | ||
|
|
7489516df3 | ||
|
|
6dcdeb89cb | ||
|
|
97e58db3c1 | ||
|
|
a606e3ab20 | ||
|
|
925e51e435 | ||
|
|
722d9aeff1 |
+92
-68
@@ -1,37 +1,36 @@
|
||||
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 uploads
|
||||
# the resulting `.pkg.tar.zst` to the GitHub release it was built from —
|
||||
# installable by hand with `pacman -U`. It does NOT commit anything back to
|
||||
# this repo — `packaging/arch/PKGBUILD` stays a hand-maintained template with
|
||||
# a placeholder version, and the workflow never starts from or writes to it.
|
||||
#
|
||||
# ## 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 +42,6 @@ on:
|
||||
|
||||
env:
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
AUR_REPO: ssh://aur@aur.archlinux.org/triple-c-bin.git
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
@@ -97,10 +95,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 +168,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 +205,7 @@ jobs:
|
||||
! grep -q "SKIP" PKGBUILD
|
||||
|
||||
- name: Validate with makepkg and namcap
|
||||
id: build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -205,6 +227,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 +254,45 @@ 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}"
|
||||
@@ -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(¤t, &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:
|
||||
|
||||
+2
-2
@@ -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
|
||||
│
|
||||
|
||||
Generated
+144
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¤t, &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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user