Compare commits
45
Commits
016de8f641
...
v0.4.18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24807bd5f | ||
|
|
1eb91a35eb | ||
|
|
aa0a574091 | ||
|
|
ed1dc8502c | ||
|
|
bd08ce8be2 | ||
|
|
7a5c0c1f13 | ||
|
|
3a49a67c1f | ||
|
|
88d6bed6db | ||
|
|
6cc48b3266 | ||
|
|
0fad306c25 | ||
|
|
8beb62b12c | ||
|
|
f2cfc0be8f | ||
|
|
99c9dd3cc2 | ||
|
|
dd48baac8a | ||
|
|
e63318e04a | ||
|
|
adf9e7d603 | ||
|
|
3c8296843f | ||
|
|
7489516df3 | ||
|
|
6dcdeb89cb | ||
|
|
97e58db3c1 | ||
|
|
a606e3ab20 | ||
|
|
925e51e435 | ||
|
|
722d9aeff1 | ||
|
|
81b1cfba09 | ||
|
|
ca6028bbb3 | ||
|
|
b3d07bda09 | ||
|
|
e025a7441a | ||
|
|
8f62949902 | ||
|
|
6354cb42b2 | ||
|
|
9b55a12b32 | ||
|
|
049232099b | ||
|
|
945883bb9d | ||
|
|
b71e15c2c0 | ||
|
|
06254db3d4 | ||
|
|
61bdbc4a5b | ||
|
|
439ef16f07 | ||
|
|
d8bb5ab262 | ||
|
|
4827170715 | ||
|
|
1a79852f65 | ||
|
|
68b73a9102 | ||
|
|
d09e2a2743 | ||
|
|
4371c9f03e | ||
|
|
eead748222 | ||
|
|
2c9482a67d | ||
|
|
88ffb4744a |
@@ -43,7 +43,18 @@ name: Build App (Preview)
|
||||
# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a
|
||||
# release; the point of a preview is the build you are testing now.
|
||||
#
|
||||
# `sync-release.yml` is workflow_dispatch-only, so nothing here reaches GitHub.
|
||||
# A preview release is not meant to reach GitHub. `build-app.yml`'s inline
|
||||
# mirror never sees one (it only runs for its own `push`-triggered release),
|
||||
# but `backfill-releases.yml` pulls every Gitea release unfiltered and would
|
||||
# faithfully forward a preview's `prerelease: true` if it were ever dispatched
|
||||
# while one existed — so `GitHubRelease::prerelease` in `update_commands.rs`
|
||||
# is real defence, not a no-op, even though the `preview-<sha>` tag shape
|
||||
# (never valid semver) already blocks it independently. (The previous
|
||||
# mechanism here, `sync-release.yml`, was `workflow_dispatch`-only and read
|
||||
# `gitea.event.release.*` fields that are only ever populated by a `release`
|
||||
# trigger, so it could never have actually run; deleted rather than fixed,
|
||||
# since build-app.yml's inline mirror already does what it was meant to do
|
||||
# for real releases. See triple-c#32.)
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
@@ -70,12 +81,23 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.VERSION }}
|
||||
sha: ${{ steps.version.outputs.SHA }}
|
||||
# Everything after the first `-` in VERSION (e.g. `preview.a1b2c3d`).
|
||||
# The bundle version fields never see this — see "Set app version" in
|
||||
# each build job — but it is baked into the binary as
|
||||
# `TRIPLE_C_BUILD_SUFFIX` so `get_app_version()` can still report it.
|
||||
# An installed preview otherwise reports the same bare number a
|
||||
# production build would, indistinguishable in the About panel and to
|
||||
# `check_for_updates`. See triple-c#32.
|
||||
suffix: ${{ steps.version.outputs.SUFFIX }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch all tags
|
||||
run: git fetch --tags
|
||||
|
||||
- name: Compute preview version
|
||||
id: version
|
||||
run: |
|
||||
@@ -86,21 +108,60 @@ jobs:
|
||||
# is testing and not something to hang a tag on.
|
||||
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
# The patch number is computed exactly as build-app.yml does it, so a
|
||||
# preview is labelled with the version the release it previews would
|
||||
# carry. This used to be hard-coded `.0`, which made every preview
|
||||
# installer claim to be x.y.0 no matter what it contained.
|
||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||
echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)"
|
||||
# The patch number must be the same "one past the highest patch
|
||||
# already used" build-app.yml computes for a real release — not a
|
||||
# distance from the latest tag. It used to be
|
||||
# `git rev-list --count <latest tag>..HEAD`, which build-app.yml's
|
||||
# own history section documents as broken for exactly this reason:
|
||||
# it resets to zero on every tag cut, so previews went *backwards*
|
||||
# (0.4.62 -> 0.4.0) the moment a release landed, and nothing stopped
|
||||
# a preview number from later colliding with a real release's.
|
||||
#
|
||||
# Reading the same `v${MAJOR_MINOR}.*` tags (including the `-mac`
|
||||
# / `-win` suffixed ones a partially-published release can leave
|
||||
# behind) means a preview built right before a release computes the
|
||||
# exact number that release is about to take — e.g. `0.4.13` for
|
||||
# both. That makes the two numerically *equal*, not "preview less
|
||||
# than release" — plain semver ordering does not make a
|
||||
# `-preview.<sha>` suffix sort lower on its own here, because
|
||||
# `check_for_updates` compares against the bare, stripped
|
||||
# `CARGO_PKG_VERSION`, never the suffixed display string. What
|
||||
# closes the loop is `update_commands.rs`'s `is_preview_build`
|
||||
# check, which relaxes that one comparison to `>=` specifically so
|
||||
# "a release exists at my own number" reads as an update. See
|
||||
# triple-c#32.
|
||||
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
|
||||
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
|
||||
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
|
||||
| sort -n | tail -1 || true)
|
||||
|
||||
# Mirrors build-app.yml's own `EXISTING` guard: this workflow is
|
||||
# also `workflow_dispatch`-able on `main`, not just PR-triggered, so
|
||||
# HEAD can be a commit a release was already cut from. Without this,
|
||||
# dispatching a preview there would compute `HIGHEST + 1` — one past
|
||||
# that release — and produce exactly the "preview outranks
|
||||
# production" failure triple-c#32 was filed over, just reintroduced
|
||||
# through the manual-dispatch door instead of the automatic one.
|
||||
EXISTING=$(git tag --points-at HEAD \
|
||||
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
|
||||
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
|
||||
| sort -n | tail -1 || true)
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — matching it"
|
||||
PATCH="${EXISTING}"
|
||||
elif [ -n "$HIGHEST" ]; then
|
||||
echo "Highest patch already used on this line: ${HIGHEST}"
|
||||
PATCH=$((HIGHEST + 1))
|
||||
else
|
||||
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||
PATCH=0
|
||||
fi
|
||||
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}"
|
||||
SUFFIX="preview.${SHORT_SHA}"
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}-${SUFFIX}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "Computed preview version: ${VERSION}"
|
||||
|
||||
# One release, created once. The three build jobs run concurrently, so
|
||||
@@ -249,6 +310,13 @@ jobs:
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ./app
|
||||
env:
|
||||
# Baked into the binary via `option_env!` in `get_app_version()` —
|
||||
# the bundle version above stays bare (WiX/MSI's ProductVersion has
|
||||
# no room for a suffix), so this is the only place a preview build
|
||||
# can still tell itself apart from a production one. See
|
||||
# triple-c#32.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build
|
||||
@@ -361,6 +429,9 @@ jobs:
|
||||
|
||||
- name: Build Tauri app (universal)
|
||||
working-directory: ./app
|
||||
env:
|
||||
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build --target universal-apple-darwin
|
||||
@@ -489,6 +560,8 @@ jobs:
|
||||
working-directory: ./app
|
||||
env:
|
||||
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||
cargo tauri build
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Secret Scan
|
||||
|
||||
# **No `paths:` filter, deliberately.** The credential this exists for lived in
|
||||
# `app/src-tauri/src/docker/container.rs`, which `build.yml` would have skipped —
|
||||
# that workflow only runs for `container/**`. A scan that can be avoided by
|
||||
# touching the wrong directory is not a scan.
|
||||
#
|
||||
# This is the half of the check that nobody can bypass. The pre-commit hook in
|
||||
# `.githooks/` is faster and friendlier, but it is opt-in per clone and
|
||||
# `--no-verify` skips it; both are true of every git hook and neither is fixable
|
||||
# from inside a repository.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The whole tracked tree, not just the diff. Scanning a range is cheaper
|
||||
# but depends on getting the range right across pushes, force-pushes,
|
||||
# merges and PR events — and a wrong range fails *open*. The full scan
|
||||
# takes under half a second on this repository and cannot be evaded by
|
||||
# arranging for the interesting commit to sit outside the window.
|
||||
- name: Scan tracked files for credentials
|
||||
run: sh scripts/scan-secrets.sh --tracked
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Sync Release to GitHub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mirror release to GitHub
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
RELEASE_TAG: ${{ gitea.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ gitea.event.release.name }}
|
||||
RELEASE_BODY: ${{ gitea.event.release.body }}
|
||||
IS_PRERELEASE: ${{ gitea.event.release.prerelease }}
|
||||
IS_DRAFT: ${{ gitea.event.release.draft }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
echo "==> Creating release $RELEASE_TAG on GitHub..."
|
||||
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/$GITHUB_REPO/releases \
|
||||
-d "{
|
||||
\"tag_name\": \"$RELEASE_TAG\",
|
||||
\"name\": \"$RELEASE_NAME\",
|
||||
\"body\": $(echo "$RELEASE_BODY" | jq -Rs .),
|
||||
\"draft\": $IS_DRAFT,
|
||||
\"prerelease\": $IS_PRERELEASE
|
||||
}")
|
||||
|
||||
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||
echo "Release created. Upload URL: $UPLOAD_URL"
|
||||
|
||||
echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do
|
||||
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
|
||||
|
||||
echo "==> Downloading asset: $ASSET_NAME"
|
||||
curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL"
|
||||
|
||||
echo "==> Uploading $ASSET_NAME to GitHub..."
|
||||
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@/tmp/$ASSET_NAME" \
|
||||
"$UPLOAD_URL?name=$ENCODED_NAME"
|
||||
|
||||
echo " Uploaded: $ASSET_NAME"
|
||||
done
|
||||
|
||||
echo "==> Release sync complete."
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Refuse a commit that adds something shaped like a live credential.
|
||||
#
|
||||
# Installed by pointing git at this directory:
|
||||
#
|
||||
# git config core.hooksPath .githooks
|
||||
#
|
||||
# which `npm run hooks` in app/ does for you. It is per-clone — git will not let
|
||||
# a repository configure its own hooks path, for the obvious reason that cloning
|
||||
# a repo would then be enough to run its code. So this is opt-in on every
|
||||
# machine, `--no-verify` skips it, and neither of those is a flaw to fix here:
|
||||
# the CI job in `.gitea/workflows/build.yml` is the half nobody can bypass. The
|
||||
# hook exists to tell you in one second rather than in five minutes.
|
||||
exec "$(git rev-parse --show-toplevel)/scripts/scan-secrets.sh" --staged
|
||||
@@ -79,23 +79,34 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
||||
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||
modals — see "UI conventions" below.
|
||||
- **Files is container-side only. It does no host filesystem I/O, and must not grow any.**
|
||||
The tab lists, opens (text and image viewer), renames and creates folders *inside* the
|
||||
container: `list_container_files`, `read_container_file`, `rename_container_path`,
|
||||
`create_container_directory`. There is no upload button, no "Save to host…", and no
|
||||
drop-into-the-pane. Four successive audits found that host filesystem paths crossing IPC
|
||||
were where the criticals lived — a caller-named host destination for container-controlled
|
||||
bytes, an arbitrary host source read into the container, a `link(2)` upload reservation
|
||||
that succeeded against a directory and failed forever on any filesystem without hard
|
||||
links — so the feature was narrowed rather than fixed a fifth time. If a host path ever
|
||||
needs to reach this pane again, the honest shape is for the *backend* to drive
|
||||
`tauri-plugin-dialog`, so no host path arrives over IPC at all.
|
||||
- **A file gets *in* by being dropped on the Terminal, and *out* through "Back up
|
||||
container".** Those two are the whole host↔container story, they predate the Files work,
|
||||
and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent` is Tauri's
|
||||
native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for *whose*
|
||||
drop it is, a document-wide `dropIsBlocked` for whether the app should accept one at all;
|
||||
keep both halves and keep `PaneVisibility`). Backup is
|
||||
- **The Files pane's host transfers open their dialog from Rust, and that is the whole
|
||||
design — do not move it back into the webview.** The tab browses, views (text and image),
|
||||
renames and creates folders inside the container (`list_container_files`,
|
||||
`read_container_file`, `rename_container_path`, `create_container_directory`), and it
|
||||
copies single files in and out (`upload_files_to_container`, `download_container_file`).
|
||||
The second pair call `pick_files_to_upload` / `pick_save_path`, which drive
|
||||
`tauri-plugin-dialog` from the *backend*: the webview can ask for a picker and that is the
|
||||
entirety of its influence — it cannot name a host path as an *input*. The claim stops
|
||||
there and should not be widened: host paths still travel outward in error text, canonical
|
||||
ones included. What is closed is the direction that produced the criticals.
|
||||
That shape is not decoration. Four successive audits found that host filesystem paths
|
||||
crossing IPC were where the criticals lived — a caller-named host destination for
|
||||
container-controlled bytes, an arbitrary host source read into the container, a `link(2)`
|
||||
upload reservation that succeeded against a directory and failed forever on any filesystem
|
||||
without hard links. The feature was removed rather than fixed a fifth time, and it came
|
||||
back only in the shape that removes the class: a frontend-driven dialog handing Rust a
|
||||
string is the exact thing that failed, so re-introducing `open()`/`save()` in `FilesTab`
|
||||
would undo the whole point while looking like a simplification.
|
||||
None of the reservation machinery came back with it. There is no destination reservation,
|
||||
no placeholder rollback and no collision marker — the OS save dialog already asks about
|
||||
overwriting, and Docker's archive extractor overwrites on upload the way `cp` does.
|
||||
- **Drag-and-drop is still not it.** There is no drop-into-the-Files-pane and no OS
|
||||
drag-out; the buttons are the gesture. A file also gets *in* by being dropped on the
|
||||
Terminal, and a whole tree comes *out* through "Back up container" — those two predate the
|
||||
Files work and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent`
|
||||
is Tauri's native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for
|
||||
*whose* drop it is, a document-wide `dropIsBlocked` for whether the app should accept one
|
||||
at all; keep both halves and keep `PaneVisibility`). Backup is
|
||||
`file_commands::download_container_backup`.
|
||||
- **`resolve_host_path` applies the full lexical predicate twice — as written, and again
|
||||
after canonicalisation.** That includes the general hidden-component rule, which
|
||||
@@ -103,8 +114,12 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
`~/.local/share` is refused. Do not narrow it back to a list of "credential" directories.
|
||||
That was tried, and allow-by-omission let `~/.local/bin` (write there and you own the
|
||||
user's next shell command), `~/.password-store`, browser profiles and `~/.pki/nssdb`
|
||||
through a planted symlink with a perfectly visible name. The two callers left are
|
||||
occasional, so over-refusing is the cheaper mistake.
|
||||
through a planted symlink with a perfectly visible name. Over-refusing is the cheaper
|
||||
mistake. Note the cost is real and has grown: of the four callers, the Files pane's two
|
||||
are routine, and their path comes from a dialog — so an over-catch refuses a destination a
|
||||
person actually chose (`~/.config` is the common one). Accepted, and not a reason to
|
||||
narrow the rule, because the terminal drop and `download_container_backup` still take
|
||||
their host path over IPC and this predicate is their only boundary.
|
||||
- **OS drag-out is not here.** `tauri-plugin-drag`, `stage_container_file_for_drag` and its
|
||||
host staging directory were held back for separate hardening and live on
|
||||
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command
|
||||
@@ -510,6 +525,178 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
|
||||
`models/project.rs` for anything that should default to true.
|
||||
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
||||
|
||||
## Secrets
|
||||
|
||||
**`scripts/scan-secrets.sh` refuses a commit that adds something shaped like a live
|
||||
credential.** Enable the hook once per clone with `npm run hooks` (from `app/`), which sets
|
||||
`core.hooksPath` to `.githooks`. A repository cannot configure its own hooks path — cloning it
|
||||
would then be enough to run its code — so this is opt-in everywhere, and `--no-verify` skips it.
|
||||
The `Secret Scan` workflow is the half nobody can bypass; it carries **no `paths:` filter**, on
|
||||
purpose, because the incident that prompted all this lived in `app/**` and `build.yml` only runs
|
||||
for `container/**`.
|
||||
|
||||
Three rules, and the second half of the third is what keeps it usable: vendor-prefixed tokens
|
||||
(`ghp_`, `sk-`, `AKIA`, `xox`, …), `BEGIN … PRIVATE KEY` blocks, and an opaque literal assigned to
|
||||
a secret-shaped name. That last one needs **both** halves — the identifier must read as a
|
||||
credential *and* the whole literal must be hex or base64 with no word structure. Name-proximity
|
||||
alone flags `secure::get_project_secret(&id, "aws-secret-access-key")`, which is a keychain key
|
||||
name; the literal test is what excludes it. Measured against the tree: 0 false positives, and it
|
||||
catches the real incident (`9b2f4fe`) when replayed.
|
||||
|
||||
A line ending `pragma: allowlist secret` is skipped. Make a fixture obviously fake before reaching
|
||||
for it.
|
||||
|
||||
**Why this exists:** `the_custom_env_fingerprint_never_carries_the_value` used the maintainer's
|
||||
real Gitea **site-admin** token as its fixture — a test about secrets not escaping, leaking one. It
|
||||
survived 92 commits and fourteen days in the public GitHub mirror, past five audit rounds and two
|
||||
independent reviews, because every one of them read the code under change and this sat in a test
|
||||
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
|
||||
|
||||
## Settings export/import
|
||||
|
||||
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
|
||||
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that
|
||||
live in the OS keychain instead: the shared Claude Code OAuth login and the model gateway's two
|
||||
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are
|
||||
deliberately out of scope — this is not a project backup.
|
||||
|
||||
- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
|
||||
caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
|
||||
credential for a server that binds every interface — exporting `AppSettings` wholesale would
|
||||
have carried it along as if it were as inert as a port number, and importing it would have
|
||||
applied `web_terminal.enabled` and the token together with no more warning than any other
|
||||
setting, letting a crafted export silently stand up a LAN-listening terminal on the next launch.
|
||||
`export_settings`/`apply_settings_import` carve this one field out into `ExportedSecrets`
|
||||
instead, with the same "only overwrite what the import actually has" treatment as the other
|
||||
three secrets — except "leave it alone" has to be done by hand in `apply_settings_import`, since
|
||||
unlike the keychain secrets this one lives inside the `AppSettings` blob that gets replaced
|
||||
wholesale. `SettingsImportPreview::enables_web_terminal` also exists because of this: `enabled`
|
||||
and the token are independent fields, and "this turns on a listening service" must not hide
|
||||
inside a generic "settings replaced" summary. Read this as the standing example of the class of
|
||||
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
|
||||
like config but is actually a live credential would have the same problem.
|
||||
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
|
||||
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
|
||||
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
|
||||
encryption. A wrong password fails GCM's authentication tag rather than producing silent
|
||||
garbage. The salt and nonce are not secret and are written in the clear in the file's own
|
||||
header — the salt's job is only to make two exports of the same password derive different keys,
|
||||
and the nonce's only requirement is per-encryption uniqueness, which a fresh random draw on
|
||||
every export already gives it.
|
||||
- **The save/open dialogs are opened from Rust**, the same boundary `file_commands.rs`'s
|
||||
`pick_save_path`/`pick_files_to_upload` draw and document at length: a frontend-driven dialog
|
||||
handing Rust a host path string is the exact shape of bug that produced this app's past
|
||||
criticals. `preview_settings_import` resolves the chosen path itself and remembers it
|
||||
(`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without
|
||||
a path ever crossing back over IPC. It also pins a hash of the file's ciphertext next to that
|
||||
path, and `apply_settings_import` refuses to proceed if the file on disk no longer matches it —
|
||||
otherwise confirming a preview would not actually be binding on what gets applied, which matters
|
||||
given this feature's own threat model: a file shared between people may sit in a synced or
|
||||
otherwise shared directory that changes between the two calls.
|
||||
- **The decrypted payload is not cached between preview and apply — only the password is reused.**
|
||||
The frontend holds the password in React state and passes it to both calls; nothing in Rust
|
||||
holds decrypted plaintext — secrets included — in memory for longer than one command's
|
||||
execution, so `apply_settings_import` always re-decrypts rather than reusing anything
|
||||
`preview_settings_import` computed. `preview_settings_import` returns counts and presence flags
|
||||
only (`SettingsImportPreview`), never a secret value, so it's safe to hand to the frontend and
|
||||
render directly.
|
||||
- **Import replaces settings wholesale, but only writes secrets actually present in the file.**
|
||||
An import is "restore this environment," so the settings half is a full replace, not a
|
||||
field-by-field merge. Secrets are different on purpose: an absent secret in the export means
|
||||
"the source machine never had this configured," not "delete this on import" — a user who wants
|
||||
to clear a secret already has dedicated UI for that (signing out of shared auth, clearing the
|
||||
gateway key). Secrets are restored *before* the settings replace runs, not after — replacing
|
||||
settings is what triggers `reconcile_gateway`, and restoring the other way round leaves a real
|
||||
window where a gateway recreation happens against the destination's old keys.
|
||||
- **A restored gateway secret nudges a running gateway container to recreate itself, even when
|
||||
nothing about the gateway's *shape* changed.** `reconcile_gateway`'s `gateway_shape_changed` only
|
||||
compares port/provider/base URL/models — deliberately, since that's what's rendered into the
|
||||
container's config — so a secret-only change (same shape, new key) is invisible to it. Left
|
||||
alone, a running container would keep serving the old key material indefinitely after an import
|
||||
that restored a new one. `apply_settings_import` tracks whether either gateway secret was
|
||||
actually written and, if the gateway is enabled and its container both exists and is running,
|
||||
calls `docker::gateway::ensure_gateway_running` directly afterward — its own fingerprint already
|
||||
includes the secret rotation id (`storage::secure::get_gateway_secret_version`), so it recreates
|
||||
exactly when it should and no more.
|
||||
- **A keychain write failing during import is reported back, not only logged.** Each of the three
|
||||
`secure::store_*` calls collects its error into `SettingsImportOutcome::secret_restore_warnings`
|
||||
in addition to logging it — an import that silently restores two of three secrets but not the
|
||||
third must not read as unqualified success just because the settings half of the import (which
|
||||
runs after, and is validated before any of this) went through. `apply_settings_import` returns
|
||||
`SettingsImportOutcome { settings, secret_restore_warnings }` rather than bare `AppSettings` for
|
||||
this reason; `ImportSettingsModal` shows any warnings alongside the "Settings imported" message.
|
||||
- **The imported settings are validated *before* any secret is written, not just before the
|
||||
settings replace.** `apply_settings_import` calls
|
||||
`settings_commands::validate_settings_update(¤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.
|
||||
|
||||
## Packaging
|
||||
|
||||
Linux ships as `.deb`, `.rpm` and AppImage, all three built by `build-app.yml` (releases) and
|
||||
`build-app-preview.yml` (the PR check). **There is deliberately no Arch package.** A
|
||||
`triple-c-bin` `PKGBUILD` and a `publish-arch-package.yml` existed and were removed; they live on
|
||||
`hold/arch-packaging`. Do not re-add them without the piece that was always missing: the package
|
||||
was never on the AUR, so it was a manual `pacman -U` of a downloaded file — the same gesture as
|
||||
the AppImage, for a second artifact to keep working. Being `workflow_dispatch`-only it also
|
||||
reached 1 release in 28, while `HOW-TO-USE.md` told Arch users to download it from every release.
|
||||
An AUR account and its SSH key as a repo secret are what would make it worth having; until then
|
||||
the AppImage is the Arch story.
|
||||
|
||||
`scripts/install-appimage.sh` is the desktop-integration half, and it exists because an AppImage
|
||||
has no installer: it extracts the bundled icons into `~/.local/share/icons/hicolor` and writes a
|
||||
`.desktop` entry. It **rewrites** the `Exec` line rather than copying the bundled entry — the
|
||||
bundled one is `Exec=triple-c`, which resolves only inside the AppImage's own mount, so a
|
||||
verbatim copy yields a launcher entry that starts nothing. It keeps `StartupWMClass` exactly as
|
||||
the bundle sets it, which is what lets the shell match the window to the entry. Extraction uses
|
||||
`--appimage-extract`, which needs no FUSE, so the script works before `fuse2` is installed.
|
||||
|
||||
## Testing
|
||||
|
||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||
|
||||
+102
-20
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [First Launch](#first-launch)
|
||||
- [The Interface](#the-interface)
|
||||
@@ -32,6 +33,63 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Download the build for your platform from [GitHub Releases](https://github.com/shadowdao/triple-c/releases/latest).
|
||||
|
||||
| Platform | File | Install |
|
||||
|----------|------|---------|
|
||||
| **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. |
|
||||
| **macOS** | `Triple-C_<version>_universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. |
|
||||
| **Debian / Ubuntu** | `Triple-C_<version>_amd64.deb` | `sudo apt install ./Triple-C_<version>_amd64.deb` |
|
||||
| **Fedora / RHEL** | `Triple-C-<version>-1.x86_64.rpm` | `sudo dnf install ./Triple-C-<version>-1.x86_64.rpm` |
|
||||
| **Arch / CachyOS / other Linux** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. See the AppImage notes below. |
|
||||
|
||||
> **macOS note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it — right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`.
|
||||
|
||||
> **AppImage note:** Two things are worth knowing. Running an AppImage needs FUSE 2, which Arch and CachyOS do not install by default — `sudo pacman -S fuse2` once, or run it with `--appimage-extract-and-run` to sidestep FUSE entirely. And an AppImage is just an executable file: nothing registers it with the desktop, so it will not appear in your app launcher on its own. Run [`scripts/install-appimage.sh`](scripts/install-appimage.sh) to add a launcher entry and icons — see [Adding an AppImage to the app launcher](#adding-an-appimage-to-the-app-launcher).
|
||||
|
||||
> **No Arch package.** There was a `triple-c-bin` `.pkg.tar.zst` attached to some releases, built by a maintainer-triggered workflow. It was never on the AUR, so installing it meant downloading a file and running `pacman -U` — no better than the AppImage — and being manual-only it reached 1 release in 28, which made the promise of it worse than not making it. The `PKGBUILD` and its workflow are preserved on the `hold/arch-packaging` branch if an AUR package is ever worth doing properly.
|
||||
|
||||
### Adding an AppImage to the app launcher
|
||||
|
||||
An AppImage is a single executable file and nothing else. It carries a `.desktop`
|
||||
entry and icons *inside* itself, but nothing on your system ever reads them,
|
||||
because nothing installed it — so it will not show up in your app launcher, and
|
||||
running it from a file manager gives you a generic icon in the taskbar.
|
||||
|
||||
Put the AppImage somewhere stable first — `~/Apps` or `~/.local/bin`, not
|
||||
`~/Downloads` — because the launcher entry points at wherever the file is:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Apps
|
||||
mv ~/Downloads/Triple-C_*_amd64.AppImage ~/Apps/
|
||||
./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
|
||||
```
|
||||
|
||||
That copies the bundled icons into `~/.local/share/icons/hicolor` and writes
|
||||
`~/.local/share/applications/triple-c.desktop` pointing at the file you named.
|
||||
No sudo, nothing outside your home directory, and the AppImage itself is never
|
||||
copied or moved. To remove the entry again:
|
||||
|
||||
```bash
|
||||
./scripts/install-appimage.sh --uninstall
|
||||
```
|
||||
|
||||
The script rewrites the `Exec` line rather than reusing the bundled `.desktop`
|
||||
verbatim: the bundled one says `Exec=triple-c`, which resolves only inside the
|
||||
running AppImage's own mount, so a launcher entry copied straight out of the
|
||||
bundle would appear in the menu and then fail to start anything.
|
||||
|
||||
Two follow-ups worth knowing:
|
||||
|
||||
- **Upgrading.** The entry names one specific file. If you replace the AppImage
|
||||
with a newer version under a different filename, re-run the script against the
|
||||
new one. Keeping a stable name (`~/Apps/Triple-C.AppImage`) avoids this.
|
||||
- **The icon may not appear until you log out.** That is the desktop shell's
|
||||
icon cache, not a failed install — see
|
||||
[App Icon Missing After Installing (Linux)](#app-icon-missing-after-installing-linux).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Docker
|
||||
@@ -228,7 +286,7 @@ buttons. Below that are six tabs:
|
||||
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
|
||||
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
|
||||
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
|
||||
| **Files** | Browse, view and rename files inside the container — see [Files](#files) for how files get in and out |
|
||||
| **Files** | Browse, view and rename files inside the container, and move files between it and your own machine — see [Files](#files) |
|
||||
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
|
||||
|
||||
### Sessions
|
||||
@@ -351,7 +409,7 @@ it. The sidebar row carries only the two hover controls.
|
||||
| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck |
|
||||
| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab |
|
||||
| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container, upload files into it and save one back out |
|
||||
| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) |
|
||||
| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose |
|
||||
| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first |
|
||||
@@ -1170,8 +1228,8 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
|
||||
|
||||
### Files
|
||||
|
||||
The **Files** tab of Project Home browses inside a running container. It works entirely on the
|
||||
container side — it never reads or writes anything on your own machine. You can:
|
||||
The **Files** tab of Project Home browses inside a running container, and moves files between it
|
||||
and your own machine. You can:
|
||||
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
|
||||
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
|
||||
@@ -1181,32 +1239,50 @@ container side — it never reads or writes anything on your own machine. You ca
|
||||
- **Rename** an entry, from the row's Rename button or by pressing `F2`. A rename never moves a
|
||||
file between folders
|
||||
- **New folder** in the directory on screen
|
||||
- **Upload…**, from the toolbar, to copy files from your machine into the directory on screen
|
||||
- **Save to host…**, from a file's own row, to write that one file out to your machine
|
||||
- **Refresh** the directory listing at any time
|
||||
|
||||
The listing shows file names, sizes, and modification dates, and marks symbolic links.
|
||||
|
||||
#### Getting files in and out
|
||||
|
||||
The Files tab deliberately does **not** copy files between your computer and the container. There
|
||||
are two supported routes, and they are the ones to use:
|
||||
**Upload…** opens a file dialog on your machine, and whatever you choose is copied into the
|
||||
directory currently on screen. Uploaded files arrive owned by you inside the container, not by
|
||||
root. You can pick several files in one dialog; each is handled on its own, so if a folder or an
|
||||
over-sized file is among them, it is named in the message and the rest still arrive. Uploads are
|
||||
capped at **256 MB per file** — for anything larger, mount the folder into the project instead and
|
||||
skip the copying altogether.
|
||||
|
||||
- **To get a file in:** drag it from your desktop and **drop it onto the Terminal tab**. The file
|
||||
is copied into the container and its path is typed into the terminal for you, ready to hand to
|
||||
Claude Code. (You can also drop it onto the terminal's *Following* toggle — the whole pane is a
|
||||
drop target.)
|
||||
- **To get files out:** use **Back up container** in Project Home's **⋯** overflow menu. It writes
|
||||
a `.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose.
|
||||
For a single file, `cat` it in a terminal, or work in a project folder that is mounted from your
|
||||
host in the first place — those files are already on both sides.
|
||||
**Save to host…** does the reverse, for one file: a save dialog opens, you choose where the file
|
||||
goes, and it is written there. The button sits on the file's own row, and only on files. For a
|
||||
whole directory, use **Back up container** in Project Home's **⋯** overflow menu, which writes a
|
||||
`.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose — that
|
||||
is still the right tool for a tree.
|
||||
|
||||
Both routes refuse a destination whose path passes through a hidden folder — anything with a
|
||||
component beginning with `.`, such as `~/.ssh`, `~/.local/bin` or `~/.config`. That rule catches
|
||||
more than it strictly needs to (a path that happens to resolve through `~/.cache` or
|
||||
`node_modules/.pnpm` is refused as well), and the refusal says which folder tripped it. Choose a
|
||||
visible location such as `~/Documents` or `~/Downloads`.
|
||||
Dragging a file from your desktop and **dropping it onto the Terminal tab** works too, and is often
|
||||
the quickest way in when you are already typing: the file is copied into the container and its path
|
||||
is typed into the terminal for you, ready to hand to Claude Code. (The whole terminal pane is a
|
||||
drop target, including its *Following* toggle.) The Files pane itself is not a drop target.
|
||||
|
||||
Both dialogs are opened by Triple-C itself rather than by the page you are looking at. The page
|
||||
cannot name a place on your machine — it can only ask for a dialog — and nothing is read or written
|
||||
until you pick somewhere in it. Closing a dialog without choosing is not an error: nothing happens,
|
||||
and nothing is said about it.
|
||||
|
||||
Every one of these routes refuses a location whose path passes through a hidden *folder* — anything
|
||||
with a component beginning with `.`, such as `~/.ssh`, `~/.cache` or `~/.local/share` — or a system
|
||||
location, and it checks both the path as written and where it points after any symbolic links. That
|
||||
rule catches more than it strictly needs to, so now and then it will refuse a place you genuinely
|
||||
meant, `~/.config` among them. The refusal is a plain sentence saying so; choose a visible location
|
||||
such as `~/Documents` or `~/Downloads`.
|
||||
|
||||
The *file's own name* is a different matter, and dotfiles are fine: `.env`, `.gitignore` and the
|
||||
rest save normally, since you chose the name in the save dialog yourself. Only the folders on the
|
||||
way are judged.
|
||||
|
||||
If you already keep the project in a folder mounted into the container, the simplest answer is
|
||||
usually neither of the above: edit the file on your host and it is already inside.
|
||||
usually none of the above: edit the file on your host and it is already inside.
|
||||
|
||||
### Terminal Rendering
|
||||
|
||||
@@ -1518,3 +1594,9 @@ cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswit
|
||||
```
|
||||
|
||||
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
|
||||
|
||||
### App Icon Missing After Installing (Linux)
|
||||
|
||||
If Triple-C's icon shows as generic or blank right after installing — in the app menu, taskbar, and window titlebar alike — **log out and back in.**
|
||||
|
||||
Desktop shells (GNOME Shell, KDE Plasma) cache the list of installed apps and their resolved icons in memory when the shell starts, for performance. A freshly installed package's icon files land on disk correctly and its install hooks do rebuild the on-disk icon cache, but an already-running shell doesn't always notice — on X11 there used to be a way to soft-restart just the shell (GNOME's Alt+F2 → `r`) to force a reload, but under Wayland the shell *is* the compositor, so restarting it means ending the session. Logging out and back in starts a fresh shell that reads the current on-disk state, which picks the icon up.
|
||||
|
||||
@@ -24,7 +24,7 @@ This file is the architectural tour: what each subsystem is and why it works the
|
||||
- [Permission Modes](#permission-modes)
|
||||
- [Containers](#containers) — lifecycle, base-image migration, mounts, CA certificates, sibling containers
|
||||
- [Models and Authentication](#models-and-authentication) — backends, model aliases, gateway, shared token
|
||||
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view
|
||||
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view, host file transfers
|
||||
- [Inside a Project](#inside-a-project) — capability tiles, Mission Control, web terminal, speech-to-text
|
||||
- [Key Files](#key-files) · [CSS / Styling Notes](#css--styling-notes) · [Container Image](#container-image)
|
||||
|
||||
@@ -105,7 +105,7 @@ configuration. Per-project configuration lives in the Config tab rather than in
|
||||
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
||||
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||
| **Files** | Browse, view and rename files inside the container. Container-side only: to get a file *in*, drop it on the Terminal tab; to get files *out*, use **Back up container** |
|
||||
| **Files** | Browse, view, rename and create folders inside the container, upload host files into the directory on screen, and save one file back out to the host — see [Host File Transfers](#host-file-transfers). A whole tree still comes out through **Back up container** |
|
||||
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
|
||||
|
||||
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
||||
@@ -442,6 +442,39 @@ per project.
|
||||
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
|
||||
once Playwright is present — not a setup route.
|
||||
|
||||
### Host File Transfers
|
||||
|
||||
Four routes move files across the boundary: **Upload…** and the per-row **Save to host…** in the
|
||||
Files tab, a file dropped onto the Terminal tab, and **Back up container**. All four share one path
|
||||
policy in `commands/file_commands.rs`.
|
||||
|
||||
- **The OS dialogs are opened by Rust, not by the webview.** `upload_files_to_container` and
|
||||
`download_container_file` drive `tauri-plugin-dialog` themselves and take nothing but a project
|
||||
id and a container-side path; `FilesTab.tsx` imports no dialog plugin and `useFileManager`'s
|
||||
`uploadFiles` takes no argument at all. The web UI can ask for a dialog, and that is the whole of
|
||||
its influence over where a file comes from or goes — it cannot name a host path as an *input*.
|
||||
This is a boundary rather than a convention: a dialog the page itself opens is only as trustworthy
|
||||
as the page. Be precise about the limit, though — host paths still travel *outward* in error text,
|
||||
canonical ones included, so this closes the inbound direction and not both.
|
||||
- **The dialog's pre-filled name is sanitized, because a container authored it.** On Windows the
|
||||
save dialog parses its name box as a path, and a container can name a file
|
||||
`..\..\Users\you\…\Word\STARTUP\x.dotm` — one POSIX segment, so nothing upstream objects.
|
||||
`suggested_save_name` replaces every separator and every character NTFS refuses, so the string
|
||||
cannot be a path on any platform this ships to.
|
||||
- **One policy for every host path.** A source or destination whose path passes through a hidden
|
||||
folder (`~/.ssh`, `~/.cache`, `~/.local/share`, anything dot-prefixed) or a system location is
|
||||
refused, and the check is applied both to the path as written and to what it resolves to after
|
||||
symlinks. It over-catches deliberately, so it will occasionally refuse somewhere a person
|
||||
genuinely meant — `~/.config`, say — and the refusal is a sentence naming the folder that tripped
|
||||
it, not an errno.
|
||||
- **Uploads are capped at 256 MB per file**; past that the answer is a mount, not a copy. One
|
||||
dialog's selection is handled file by file, so a folder or an oversized file among the selection
|
||||
is reported by name and does not stop the others. Uploaded files land owned by the container user,
|
||||
not root. A cancelled dialog is silent — `Ok(None)`, not an error.
|
||||
- **`download_container_file` is one file and files only** — no button on a folder row. A directory
|
||||
is what `download_container_backup` is for. There is no drop target on the Files pane; the
|
||||
Terminal tab keeps the one it has.
|
||||
|
||||
## Inside a Project
|
||||
|
||||
### Container Introspection (Capability Tiles)
|
||||
@@ -513,7 +546,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
||||
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
||||
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) plus **Upload…** and per-row **Save to host…**; imports no dialog plugin — the dialogs are Rust's |
|
||||
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
|
||||
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
|
||||
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
|
||||
@@ -536,7 +569,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
||||
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) |
|
||||
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) and the host transfers (upload, save one file out); never handles a host path |
|
||||
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||
@@ -561,7 +594,7 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir) plus `download_container_backup` |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir), the host transfers `upload_files_to_container` and `download_container_file` — each opening its own OS dialog here in Rust — plus `download_container_backup`, and the hidden-folder path policy all of them share |
|
||||
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
|
||||
|
||||
+5
-6
@@ -412,13 +412,12 @@ triple-c/
|
||||
│
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline
|
||||
│ ├── build-app-preview.yml # Preview builds
|
||||
│ ├── build.yml # Build container image (multi-arch)
|
||||
│ ├── build-stt.yml # Build the STT image
|
||||
│ ├── sync-release.yml # Mirror releases to GitHub
|
||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│ └── cleanup-releases.yml # Prune old releases
|
||||
│ ├── cleanup-releases.yml # Prune old releases
|
||||
│
|
||||
└── app/ # Tauri v2 desktop application
|
||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||
@@ -436,7 +435,7 @@ triple-c/
|
||||
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
|
||||
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
|
||||
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||
│ │ ├── useFileManager.ts # File browser operations
|
||||
│ │ ├── useFileManager.ts # File browser operations + host transfers
|
||||
│ │ ├── useInstallHelper.ts # Guided Docker installation
|
||||
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
|
||||
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
|
||||
@@ -464,7 +463,7 @@ triple-c/
|
||||
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
|
||||
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
|
||||
│ │ │ ├── ConfigTab.tsx # Config section host
|
||||
│ │ │ ├── FilesTab.tsx # In-container file browser
|
||||
│ │ │ ├── FilesTab.tsx # In-container file browser, upload / save to host
|
||||
│ │ │ ├── CapabilityTiles.tsx # Read-only capability counts
|
||||
│ │ │ ├── format.ts # Age / size / uptime formatting
|
||||
│ │ │ └── config/ # WorkspaceSection, ModelSection,
|
||||
@@ -504,7 +503,7 @@ triple-c/
|
||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||
│ ├── docker_commands.rs # Docker status, image ops
|
||||
│ ├── file_commands.rs # File browser (browse, view, rename)
|
||||
│ ├── file_commands.rs # File browser + host transfers (Rust-opened dialogs)
|
||||
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||
|
||||
+2
-1
@@ -9,7 +9,8 @@
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"hooks": "git -C .. config core.hooksPath .githooks && echo \"pre-commit secret scan enabled\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
|
||||
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
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -2,7 +2,7 @@ use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
|
||||
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -696,7 +696,7 @@ pub async fn add_project(
|
||||
pub async fn remove_project(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<ProjectRemovalReport, String> {
|
||||
// **H-2: the only writer of these three categories that held nothing.**
|
||||
// This purges migration artifacts, removes `triple-c-snapshot-{id}` and
|
||||
// both named volumes — and a compaction resolves that same tag when its
|
||||
@@ -722,12 +722,76 @@ pub async fn remove_project(
|
||||
// holding an entire snapshot image that nothing will ever reference again.
|
||||
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
||||
|
||||
// Stop and remove container if it exists
|
||||
if let Some(ref project) = state.projects_store.get(&project_id) {
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
// Stop and remove container if it exists. Everything named in `report`
|
||||
// below is what will be unreachable the moment this function drops the
|
||||
// project record — see [`ProjectRemovalReport`] and
|
||||
// `storage::pending_cleanup`, which is what makes it reachable anyway.
|
||||
let mut report = ProjectRemovalReport::default();
|
||||
let existing_project = state.projects_store.get(&project_id);
|
||||
|
||||
if let Some(ref project) = existing_project {
|
||||
// Resolved via `find_existing_container` unconditionally rather than
|
||||
// trusting `project.container_id` — that field can be *stale*, not
|
||||
// just absent: `start_project_container_locked`'s recreate path
|
||||
// removes the old container, creates a new one, and does not persist
|
||||
// the new id until after `start_container` succeeds, so a start
|
||||
// failure in between (a missing `/dev/net/tun`, an image that exits
|
||||
// immediately) leaves the stored id pointing at a container that no
|
||||
// longer exists while a live one sits under the same deterministic
|
||||
// name. Removing by a stale id then 404s — success as far as Docker
|
||||
// is concerned — while the real container survives to block every
|
||||
// subsequent volume removal with a 409, with nothing in the report
|
||||
// ever naming it. `find_existing_container` is what every other
|
||||
// destroyer of a project's container already resolves through
|
||||
// (`start_project_container`, migration's recreate paths) for this
|
||||
// exact reason.
|
||||
//
|
||||
// A `Docker unreachable` error here is treated as "assume a
|
||||
// container is still there" rather than "assume none is", matching
|
||||
// `remove_volumes_by_name`'s fail-closed handling of the same
|
||||
// situation — the alternative silently drops the one resource most
|
||||
// likely to block everything else if it does exist.
|
||||
//
|
||||
// Exec sessions are closed for `project.container_id` unconditionally,
|
||||
// before the lookup above and regardless of whether it succeeds —
|
||||
// that is host-side state with no Docker dependency, so it must not
|
||||
// wait on a daemon that might not answer. Resolving through
|
||||
// `find_existing_container` instead of using it directly would leave
|
||||
// these open in exactly the two cases this whole change exists to
|
||||
// handle: Docker unreachable (no id resolved, no way to ever close
|
||||
// them again once the project record is gone) and the stale-id race
|
||||
// (sessions were opened against the container that actually exists,
|
||||
// which is what gets resolved below, not the stored id).
|
||||
if let Some(ref stored_id) = project.container_id {
|
||||
state.exec_manager.close_sessions_for_container(stored_id).await;
|
||||
}
|
||||
let container_ref = match docker::find_existing_container(project).await {
|
||||
Ok(found) => found,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Could not check for an existing container for project {}: {}",
|
||||
project_id, e
|
||||
);
|
||||
report.container = Some(project.container_name());
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(ref container_id) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container_id.as_str()) {
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
}
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
let _ = docker::remove_container(container_id).await;
|
||||
if let Err(e) = docker::remove_container(container_id).await {
|
||||
log::warn!(
|
||||
"Failed to remove container {} for project {}: {}",
|
||||
container_id, project_id, e
|
||||
);
|
||||
// Recorded by name, not id: the name is the stable handle a
|
||||
// later retry can still resolve (Docker's remove-container
|
||||
// call accepts either), and it is what `container_ref` above
|
||||
// falls back to finding in the first place.
|
||||
report.container = Some(project.container_name());
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
|
||||
@@ -738,10 +802,9 @@ pub async fn remove_project(
|
||||
// Clean up the snapshot image + volumes
|
||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||
report.image = Some(docker::get_snapshot_image_name(project));
|
||||
}
|
||||
if let Err(e) = docker::remove_project_volumes(project).await {
|
||||
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
||||
}
|
||||
report.volumes = docker::remove_project_volumes(project).await;
|
||||
}
|
||||
|
||||
// Clean up keychain secrets for this project
|
||||
@@ -749,7 +812,216 @@ pub async fn remove_project(
|
||||
log::warn!("Failed to delete keychain secrets for project {}: {}", project_id, e);
|
||||
}
|
||||
|
||||
state.projects_store.remove(&project_id)
|
||||
if !report.is_clean() {
|
||||
let record = crate::storage::pending_cleanup::PendingCleanup {
|
||||
project_id: project_id.clone(),
|
||||
project_name: existing_project.map(|p| p.name).unwrap_or_default(),
|
||||
container_id: report.container.clone(),
|
||||
image: report.image.clone(),
|
||||
volumes: report.volumes.clone(),
|
||||
recorded_at: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
match crate::storage::pending_cleanup::save(&record) {
|
||||
Ok(()) => {
|
||||
report.retry_scheduled = true;
|
||||
log::warn!(
|
||||
"Project {} removed; could not confirm these Docker resources were removed: \
|
||||
{:?} — recorded for automatic retry on next launch",
|
||||
project_id, report
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
report.retry_scheduled = false;
|
||||
log::error!(
|
||||
"Project {} removed; could not confirm these Docker resources were removed \
|
||||
({:?}), and the pending-cleanup record could not be written ({}) — nothing \
|
||||
will retry removing them",
|
||||
project_id, report, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The pending-cleanup record above must not outlive the project record it
|
||||
// describes: if the store's own write fails (full disk, permissions) the
|
||||
// project is still on disk and will reload on the next launch, but the
|
||||
// record would tell startup housekeeping to delete its container and
|
||||
// volumes out from under it. Roll the record back rather than leaving
|
||||
// that mismatch for the retry to discover the hard way.
|
||||
//
|
||||
// This is a second, narrower line of defence, not the only one — a crash
|
||||
// between the `save` above and the `remove` below leaves exactly the same
|
||||
// mismatch with no error for either side to catch, which is why
|
||||
// `retry_pending_cleanup_logged` also refuses to act on a record whose
|
||||
// project is still listed in `projects.json`. Belt and suspenders: a
|
||||
// caught failure here is handled immediately rather than waiting for the
|
||||
// next launch to notice.
|
||||
if let Err(e) = state.projects_store.remove(&project_id) {
|
||||
if !report.is_clean() {
|
||||
if let Err(clear_err) = crate::storage::pending_cleanup::clear(&project_id) {
|
||||
log::error!(
|
||||
"Project {} was not removed ({}), and its pending-cleanup record could not \
|
||||
be rolled back either ({}) — it will name this still-live project until \
|
||||
startup housekeeping's own guard clears it",
|
||||
project_id, e, clear_err
|
||||
);
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Retry every pending-cleanup record left behind by a [`remove_project`]
|
||||
/// that could not finish. Run once at startup alongside the other reapers
|
||||
/// (see `lib.rs`'s "Startup disk housekeeping" block) — never on a timer and
|
||||
/// never blocking anything, since a locked volume or an in-use image can sit
|
||||
/// unresolved for an arbitrary amount of time and the daemon may not even be
|
||||
/// up yet.
|
||||
///
|
||||
/// Not a `#[tauri::command]`: nothing in the UI surfaces this list yet
|
||||
/// (deliberately — see `SnapshotSweepReport`'s doc comment for the same
|
||||
/// reasoning), so there is no IPC contract to keep. A record that still has
|
||||
/// leftovers after this is written back so the next run does not lose track
|
||||
/// of what changed; one that is now empty is deleted.
|
||||
///
|
||||
/// Takes the `ProjectsStore` so it can refuse to touch a project that is
|
||||
/// still live: `remove_project` writes a pending-cleanup record durably
|
||||
/// (fsync'd) *before* it asks the store to drop the project, and that
|
||||
/// store write is a plain `fs::write` with no fsync of its own. A crash or
|
||||
/// power loss in the gap between the two — or the store write failing
|
||||
/// outright, on top of the round-2 fix that only rolls the record back when
|
||||
/// that failure is caught in-process — can leave a record on disk pointing
|
||||
/// at a project `projects.json` still lists. Without this check, the very
|
||||
/// first retry after such a crash deletes that project's container,
|
||||
/// snapshot image and *both volumes, including the one holding the OAuth
|
||||
/// credential and every session transcript*, out from under a project the
|
||||
/// user still sees in the sidebar. A record whose project still exists is
|
||||
/// therefore always stale — cleared without touching Docker, not retried.
|
||||
pub async fn retry_pending_cleanup_logged(projects_store: &crate::storage::projects_store::ProjectsStore) {
|
||||
let records = crate::storage::pending_cleanup::list();
|
||||
if records.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cleaned = 0usize;
|
||||
let mut still_pending = 0usize;
|
||||
|
||||
for mut record in records {
|
||||
if projects_store.get(&record.project_id).is_some() {
|
||||
log::warn!(
|
||||
"Pending cleanup record for project {} ({}) names a project that still exists — \
|
||||
clearing the record without touching Docker rather than risk deleting a live \
|
||||
project's resources",
|
||||
record.project_id, record.project_name
|
||||
);
|
||||
if let Err(e) = crate::storage::pending_cleanup::clear(&record.project_id) {
|
||||
log::error!(
|
||||
"Could not clear the stale pending-cleanup record for still-live project {} \
|
||||
({}): {}",
|
||||
record.project_id, record.project_name, e
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(container_id) = record.container_id.take() {
|
||||
match docker::remove_container(&container_id).await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Pending cleanup: still could not remove container {} for project {} \
|
||||
({}): {}",
|
||||
container_id, record.project_id, record.project_name, e
|
||||
);
|
||||
record.container_id = Some(container_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(image) = record.image.take() {
|
||||
match docker::remove_image_by_name(&image).await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Pending cleanup: still could not remove image {} for project {} ({}): {}",
|
||||
image, record.project_id, record.project_name, e
|
||||
);
|
||||
record.image = Some(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !record.volumes.is_empty() {
|
||||
record.volumes = docker::remove_volumes_by_name(&record.volumes).await;
|
||||
}
|
||||
|
||||
if record.is_empty() {
|
||||
if let Err(e) = crate::storage::pending_cleanup::clear(&record.project_id) {
|
||||
log::warn!(
|
||||
"Pending cleanup for project {} ({}) finished but the record could not be \
|
||||
deleted: {}",
|
||||
record.project_id, record.project_name, e
|
||||
);
|
||||
}
|
||||
cleaned += 1;
|
||||
} else {
|
||||
still_pending += 1;
|
||||
// `recorded_at` is otherwise write-only — nothing read it back,
|
||||
// which is exactly the shape `storage::migration_store` calls out
|
||||
// as a bug in its own history ("nothing ever removed them"). A
|
||||
// record that has failed every retry for a week is no longer
|
||||
// routine: escalate the log level so it is not indistinguishable
|
||||
// from one seen for the first time.
|
||||
match pending_cleanup_is_stale(&record.recorded_at, chrono::Utc::now()) {
|
||||
Some(true) => {
|
||||
log::error!(
|
||||
"Pending cleanup for project {} ({}) has not succeeded in over {} \
|
||||
days: {:?} — this may need a manual `docker volume rm` / \
|
||||
`docker rmi` / `docker rm`",
|
||||
record.project_id, record.project_name, PENDING_CLEANUP_STALE_AFTER_DAYS, record
|
||||
);
|
||||
}
|
||||
Some(false) => {}
|
||||
// Silent otherwise would mean a record with a corrupted
|
||||
// timestamp never escalates and nothing says why.
|
||||
None => log::debug!(
|
||||
"Pending cleanup record for project {} ({}) has an unreadable recorded_at \
|
||||
({:?}) — its age cannot be tracked",
|
||||
record.project_id, record.project_name, record.recorded_at
|
||||
),
|
||||
}
|
||||
if let Err(e) = crate::storage::pending_cleanup::save(&record) {
|
||||
log::warn!(
|
||||
"Could not update pending cleanup record for project {} ({}): {}",
|
||||
record.project_id, record.project_name, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Pending cleanup retry: {} project(s) fully cleaned up, {} still have leftovers",
|
||||
cleaned, still_pending
|
||||
);
|
||||
}
|
||||
|
||||
/// After this many days of a pending-cleanup record failing every retry,
|
||||
/// `retry_pending_cleanup_logged` escalates its log line from `warn` to
|
||||
/// `error` — see the comment at its call site.
|
||||
const PENDING_CLEANUP_STALE_AFTER_DAYS: i64 = 7;
|
||||
|
||||
/// Whether a pending-cleanup record's `recorded_at` is older than
|
||||
/// [`PENDING_CLEANUP_STALE_AFTER_DAYS`], measured against `now`. `None` means
|
||||
/// the timestamp could not be parsed at all — a corrupted or (hypothetically)
|
||||
/// hand-edited record — which callers must not silently treat as "not stale"
|
||||
/// without saying why. `now` is a parameter rather than read internally so
|
||||
/// this is testable without a live clock.
|
||||
fn pending_cleanup_is_stale(recorded_at: &str, now: chrono::DateTime<chrono::Utc>) -> Option<bool> {
|
||||
let recorded = chrono::DateTime::parse_from_rfc3339(recorded_at)
|
||||
.ok()?
|
||||
.with_timezone(&chrono::Utc);
|
||||
Some(now.signed_duration_since(recorded) > chrono::Duration::days(PENDING_CLEANUP_STALE_AFTER_DAYS))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1226,7 +1498,7 @@ pub async fn rebuild_project_container(
|
||||
project_id: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Project, String> {
|
||||
) -> Result<ProjectResetOutcome, String> {
|
||||
// Reset deletes both volumes and the snapshot image. Doing that while a
|
||||
// migration is mid-flight pulls the ground out from under it and leaves an
|
||||
// orphan migration record pointing at images that no longer exist — and
|
||||
@@ -1253,25 +1525,58 @@ pub async fn rebuild_project_container(
|
||||
// `start_project_container` below re-arms it against the new one.
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
|
||||
// Remove existing container
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
// Remove existing container. Resolved via `find_existing_container`
|
||||
// unconditionally, not `project.container_id` — see the long comment in
|
||||
// `remove_project` for why that field can be stale, not just absent. A
|
||||
// container this misses blocks the volume removal immediately below with
|
||||
// a 409, and Reset silently keeping the old volumes is exactly the bug
|
||||
// this whole change is closing. Unlike `remove_project`'s best-effort
|
||||
// handling of the same lookup failing, `?` here aborts Reset outright:
|
||||
// every step after this one needs Docker too, so there is no useful
|
||||
// partial progress to make without it.
|
||||
// Closed for the stored id unconditionally, then again for the resolved
|
||||
// one if it differs — see the matching comment in `remove_project` for
|
||||
// why the stale-id race can leave sessions open under either identity.
|
||||
if let Some(ref stored_id) = project.container_id {
|
||||
state.exec_manager.close_sessions_for_container(stored_id).await;
|
||||
}
|
||||
let container_ref = docker::find_existing_container(&project).await?;
|
||||
if let Some(ref container_id) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container_id.as_str()) {
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
}
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
docker::remove_container(container_id).await?;
|
||||
state.projects_store.set_container_id(&project_id, None)?;
|
||||
}
|
||||
|
||||
// Remove snapshot image + volumes so Reset creates from the clean base image
|
||||
// Remove snapshot image + volumes so Reset creates from the clean base
|
||||
// image. Both leftovers are surfaced, not just logged — an image that
|
||||
// survives is the more serious of the two, since
|
||||
// `start_project_container_locked` below builds from
|
||||
// `triple-c-snapshot-{id}:latest` whenever it exists, so a leftover image
|
||||
// means Reset silently rebuilds the exact system layer it promised to
|
||||
// discard. No pending-cleanup record for either: unlike `remove_project`,
|
||||
// Reset keeps the project record, so a later Reset attempt can retry
|
||||
// these itself rather than needing startup housekeeping to do it.
|
||||
let mut leftover_image = None;
|
||||
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||
leftover_image = Some(docker::get_snapshot_image_name(&project));
|
||||
}
|
||||
if let Err(e) = docker::remove_project_volumes(&project).await {
|
||||
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
||||
let leftover_volumes = docker::remove_project_volumes(&project).await;
|
||||
if leftover_image.is_some() || !leftover_volumes.is_empty() {
|
||||
log::warn!(
|
||||
"Reset for project {} could not fully clean up — image: {:?}, volumes: {:?} — the \
|
||||
new container may be built from, or reuse, old contents instead of starting clean",
|
||||
project_id, leftover_image, leftover_volumes
|
||||
);
|
||||
}
|
||||
|
||||
// Start fresh. The locked variant, because `_guard` above is this project's
|
||||
// claim and the public command would be refused by it.
|
||||
start_project_container_locked(project_id, app_handle, state).await
|
||||
let project = start_project_container_locked(project_id, app_handle, state).await?;
|
||||
Ok(ProjectResetOutcome { project, leftover_image, leftover_volumes })
|
||||
}
|
||||
|
||||
/// Reconcile project statuses against actual Docker container state.
|
||||
@@ -1379,6 +1684,54 @@ fn default_docker_socket() -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Pending-cleanup aging ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_record_younger_than_the_threshold_is_not_stale() {
|
||||
let now = "2026-08-25T00:00:00Z".parse().unwrap();
|
||||
let recorded_at = "2026-08-19T00:00:00Z"; // 6 days before `now`
|
||||
assert_eq!(pending_cleanup_is_stale(recorded_at, now), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_exactly_at_the_threshold_is_not_yet_stale() {
|
||||
let now = "2026-08-25T00:00:00Z".parse().unwrap();
|
||||
let recorded_at = "2026-08-18T00:00:00Z"; // exactly 7 days before `now`
|
||||
assert_eq!(
|
||||
pending_cleanup_is_stale(recorded_at, now),
|
||||
Some(false),
|
||||
"the boundary itself must not already read as stale"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_older_than_the_threshold_is_stale() {
|
||||
let now = "2026-08-25T00:00:00Z".parse().unwrap();
|
||||
let recorded_at = "2026-08-17T00:00:00Z"; // 8 days before `now`
|
||||
assert_eq!(pending_cleanup_is_stale(recorded_at, now), Some(true));
|
||||
}
|
||||
|
||||
/// A clock that ran fast when the record was written leaves a timestamp
|
||||
/// in the future. This must read as "not stale" rather than underflow or
|
||||
/// panic — `signed_duration_since` returns a negative `Duration` here,
|
||||
/// which compares less than any positive threshold correctly.
|
||||
#[test]
|
||||
fn a_timestamp_in_the_future_is_not_stale() {
|
||||
let now = "2026-08-25T00:00:00Z".parse().unwrap();
|
||||
let recorded_at = "2026-08-26T00:00:00Z"; // one day after `now`
|
||||
assert_eq!(pending_cleanup_is_stale(recorded_at, now), Some(false));
|
||||
}
|
||||
|
||||
/// A corrupted or hand-edited `recorded_at` must not silently read as
|
||||
/// "not stale" through some default — callers need to be able to tell
|
||||
/// "definitely not stale" apart from "cannot tell".
|
||||
#[test]
|
||||
fn an_unparseable_recorded_at_reports_unknown_rather_than_not_stale() {
|
||||
let now = "2026-08-25T00:00:00Z".parse().unwrap();
|
||||
assert_eq!(pending_cleanup_is_stale("not a timestamp", now), None);
|
||||
assert_eq!(pending_cleanup_is_stale("", now), None);
|
||||
}
|
||||
|
||||
fn path(host: &str, mount: &str) -> ProjectPath {
|
||||
ProjectPath {
|
||||
host_path: host.to_string(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -202,8 +202,11 @@ pub async fn upload_host_file_to_terminal(
|
||||
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
|
||||
// the path with its symlinks already resolved, so a visible directory that
|
||||
// *leads* to one of those is refused too. What comes back is that resolved
|
||||
// path, and it is what gets opened. This is now one of only two commands
|
||||
// that touch a host path at all; the other is `download_container_backup`.
|
||||
// path, and it is what gets opened. Four commands touch a host path now,
|
||||
// but only two take it *over IPC*: this one and `download_container_backup`.
|
||||
// The Files pane's `download_container_file` and `upload_files_to_container`
|
||||
// open their dialog from Rust instead, so for them the policy above is
|
||||
// defence in depth and for these two it is the boundary itself.
|
||||
// The name is taken from the path the user actually dropped, *before*
|
||||
// resolution. Deriving it from the resolved path renames the file behind
|
||||
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
|
||||
@@ -222,8 +225,9 @@ pub async fn upload_host_file_to_terminal(
|
||||
// with no writer, with no timeout anywhere on this path. The upload then
|
||||
// never returns, the toast sticks on "Adding N files…" for the session and
|
||||
// the rest of the batch is abandoned. Sockets and device nodes are the same
|
||||
// shape. With the Files tab's upload removed, this is the only route for
|
||||
// getting a file into a container, so it is the wrong place to be clever.
|
||||
// shape. This is one of two routes for getting a host file into a
|
||||
// container (the Files pane's upload is the other), so it is the wrong
|
||||
// place to be clever.
|
||||
if !meta.is_file() {
|
||||
return Err(if meta.is_dir() {
|
||||
format!("{} is a directory — drop individual files", host_path)
|
||||
@@ -260,7 +264,13 @@ pub async fn upload_host_file_to_terminal(
|
||||
.await?;
|
||||
|
||||
let file_name = format!("triple-c-drops/{}", base);
|
||||
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
|
||||
crate::docker::exec::upload_host_file_to_container(
|
||||
&container_id,
|
||||
&host_path,
|
||||
"/tmp",
|
||||
&file_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -16,9 +16,37 @@ const REGISTRY_API_BASE: &str =
|
||||
const GHCR_TOKEN_URL: &str =
|
||||
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
||||
|
||||
/// The build-time preview suffix, if one was baked in and isn't blank.
|
||||
///
|
||||
/// The bundle version itself (`tauri.conf.json`, `Cargo.toml`, `package.json`)
|
||||
/// is never given a `-preview.<sha>` suffix — `build-app-preview.yml` strips
|
||||
/// it before patching those files, because the Windows MSI's `ProductVersion`
|
||||
/// is a fixed-width numeric field with no room for one, and nothing here can
|
||||
/// verify a change to that without an actual Windows build. `TRIPLE_C_BUILD_SUFFIX`
|
||||
/// is the workaround: set as a build-time env var in the preview workflow
|
||||
/// only, so `option_env!` bakes it into the binary without the bundle version
|
||||
/// ever seeing it. A production build sets nothing, so `option_env!` reads
|
||||
/// `None` here — see triple-c#32.
|
||||
///
|
||||
/// The single source of truth for "is this a preview build": both
|
||||
/// `get_app_version()` (what the About panel shows) and `check_for_updates()`
|
||||
/// (whether a same-numbered release counts as an update — see `pick_update`)
|
||||
/// read this rather than each calling `option_env!` themselves, so the two
|
||||
/// can never silently disagree about which build this is.
|
||||
fn preview_build_suffix() -> Option<&'static str> {
|
||||
option_env!("TRIPLE_C_BUILD_SUFFIX").filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn format_app_version(base: &str, build_suffix: Option<&str>) -> String {
|
||||
match build_suffix {
|
||||
Some(suffix) if !suffix.is_empty() => format!("{}-{}", base, suffix),
|
||||
_ => base.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -51,30 +79,20 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
&[".AppImage", ".deb", ".rpm"]
|
||||
};
|
||||
|
||||
// Filter releases that have at least one asset matching the current platform
|
||||
let platform_releases: Vec<&GitHubRelease> = releases
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.assets.iter().any(|a| {
|
||||
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// `current_version` above is always the bare, stripped `CARGO_PKG_VERSION`
|
||||
// — the preview workflow patches `Cargo.toml` with that before compiling,
|
||||
// never the `-preview.<sha>`-suffixed one `get_app_version()` reports —
|
||||
// so a preview build and the release it precedes compile to the identical
|
||||
// numeric tuple by construction (see `build-app-preview.yml`'s "highest
|
||||
// tag used, +1" computation). A strict `>` therefore never fires for the
|
||||
// one release a preview most needs to be offered. `is_preview_build`
|
||||
// relaxes that one comparison to `>=` so "there is a real release at my
|
||||
// own number" reads as an update, without touching the production case
|
||||
// — see `pick_update`.
|
||||
let is_preview_build = preview_build_suffix().is_some();
|
||||
|
||||
// Find the latest release with a higher semver version
|
||||
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
|
||||
for release in &platform_releases {
|
||||
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
|
||||
if ver > current_semver {
|
||||
if best.is_none() || ver > best.unwrap().1 {
|
||||
best = Some((release, ver));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match best {
|
||||
Some((release, _)) => {
|
||||
match pick_update(&releases, current_semver, platform_extensions, is_preview_build) {
|
||||
Some(release) => {
|
||||
// Only include assets matching the current platform
|
||||
let assets = release
|
||||
.assets
|
||||
@@ -105,6 +123,51 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the newest available update out of a release list, or `None` if
|
||||
/// nothing beats `current_semver`. Pure and synchronous — split out of
|
||||
/// `check_for_updates` so the prerelease/platform/version filtering can be
|
||||
/// tested without a live HTTP call.
|
||||
///
|
||||
/// Three filters, all of which must pass: not a prerelease (see the long
|
||||
/// comment on `GitHubRelease::prerelease`), at least one asset for this
|
||||
/// platform, and a tag that parses as semver *and* beats what is running. A
|
||||
/// tag that does not parse — `preview-<sha>` (the shape
|
||||
/// `build-app-preview.yml` actually creates release tags with), most
|
||||
/// realistically — is skipped rather than erroring, the same as it always
|
||||
/// has been; nothing here changes what an update tag is expected to look
|
||||
/// like, only what channel it is allowed to come from.
|
||||
///
|
||||
/// `is_preview_build` relaxes "beats" from `>` to `>=`. A preview build's
|
||||
/// `current_semver` is the bare number it was compiled with, which is by
|
||||
/// construction identical to the release it precedes — see the comment at
|
||||
/// `check_for_updates`'s call site — so a strict `>` would never fire for
|
||||
/// exactly the release a preview install most needs to be told about.
|
||||
fn pick_update<'a>(
|
||||
releases: &'a [GitHubRelease],
|
||||
current_semver: (u32, u32, u32),
|
||||
platform_extensions: &[&str],
|
||||
is_preview_build: bool,
|
||||
) -> Option<&'a GitHubRelease> {
|
||||
releases
|
||||
.iter()
|
||||
.filter(|r| !r.prerelease)
|
||||
.filter(|r| {
|
||||
r.assets
|
||||
.iter()
|
||||
.any(|a| platform_extensions.iter().any(|ext| a.name.ends_with(ext)))
|
||||
})
|
||||
.filter_map(|r| parse_semver_from_tag(&r.tag_name).map(|ver| (r, ver)))
|
||||
.filter(|(_, ver)| {
|
||||
if is_preview_build {
|
||||
*ver >= current_semver
|
||||
} else {
|
||||
*ver > current_semver
|
||||
}
|
||||
})
|
||||
.max_by_key(|(_, ver)| *ver)
|
||||
.map(|(r, _)| r)
|
||||
}
|
||||
|
||||
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||
let clean = version.trim_start_matches('v');
|
||||
@@ -131,6 +194,120 @@ fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||
Some(format!("{}.{}.{}", major, minor, patch))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::GitHubAsset;
|
||||
|
||||
// ── format_app_version ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_production_build_reports_the_bare_version() {
|
||||
assert_eq!(format_app_version("0.4.12", None), "0.4.12");
|
||||
// An empty env var (set but blank) must not print a trailing dash.
|
||||
assert_eq!(format_app_version("0.4.12", Some("")), "0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_build_reports_its_suffix() {
|
||||
assert_eq!(
|
||||
format_app_version("0.4.12", Some("preview.a1b2c3d")),
|
||||
"0.4.12-preview.a1b2c3d"
|
||||
);
|
||||
}
|
||||
|
||||
// ── pick_update ──────────────────────────────────────────────────────
|
||||
|
||||
fn release(tag: &str, prerelease: bool, asset_names: &[&str]) -> GitHubRelease {
|
||||
GitHubRelease {
|
||||
tag_name: tag.to_string(),
|
||||
html_url: format!("https://example.invalid/{}", tag),
|
||||
body: String::new(),
|
||||
assets: asset_names
|
||||
.iter()
|
||||
.map(|name| GitHubAsset {
|
||||
name: name.to_string(),
|
||||
browser_download_url: String::new(),
|
||||
size: 0,
|
||||
})
|
||||
.collect(),
|
||||
published_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
prerelease,
|
||||
}
|
||||
}
|
||||
|
||||
const LINUX_EXTENSIONS: &[&str] = &[".AppImage", ".deb", ".rpm"];
|
||||
|
||||
#[test]
|
||||
fn a_prerelease_is_never_offered_even_if_its_tag_would_otherwise_win() {
|
||||
let releases = vec![release("v9.9.9", true, &["app-9.9.9.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_with_no_asset_for_this_platform_is_skipped() {
|
||||
let releases = vec![release("v0.4.12", false, &["app-0.4.12.msi"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_that_is_not_newer_is_not_offered() {
|
||||
let releases = vec![release("v0.4.10", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_or_unparseable_release_is_skipped_not_fatal() {
|
||||
// A `-preview.<sha>` tag is exactly the shape this must not choke on
|
||||
// or mistake for an update — it simply never parses as a bare semver.
|
||||
let releases = vec![
|
||||
release("preview-a1b2c3d", false, &["app.AppImage"]),
|
||||
release("v0.4.12", false, &["app.AppImage"]),
|
||||
];
|
||||
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_highest_qualifying_version_wins_not_the_first_or_last_in_the_list() {
|
||||
let releases = vec![
|
||||
release("v0.4.11", false, &["app.AppImage"]),
|
||||
release("v0.4.13", false, &["app.AppImage"]),
|
||||
release("v0.4.12", false, &["app.AppImage"]),
|
||||
];
|
||||
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.13");
|
||||
}
|
||||
|
||||
// ── is_preview_build (>= instead of >) ─────────────────────────────────
|
||||
|
||||
/// The exact scenario triple-c#32 was filed to fix: a preview compiled as
|
||||
/// `0.4.12-preview.<sha>` (bare `CARGO_PKG_VERSION` "0.4.12") must be
|
||||
/// offered the `v0.4.12` release that follows it, even though the two
|
||||
/// compute to the identical numeric tuple.
|
||||
#[test]
|
||||
fn a_preview_build_is_offered_the_release_it_precedes() {
|
||||
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||
let best = pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_build_is_not_offered_an_older_release() {
|
||||
let releases = vec![release("v0.4.11", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_production_build_still_requires_strictly_newer() {
|
||||
// A production build must never treat "equal" as an update — that
|
||||
// would perpetually re-offer the version already running.
|
||||
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a newer container image is available in the registry.
|
||||
///
|
||||
/// Compares the local image digest with the remote registry digest using the
|
||||
|
||||
@@ -1935,7 +1935,7 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||
"Removing container {} (v=false: named volumes such as claude config are preserved)",
|
||||
container_id
|
||||
);
|
||||
docker
|
||||
match docker
|
||||
.remove_container(
|
||||
container_id,
|
||||
Some(RemoveContainerOptions {
|
||||
@@ -1945,7 +1945,17 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove container: {}", e))
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
// Already gone is the outcome this call wants, not a failure — a
|
||||
// caller retrying a leftover from a previous, partially-failed removal
|
||||
// (see `remove_project`) must not be told it failed forever just
|
||||
// because a *different* attempt already succeeded.
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove container: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the snapshot image name for a project.
|
||||
@@ -3496,13 +3506,27 @@ async fn rewrite_image_without_secrets(
|
||||
}
|
||||
|
||||
/// Remove the snapshot image for a project (used on Reset / project removal).
|
||||
///
|
||||
/// A project that never started never built a snapshot, so "no such image" is
|
||||
/// the ordinary case, not a failure — it is treated the same as success and
|
||||
/// logged at most at `info`. A real failure (the image is in use, a
|
||||
/// permission error, the daemon dropped the connection) is the one thing this
|
||||
/// returns `Err` for, and callers must not throw that away: see the
|
||||
/// `remove_project` doc comment on `ProjectRemovalReport` for why an
|
||||
/// unreported failure here used to make the resource unreachable forever.
|
||||
pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
let image_name = get_snapshot_image_name(project);
|
||||
remove_image_by_name(&get_snapshot_image_name(project)).await
|
||||
}
|
||||
|
||||
docker
|
||||
/// Remove a Docker image by name/tag, treating "does not exist" as success.
|
||||
/// Shared by [`remove_snapshot_image`] and the pending-cleanup retry, which
|
||||
/// only has the image name (the project record is already gone by then).
|
||||
pub async fn remove_image_by_name(image_name: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
match docker
|
||||
.remove_image(
|
||||
&image_name,
|
||||
image_name,
|
||||
Some(RemoveImageOptions {
|
||||
force: true,
|
||||
noprune: false,
|
||||
@@ -3510,25 +3534,91 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove snapshot image {}: {}", image_name, e))?;
|
||||
|
||||
{
|
||||
Ok(_) => {
|
||||
log::info!("Removed snapshot image {}", image_name);
|
||||
Ok(())
|
||||
}
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove snapshot image {}: {}", image_name, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove both named volumes for a project (used on Reset / project removal).
|
||||
pub async fn remove_project_volumes(project: &Project) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
for vol in [
|
||||
///
|
||||
/// Returns the names of volumes that still exist afterwards — empty means
|
||||
/// both are gone (removed here, or never created). This used to always
|
||||
/// return `Ok(())` regardless of what actually happened, which made the
|
||||
/// `if let Err(e)` at every call site unreachable by construction; see
|
||||
/// triple-c#31. A volume Docker reports as simply not existing is not a
|
||||
/// leftover and is not included.
|
||||
pub async fn remove_project_volumes(project: &Project) -> Vec<String> {
|
||||
remove_volumes_by_name(&[
|
||||
home_volume_name(&project.id),
|
||||
config_volume_name(&project.id),
|
||||
] {
|
||||
match docker.remove_volume(&vol, None).await {
|
||||
])
|
||||
.await
|
||||
}
|
||||
|
||||
/// Remove a set of named volumes, treating "does not exist" as success.
|
||||
/// Returns the names that still exist afterwards — empty means every one is
|
||||
/// gone (removed here, or never created).
|
||||
///
|
||||
/// Shared by [`remove_project_volumes`] and the pending-cleanup retry, the
|
||||
/// latter calling this with whatever the former could not remove the first
|
||||
/// time. Used to always report success regardless of what actually happened,
|
||||
/// which made every `if let Err(e)` at its call sites unreachable by
|
||||
/// construction; see triple-c#31.
|
||||
pub async fn remove_volumes_by_name(names: &[String]) -> Vec<String> {
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
// Can't reach the daemon to even try, so nothing here can be
|
||||
// confirmed removed. Reporting all as leftover is the safe
|
||||
// direction: worst case a later retry finds them already gone.
|
||||
log::warn!("Could not remove volumes {:?}: {}", names, e);
|
||||
return names.to_vec();
|
||||
}
|
||||
};
|
||||
|
||||
let mut leftover = Vec::new();
|
||||
for vol in names {
|
||||
match remove_one_volume_with_retry(&docker, vol).await {
|
||||
Ok(_) => log::info!("Removed volume {}", vol),
|
||||
Err(e) => log::warn!("Failed to remove volume {} (may not exist): {}", vol, e),
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => {}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to remove volume {}: {}", vol, e);
|
||||
leftover.push(vol.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
leftover
|
||||
}
|
||||
|
||||
/// Remove one volume, retrying once after a short delay on a 409 ("volume is
|
||||
/// in use"). Docker releasing a volume's mount reference after the container
|
||||
/// using it is removed is not always instantaneous, so the very first call
|
||||
/// site of this — `remove_project`, whose container removal lands
|
||||
/// immediately before its volume removal — could otherwise turn an ordinary
|
||||
/// race into a permanent pending-cleanup record and an alarming toast for
|
||||
/// something that would have cleared itself half a second later.
|
||||
async fn remove_one_volume_with_retry(
|
||||
docker: &bollard::Docker,
|
||||
name: &str,
|
||||
) -> Result<(), bollard::errors::Error> {
|
||||
match docker.remove_volume(name, None).await {
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 409, ..
|
||||
}) => {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
docker.remove_volume(name, None).await
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the existing container's configuration still matches the
|
||||
@@ -4177,7 +4267,12 @@ mod tests {
|
||||
// It goes into `triple-c.custom-env-fingerprint`, which `docker inspect`
|
||||
// hands to anything on the host, `docker commit` copies onto the
|
||||
// project's snapshot image, and the recreation check logs on a mismatch.
|
||||
let secret = "33da01c1b320644920c20d6b5e0a1c6b3c3451c2";
|
||||
// **Never a real credential.** This literal was the maintainer's actual
|
||||
// Gitea token for fourteen days and ninety-two commits, on a public
|
||||
// mirror — in a test whose whole subject is that secrets do not escape.
|
||||
// A fixture only has to be *a value*; it never has to be a live one, so
|
||||
// there is no version of this that justifies pasting something real.
|
||||
let secret = "not-a-real-token-0000000000000000000000";
|
||||
let fp = compute_env_fingerprint(&[EnvVar {
|
||||
key: "TEA_TOKEN".to_string(),
|
||||
value: secret.to_string(),
|
||||
|
||||
@@ -330,29 +330,58 @@ impl ExecSessionManager {
|
||||
/// meant a moment earlier.
|
||||
pub const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
|
||||
/// Upload a host file into `dest_dir` under `dest_name`. The file is
|
||||
/// read and packed into the tar inside a blocking task, so the synchronous IO
|
||||
/// runs off the async worker. The tar's declared entry size is taken from the
|
||||
/// bytes actually read (not a separate `stat`), so a file changing size between
|
||||
/// a size check and the read can't desync the header and corrupt the archive.
|
||||
/// Returns the in-container path (`/tmp/<dest_name>`).
|
||||
/// Returns the in-container path (`<dest_dir>/<dest_name>`).
|
||||
///
|
||||
/// `dest_dir` must already exist and must already have been checked by the
|
||||
/// caller — Docker's archive extractor writes wherever it is pointed. The two
|
||||
/// callers both do that first, by different routes because they are answering
|
||||
/// different questions: the terminal drop stages into a fixed `/tmp` path it
|
||||
/// creates itself, and the Files pane passes the directory the user is looking
|
||||
/// at, which `file_commands::resolve_container_dir` has already confirmed
|
||||
/// resolves inside `CONTAINER_WRITE_ROOTS`.
|
||||
pub async fn upload_host_file_to_container(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
) -> Result<String, String> {
|
||||
let ids = container_user_ids(container_id).await;
|
||||
upload_host_file_with_ids(container_id, host_path, dest_dir, dest_name, ids).await
|
||||
}
|
||||
|
||||
/// [`upload_host_file_to_container`] for a caller that already knows the
|
||||
/// container user's ids.
|
||||
///
|
||||
/// `container_user_ids` is a `docker exec`, and the Files pane's upload is a
|
||||
/// *selection* — one dialog can hand back twenty files. Resolving the ids per
|
||||
/// file made twenty extra round trips to answer the same `id -u` twenty times,
|
||||
/// which is seconds of latency for a fact that cannot change inside one
|
||||
/// container's lifetime. So the loop resolves once and passes the answer in.
|
||||
/// The wrapper above keeps the single-file callers unchanged.
|
||||
pub async fn upload_host_file_with_ids(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
(uid, gid): (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
let host_path = host_path.to_string();
|
||||
let dest_name = dest_name.to_string();
|
||||
let dest_for_blk = dest_name.clone();
|
||||
let (uid, gid) = container_user_ids(container_id).await;
|
||||
let mtime = now_epoch_secs();
|
||||
|
||||
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
// The caller resolved this path (`resolve_host_read_path`); opening it
|
||||
// is a second trip through the same directories, so the descriptor is
|
||||
// checked against the path that was validated before its bytes are
|
||||
// packed into anything. This is the terminal's drop target, and it is
|
||||
// the only path by which host bytes enter a container.
|
||||
// packed into anything. Two paths reach here: the terminal's drop
|
||||
// target, and the Files pane's upload via `upload_host_file_with_ids`.
|
||||
// Between them they are how host bytes enter a container.
|
||||
let file = std::fs::File::open(&host_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
crate::commands::file_commands::verify_opened_path(
|
||||
@@ -381,7 +410,7 @@ pub async fn upload_host_file_to_container(
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: "/tmp".to_string(),
|
||||
path: dest_dir.to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
@@ -389,7 +418,17 @@ pub async fn upload_host_file_to_container(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(format!("/tmp/{}", dest_name))
|
||||
Ok(container_join(dest_dir, &dest_name))
|
||||
}
|
||||
|
||||
/// Join a container directory to a name that may itself carry separators.
|
||||
///
|
||||
/// Only the *reported* path — the bytes have already landed by the time this is
|
||||
/// called — but that path is what the terminal echoes and what the Files pane
|
||||
/// puts in its toast, so `/tmp//x` reading back as a different file than `/tmp/x`
|
||||
/// is worth the four lines. `"/"` trims to `""` and yields `/x`.
|
||||
fn container_join(dir: &str, name: &str) -> String {
|
||||
format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
|
||||
@@ -779,8 +818,17 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||
match docker.inspect_exec(exec_id).await {
|
||||
Ok(info) => {
|
||||
if info.running != Some(true) {
|
||||
// Finished: use the reported code (default 0 if somehow absent).
|
||||
return Some(info.exit_code.unwrap_or(0));
|
||||
// Finished. `exit_code` rather than `unwrap_or(0)`: an exec
|
||||
// that has stopped without a reported code is a status
|
||||
// nobody can vouch for, and flattening it to *success* is
|
||||
// the wrong default when a caller is deciding whether to
|
||||
// rename a downloaded file over the user's own.
|
||||
// `download_container_file` treats `None` as a failure
|
||||
// precisely because it cannot tell that silence from a
|
||||
// clean exit; an `unwrap_or` here made that check
|
||||
// unreachable. Callers that only care about "did it fail
|
||||
// loudly" use `is_some_and`, which reads `None` as before.
|
||||
return info.exit_code;
|
||||
}
|
||||
}
|
||||
Err(_) => return None,
|
||||
@@ -892,4 +940,25 @@ mod tests {
|
||||
// …but still comfortably above a genuine /proc/net/tcp{,6} pair.
|
||||
assert!(PROC_NET_OUTPUT_LIMIT > 100 * 150);
|
||||
}
|
||||
|
||||
/// The reported path, which is what the terminal echoes back to Claude and
|
||||
/// what the Files pane puts in its log line. `/tmp//x` and `/tmp/x` are the
|
||||
/// same file to the kernel and different strings to a person reading either
|
||||
/// of those.
|
||||
#[test]
|
||||
fn container_join_produces_one_separator() {
|
||||
assert_eq!(container_join("/tmp", "a.txt"), "/tmp/a.txt");
|
||||
// The terminal's drop passes a nested name; it must not gain a second
|
||||
// slash at the seam.
|
||||
assert_eq!(
|
||||
container_join("/tmp", "triple-c-drops/a.txt"),
|
||||
"/tmp/triple-c-drops/a.txt"
|
||||
);
|
||||
// A directory the user navigated to can carry a trailing slash, and the
|
||||
// container root is the case where trimming it must not eat the only
|
||||
// separator there is.
|
||||
assert_eq!(container_join("/workspace/", "a.txt"), "/workspace/a.txt");
|
||||
assert_eq!(container_join("/", "a.txt"), "/a.txt");
|
||||
assert_eq!(container_join("/", "/a.txt"), "/a.txt");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,21 @@ pub struct AppState {
|
||||
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||
pub lifecycle: Arc<Lifecycle>,
|
||||
/// The file `preview_settings_import` last decrypted successfully, held
|
||||
/// so `apply_settings_import` can re-read and re-decrypt the same file
|
||||
/// without the frontend ever passing a host path back to Rust as an
|
||||
/// argument — see the doc comment on `commands::settings_export_commands`
|
||||
/// for why that direction specifically is the one this app treats as
|
||||
/// dangerous. Deliberately re-decrypted rather than cached in plaintext:
|
||||
/// nothing here holds a decrypted secret in memory for longer than one
|
||||
/// command's execution.
|
||||
///
|
||||
/// Also pins a hash of the file's ciphertext at preview time, so
|
||||
/// `apply_settings_import` can refuse to proceed if the file on disk
|
||||
/// changed underneath the pending import — otherwise confirming a
|
||||
/// preview is not actually binding on what gets applied.
|
||||
pub pending_settings_import:
|
||||
Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -222,6 +237,7 @@ pub fn run() {
|
||||
auth_bridge,
|
||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
lifecycle,
|
||||
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
})
|
||||
.setup(move |app| {
|
||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||
@@ -250,6 +266,7 @@ pub fn run() {
|
||||
// an image open and the sweep will not force; pins are untagged
|
||||
// second so the images they were holding are dangling by the time
|
||||
// the sweep lists them; the sweep runs last and collects both.
|
||||
let projects_store_for_cleanup = projects_store_setup.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::docker::reap_probe_containers().await;
|
||||
let reaped = crate::docker::reap_stale_migration_pins().await;
|
||||
@@ -257,6 +274,15 @@ pub fn run() {
|
||||
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
||||
}
|
||||
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
|
||||
// A container/image/volume `remove_project` could not delete
|
||||
// is recorded rather than lost — see triple-c#31 — and this is
|
||||
// the only place anything ever retries it. Takes the store so
|
||||
// it can refuse to touch a project that turns out to still be
|
||||
// live — see the long comment on the function itself.
|
||||
crate::commands::project_commands::retry_pending_cleanup_logged(
|
||||
&projects_store_for_cleanup,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
// Auto-start web terminal server if enabled in settings
|
||||
@@ -484,6 +510,10 @@ pub fn run() {
|
||||
commands::settings_commands::inspect_ca_cert_path,
|
||||
commands::settings_commands::list_aws_profiles,
|
||||
commands::settings_commands::detect_host_timezone,
|
||||
// Settings export/import
|
||||
commands::settings_export_commands::export_settings,
|
||||
commands::settings_export_commands::preview_settings_import,
|
||||
commands::settings_export_commands::apply_settings_import,
|
||||
// Terminal
|
||||
commands::terminal_commands::open_terminal_session,
|
||||
commands::terminal_commands::terminal_input,
|
||||
@@ -497,6 +527,8 @@ pub fn run() {
|
||||
// Files
|
||||
commands::file_commands::list_container_files,
|
||||
commands::file_commands::download_container_backup,
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::upload_files_to_container,
|
||||
commands::file_commands::read_container_file,
|
||||
commands::file_commands::rename_container_path,
|
||||
commands::file_commands::create_container_directory,
|
||||
|
||||
@@ -1,6 +1,136 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
||||
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
||||
/// under Wayland, printing `Could not create default EGL display:
|
||||
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
|
||||
/// code and killing the webview before Triple-C's own logging even starts —
|
||||
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||
///
|
||||
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
|
||||
/// variable is exported into an XWayland client's environment too, so a
|
||||
/// gate on it wouldn't even cleanly separate "Wayland" from "X11" — and
|
||||
/// there is no reliable heuristic at all for the actual variable that
|
||||
/// matters, which Mesa/driver/compositor combination is affected. This is
|
||||
/// the blunt instrument, chosen deliberately because the fallback is a real
|
||||
/// trade, not a free one: the terminal's `@xterm/addon-webgl` renderer
|
||||
/// (`TerminalView.tsx`) is the one surface in this app actually asking for
|
||||
/// GPU compositing, and it degrades to xterm's canvas renderer under this
|
||||
/// setting — slower on very heavy output, but the addon's own construction
|
||||
/// is already wrapped in a fallback (`WebGL not available` is a handled
|
||||
/// case, not a crash), so this is a real but graceful downgrade, traded
|
||||
/// against a startup abort that has no fallback at all.
|
||||
///
|
||||
/// Must be set before `triple_c_lib::run()` — GTK/WebKitGTK reads it at
|
||||
/// their own init time, which happens inside the Tauri builder that
|
||||
/// function calls into, not at binary load.
|
||||
///
|
||||
/// A user who has already set this themselves is left alone — with one
|
||||
/// correction. The earlier version of this function left *any* pre-set value
|
||||
/// alone, including `0`, on the assumption WebKitGTK reads the variable as a
|
||||
/// boolean. WebKitGTK reads it as presence-only, so `WEBKIT_DISABLE_DMABUF_
|
||||
/// RENDERER=0` disabled DMA-BUF exactly like `=1` did, and there was no value
|
||||
/// at all a user could set to get the accelerated path back: the escape hatch
|
||||
/// the comment described did not exist. `0`, `false` and empty are now treated
|
||||
/// as an explicit opt-out and the variable is *removed*, which is the only
|
||||
/// thing WebKitGTK reads as "enabled". The default is unchanged — unset still
|
||||
/// means disabled on Linux, so nobody who was not deliberately overriding this
|
||||
/// sees any difference.
|
||||
///
|
||||
/// That matters more than it looks, because the trade described above is not
|
||||
/// the trade actually being made. `@xterm/addon-webgl` does not fall back to
|
||||
/// the canvas renderer here: its constructor throws only when WebGL is
|
||||
/// *absent*, and with DMA-BUF disabled WebGL is still present — served by
|
||||
/// software rasterisation. So the addon loads happily and every terminal frame
|
||||
/// is rendered on the CPU and copied, which is slower than the canvas renderer
|
||||
/// this comment assumed it would degrade to, not faster. See
|
||||
/// `terminal_gpu_rendering` in `AppSettings` for the switch that decides
|
||||
/// whether the addon is loaded at all.
|
||||
///
|
||||
/// This env var also leaks to whatever the app spawns afterwards — notably
|
||||
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
||||
/// call. Narrow in practice (an already-running browser just receives the
|
||||
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
||||
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
||||
/// as a separate, unrelated cause.
|
||||
#[cfg(target_os = "linux")]
|
||||
const DMABUF_VAR: &str = "WEBKIT_DISABLE_DMABUF_RENDERER";
|
||||
|
||||
/// What to do with `WEBKIT_DISABLE_DMABUF_RENDERER`, given whatever it is
|
||||
/// already set to. Split from the mutation so it can be tested without
|
||||
/// touching process-wide environment state from a parallel test runner.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum DmabufAction {
|
||||
/// Not set by the user — apply the workaround.
|
||||
Disable,
|
||||
/// Explicitly opted out. WebKitGTK reads presence, not value, so the only
|
||||
/// way to express "enabled" is for the variable not to exist.
|
||||
Remove,
|
||||
/// Set to something meaning "disabled". Already what we want; leave it.
|
||||
LeaveAlone,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn dmabuf_action(current: Option<&str>) -> DmabufAction {
|
||||
match current {
|
||||
None => DmabufAction::Disable,
|
||||
Some(value) => match value.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "0" | "false" | "no" => DmabufAction::Remove,
|
||||
_ => DmabufAction::LeaveAlone,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn apply_webkit_wayland_workaround() {
|
||||
let current = std::env::var(DMABUF_VAR).ok();
|
||||
match dmabuf_action(current.as_deref()) {
|
||||
DmabufAction::Disable => std::env::set_var(DMABUF_VAR, "1"),
|
||||
DmabufAction::Remove => std::env::remove_var(DMABUF_VAR),
|
||||
DmabufAction::LeaveAlone => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod tests {
|
||||
use super::{dmabuf_action, DmabufAction};
|
||||
|
||||
#[test]
|
||||
fn unset_gets_the_workaround() {
|
||||
assert_eq!(dmabuf_action(None), DmabufAction::Disable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falsey_values_opt_out_by_removing_the_variable() {
|
||||
// The bug this replaces: these all previously read as "user set it,
|
||||
// leave it alone", and WebKitGTK then disabled DMA-BUF anyway because
|
||||
// it only checks presence. There was no way to ask for the GPU path.
|
||||
for value in ["0", "false", "no", "", " 0 ", "FALSE", "No"] {
|
||||
assert_eq!(
|
||||
dmabuf_action(Some(value)),
|
||||
DmabufAction::Remove,
|
||||
"{value:?} should opt out"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_values_are_left_alone() {
|
||||
for value in ["1", "true", "yes", "anything"] {
|
||||
assert_eq!(
|
||||
dmabuf_action(Some(value)),
|
||||
DmabufAction::LeaveAlone,
|
||||
"{value:?} should be left alone"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(target_os = "linux")]
|
||||
apply_webkit_wayland_workaround();
|
||||
|
||||
triple_c_lib::run()
|
||||
}
|
||||
|
||||
@@ -135,6 +135,26 @@ pub struct AppSettings {
|
||||
pub gateway: GatewaySettings,
|
||||
#[serde(default)]
|
||||
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
|
||||
/// Whether the terminal loads `@xterm/addon-webgl`.
|
||||
///
|
||||
/// `None` is "auto", and auto is not the same answer on every platform.
|
||||
/// On Linux the app disables WebKitGTK's DMA-BUF renderer at startup (see
|
||||
/// `apply_webkit_wayland_workaround` in `main.rs`, and triple-c#34), which
|
||||
/// does not remove WebGL — it leaves it backed by software rasterisation.
|
||||
/// The addon therefore loads successfully and then renders every frame on
|
||||
/// the CPU, which is slower than the canvas renderer it would otherwise
|
||||
/// have fallen back to. So auto means enabled on macOS and Windows, and
|
||||
/// disabled on Linux.
|
||||
///
|
||||
/// `Some(true)` / `Some(false)` force it either way on any platform. A
|
||||
/// Linux user running X11, or one whose driver stack is unaffected, can
|
||||
/// turn it back on; anyone seeing terminal lag can turn it off without
|
||||
/// waiting for a release. Deliberately `Option<bool>` rather than `bool`:
|
||||
/// the zero value has to mean "we choose", not "off", or every existing
|
||||
/// settings file would silently pin the answer at whatever the default was
|
||||
/// the day it was written.
|
||||
#[serde(default)]
|
||||
pub terminal_gpu_rendering: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_stt_model() -> String {
|
||||
@@ -226,6 +246,7 @@ impl Default for AppSettings {
|
||||
stt: SttSettings::default(),
|
||||
gateway: GatewaySettings::default(),
|
||||
global_claude_code_settings: None,
|
||||
terminal_gpu_rendering: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -422,6 +422,61 @@ pub enum ProjectStatus {
|
||||
Error,
|
||||
}
|
||||
|
||||
/// What `remove_project` could not delete, named so the UI can say so instead
|
||||
/// of reporting a clean removal that was not one.
|
||||
///
|
||||
/// The project record is dropped from `projects.json` regardless — see the
|
||||
/// long comment on `remove_project` for why refusing is not the answer — but
|
||||
/// anything named here is also written to a pending-cleanup record that
|
||||
/// startup housekeeping retries, so it stays reachable after the project it
|
||||
/// belonged to no longer exists.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectRemovalReport {
|
||||
/// The project's container, if it could not be removed. Named by its
|
||||
/// deterministic `triple-c-{id}` name (see `Project::container_name`),
|
||||
/// not the container id, since the id can be stale or absent and the
|
||||
/// name is what a later retry can still resolve.
|
||||
pub container: Option<String>,
|
||||
/// The `triple-c-snapshot-{id}` image, if it could not be removed.
|
||||
pub image: Option<String>,
|
||||
/// Named volumes (home, claude config) that could not be removed.
|
||||
pub volumes: Vec<String>,
|
||||
/// True once the leftovers above were durably recorded for automatic
|
||||
/// retry on the next launch. False means the pending-cleanup record
|
||||
/// itself could not be written — nothing will retry these, and the UI
|
||||
/// must say so rather than promising a retry that will not happen.
|
||||
/// Meaningless (and left at its default) when `is_clean()` is true.
|
||||
pub retry_scheduled: bool,
|
||||
}
|
||||
|
||||
impl ProjectRemovalReport {
|
||||
/// True when nothing was left behind.
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.container.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// What `rebuild_project_container` (Reset) produced: the project as it
|
||||
/// stands after restarting, and anything Reset could not clear.
|
||||
///
|
||||
/// Reset's contract is "back to a clean base image", so a leftover volume or
|
||||
/// image here is reused/rebuilt-from as-is by the container this creates —
|
||||
/// the opposite of what was asked for — and unlike [`ProjectRemovalReport`]
|
||||
/// there is no pending-cleanup record for either: the project id survives
|
||||
/// Reset, so a later Reset attempt can retry them itself.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectResetOutcome {
|
||||
pub project: Project,
|
||||
/// The `triple-c-snapshot-{id}` image, if Reset could not remove it. The
|
||||
/// more serious of the two leftovers here: the new container is created
|
||||
/// from this image whenever it exists, so a surviving image means Reset
|
||||
/// silently rebuilt the exact system layer it was asked to discard.
|
||||
pub leftover_image: Option<String>,
|
||||
/// Volumes that survived Reset and were mounted into the new container
|
||||
/// unchanged.
|
||||
pub leftover_volumes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Which AI model backend/provider the project uses.
|
||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||
@@ -650,6 +705,25 @@ impl Project {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── ProjectRemovalReport ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_report_is_clean_only_with_nothing_left_behind() {
|
||||
assert!(ProjectRemovalReport::default().is_clean());
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.container = Some("abc123".to_string());
|
||||
assert!(!r.is_clean(), "a leftover container must not read as clean");
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.image = Some("triple-c-snapshot-x:latest".to_string());
|
||||
assert!(!r.is_clean(), "a leftover image must not read as clean");
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.volumes.push("triple-c-home-x".to_string());
|
||||
assert!(!r.is_clean(), "a leftover volume must not read as clean");
|
||||
}
|
||||
|
||||
// ── Custom environment variable names ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,24 @@ pub struct GitHubRelease {
|
||||
pub body: String,
|
||||
pub assets: Vec<GitHubAsset>,
|
||||
pub published_at: String,
|
||||
/// Whether GitHub itself has this release marked as a prerelease.
|
||||
/// `#[serde(default)]` rather than required: every response GitHub sends
|
||||
/// carries this, but nothing here should refuse to parse the rest of a
|
||||
/// release over one missing field. Defaults to `false` (offered) rather
|
||||
/// than `true` (excluded) — a missing field only happens if GitHub's API
|
||||
/// shape changes, and "API changed, therefore updates silently stop
|
||||
/// working forever" is the worse failure of the two.
|
||||
///
|
||||
/// `build-app.yml`'s own mirror never publishes a prerelease, but
|
||||
/// `.gitea/workflows/backfill-releases.yml` forwards every Gitea release
|
||||
/// unfiltered, `prerelease` included. A preview release's `preview-<sha>`
|
||||
/// tag already fails semver parsing on its own, so this field is not what
|
||||
/// stops *that* case — it is what stops the case tag-parsing can't catch:
|
||||
/// a normally-tagged release (`v0.4.13`) that someone marks as a
|
||||
/// prerelease on Gitea (a hotfix candidate, an RC) and a backfill then
|
||||
/// mirrors as-is. Real defence for that case, not a no-op.
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
}
|
||||
|
||||
/// GitHub API asset response (internal).
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod migration_store;
|
||||
pub mod pending_cleanup;
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
pub mod settings_crypto;
|
||||
pub mod settings_store;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Host-side record of Docker resources `remove_project` could not delete.
|
||||
//!
|
||||
//! `remove_project` drops a project's id from `projects.json` unconditionally
|
||||
//! — see the comment on `ProjectRemovalReport` — so once that happens nothing
|
||||
//! in the app can name the leftover container, image or volume again by any
|
||||
//! path a user can reach. This is what keeps it reachable anyway: one JSON
|
||||
//! file per affected project under `<data_dir>/triple-c/pending-cleanup/`,
|
||||
//! written *before* the project record is dropped. Startup housekeeping
|
||||
//! retries every record on the next launch (see
|
||||
//! `commands::project_commands::retry_pending_cleanup_logged`) and deletes
|
||||
//! the ones that fully succeed.
|
||||
//!
|
||||
//! **This record is written in the same instant its record in `projects.json`
|
||||
//! is destroyed, and it is the only remaining handle on the leftover
|
||||
//! resource** — which is a stronger claim on durability than an ordinary
|
||||
//! write-temp-then-rename gives. `storage::migration_store::save` carries the
|
||||
//! same reasoning for the migration state file: `fs::write` returns once the
|
||||
//! bytes are in the page cache, and a rename over them is atomic with respect
|
||||
//! to other readers, not to power loss. A crash in that window leaves the
|
||||
//! rename applied and the data half-written, which [`list`] then treats as
|
||||
//! unparseable and skips — reproducing the exact bug this module exists to
|
||||
//! close, silently, with only a startup log line as evidence. So `save` here
|
||||
//! takes the same `File::create` → `write_all` → `sync_all` → `rename` →
|
||||
//! directory-sync shape `migration_store` does.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingCleanup {
|
||||
pub project_id: String,
|
||||
/// Kept only so a log line or a future UI can name the project without a
|
||||
/// second lookup — the project record itself is already gone by the time
|
||||
/// this is read back.
|
||||
pub project_name: String,
|
||||
/// The project's container, if it could not be removed. Named by its
|
||||
/// deterministic `triple-c-{id}` name rather than the (possibly stale)
|
||||
/// container id Docker handed out — Docker's remove-container API
|
||||
/// accepts either, and the name is the one identifier guaranteed to still
|
||||
/// resolve to the same container by the time a retry runs.
|
||||
pub container_id: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub volumes: Vec<String>,
|
||||
pub recorded_at: String,
|
||||
}
|
||||
|
||||
impl PendingCleanup {
|
||||
/// True once nothing named here still needs to be removed.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.container_id.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// `<data_dir>/triple-c/pending-cleanup`, created on demand.
|
||||
fn dir() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or_else(|| {
|
||||
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||
})?
|
||||
.join("triple-c")
|
||||
.join("pending-cleanup");
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create pending-cleanup directory: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||
/// the write anywhere but the pending-cleanup directory. Mirrors
|
||||
/// `storage::migration_store::sanitize`.
|
||||
fn sanitize(project_id: &str) -> String {
|
||||
project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write (or overwrite) a project's pending-cleanup record.
|
||||
pub fn save(record: &PendingCleanup) -> Result<(), String> {
|
||||
save_in(&dir()?, record)
|
||||
}
|
||||
|
||||
/// Remove a project's pending-cleanup record. Missing is success — this is
|
||||
/// how a fully-succeeded retry (or a record that never existed) is expressed.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
clear_in(&dir()?, project_id)
|
||||
}
|
||||
|
||||
/// Every pending-cleanup record on disk. An unparseable file is logged and
|
||||
/// skipped rather than blocking every other project's retry — the same
|
||||
/// "one bad record can't wedge the rest" reasoning as the migration store.
|
||||
pub fn list() -> Vec<PendingCleanup> {
|
||||
let Ok(dir) = dir() else { return Vec::new() };
|
||||
list_in(&dir)
|
||||
}
|
||||
|
||||
fn path_in(dir: &Path, project_id: &str) -> PathBuf {
|
||||
dir.join(format!("{}.json", sanitize(project_id)))
|
||||
}
|
||||
|
||||
/// Durable write: fsync the file before the rename, and fsync the directory
|
||||
/// after it — see the module doc comment for why a plain
|
||||
/// write-temp-then-rename is not enough here. Mirrors
|
||||
/// `storage::migration_store::save`/`sync_dir`.
|
||||
fn save_in(dir: &Path, record: &PendingCleanup) -> Result<(), String> {
|
||||
let path = path_in(dir, &record.project_id);
|
||||
let data = serde_json::to_string_pretty(record)
|
||||
.map_err(|e| format!("Failed to serialize pending cleanup record: {}", e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file = fs::File::create(&tmp)
|
||||
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
|
||||
file.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to flush pending cleanup record to disk: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp, &path)
|
||||
.map_err(|e| format!("Failed to commit pending cleanup record: {}", e))?;
|
||||
sync_dir(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
||||
let path = path_in(dir, project_id);
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove pending cleanup record: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_in(dir: &Path) -> Vec<PendingCleanup> {
|
||||
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
|
||||
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let data = fs::read_to_string(&path).ok()?;
|
||||
match serde_json::from_str::<PendingCleanup>(&data) {
|
||||
Ok(record) => Some(record),
|
||||
Err(err) => {
|
||||
// Moved aside rather than left in place: a record nothing
|
||||
// ever repairs would otherwise warn on every single
|
||||
// startup forever, same as an ordinary `.json` file it
|
||||
// would keep looking like one to `list_in` on the next
|
||||
// call too. One aside-copy is enough here — this only
|
||||
// ever holds names to retry removing, not the class of
|
||||
// once-in-a-lifetime crash evidence `migration_store`
|
||||
// keeps multiple timestamped backups of.
|
||||
let corrupt = path.with_extension("json.corrupt");
|
||||
let moved = !corrupt.exists() && fs::rename(&path, &corrupt).is_ok();
|
||||
log::warn!(
|
||||
"Could not parse pending cleanup record {}: {}{}",
|
||||
path.display(),
|
||||
err,
|
||||
if moved {
|
||||
format!(" — moved aside to {}", corrupt.display())
|
||||
} else {
|
||||
" — leaving it in place".to_string()
|
||||
}
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// fsync the directory holding `path`, so a rename into it survives power
|
||||
/// loss. Best effort only on the platforms where it is meaningless: Windows
|
||||
/// has no directory handle to sync and errors on the attempt, so failure is
|
||||
/// logged rather than propagated — the file's own `sync_all` above is what
|
||||
/// carries the data. Mirrors `storage::migration_store::sync_dir`, which is
|
||||
/// private to that module, so this is a small deliberate duplicate rather
|
||||
/// than a shared dependency between two otherwise-independent stores.
|
||||
fn sync_dir(path: &Path) {
|
||||
let Some(dir) = path.parent() else { return };
|
||||
match fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||
Ok(()) => {}
|
||||
Err(e) => log::debug!(
|
||||
"Could not fsync the pending-cleanup directory {}: {} — the record itself was flushed",
|
||||
dir.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-pending-cleanup-{}-{}",
|
||||
name,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn record(project_id: &str) -> PendingCleanup {
|
||||
PendingCleanup {
|
||||
project_id: project_id.to_string(),
|
||||
project_name: "Some Project".to_string(),
|
||||
container_id: Some("triple-c-abc".to_string()),
|
||||
image: Some("triple-c-snapshot-abc:latest".to_string()),
|
||||
volumes: vec!["triple-c-home-abc".to_string()],
|
||||
recorded_at: "2026-08-25T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_pending_cleanup_directory() {
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
assert_eq!(
|
||||
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_empty_reflects_whatever_still_needs_removing() {
|
||||
let mut r = record("p1");
|
||||
assert!(!r.is_empty());
|
||||
|
||||
r.container_id = None;
|
||||
r.image = None;
|
||||
assert!(!r.is_empty(), "a leftover volume alone still counts");
|
||||
|
||||
r.volumes.clear();
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
|
||||
/// Exercises the real `save_in`/`list_in`/`clear_in` — not a
|
||||
/// re-implementation of their bodies — against a temp directory standing
|
||||
/// in for `dir()`.
|
||||
#[test]
|
||||
fn a_saved_record_round_trips_and_clearing_removes_it() {
|
||||
let dir = temp_dir("roundtrip");
|
||||
let rec = record("proj-1");
|
||||
|
||||
save_in(&dir, &rec).expect("save");
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-1");
|
||||
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
|
||||
|
||||
clear_in(&dir, "proj-1").expect("clear");
|
||||
assert!(list_in(&dir).is_empty());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A second `save` for the same project overwrites rather than appending
|
||||
/// — a retry that narrows the leftovers must not leave the old, wider
|
||||
/// record behind it.
|
||||
#[test]
|
||||
fn saving_the_same_project_twice_overwrites_not_appends() {
|
||||
let dir = temp_dir("overwrite");
|
||||
let mut rec = record("proj-1");
|
||||
save_in(&dir, &rec).expect("save");
|
||||
|
||||
rec.container_id = None;
|
||||
rec.image = None;
|
||||
save_in(&dir, &rec).expect("save again");
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1, "one file per project, not one per save");
|
||||
assert!(found[0].container_id.is_none());
|
||||
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A record that fails to parse must not poison the rest of the listing.
|
||||
#[test]
|
||||
fn an_unparseable_record_is_skipped_not_fatal() {
|
||||
let dir = temp_dir("corrupt");
|
||||
fs::write(dir.join("bad.json"), "{ not json").unwrap();
|
||||
save_in(&dir, &record("proj-2")).expect("save");
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-2");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A record that fails to parse is moved aside once, rather than left in
|
||||
/// place to be re-warned about — and re-warned about — on every future
|
||||
/// launch forever.
|
||||
#[test]
|
||||
fn an_unparseable_record_is_moved_aside_exactly_once() {
|
||||
let dir = temp_dir("corrupt-aside");
|
||||
let bad = dir.join("bad.json");
|
||||
fs::write(&bad, "{ not json").unwrap();
|
||||
|
||||
list_in(&dir);
|
||||
assert!(!bad.exists(), "the bad file should have been moved aside");
|
||||
let corrupt = dir.join("bad.json.corrupt");
|
||||
assert!(corrupt.exists(), "and the moved copy should be at .json.corrupt");
|
||||
|
||||
// A second pass must not warn about `bad.json` again — it is gone —
|
||||
// and must not choke on `.json.corrupt` already being there.
|
||||
assert!(list_in(&dir).is_empty());
|
||||
assert!(corrupt.exists(), "the aside copy is not itself deleted");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// `list_in` must not pick up the `.json.tmp` staging file `save_in`
|
||||
/// leaves behind if a crash lands between the write and the rename — the
|
||||
/// whole point of the temp-then-rename dance is that only the renamed
|
||||
/// file is ever a complete record.
|
||||
#[test]
|
||||
fn a_leftover_tmp_file_is_not_listed() {
|
||||
let dir = temp_dir("tmp-leftover");
|
||||
fs::write(dir.join("proj-3.json.tmp"), "not a complete record").unwrap();
|
||||
assert!(list_in(&dir).is_empty());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Clearing by project id must remove exactly the file that id maps to
|
||||
/// under `sanitize`, and nothing else.
|
||||
#[test]
|
||||
fn clearing_one_project_does_not_touch_another() {
|
||||
let dir = temp_dir("clear-scoped");
|
||||
save_in(&dir, &record("proj-a")).unwrap();
|
||||
save_in(&dir, &record("proj-b")).unwrap();
|
||||
|
||||
clear_in(&dir, "proj-a").unwrap();
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-b");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -757,7 +757,13 @@
|
||||
sessionType === 'claude'
|
||||
) {
|
||||
sendTerminalInput('\x1b\r');
|
||||
return false; // xterm must not also send a bare CR, which submits
|
||||
// `preventDefault()` is what stops the submit, not the `return false`.
|
||||
// xterm's `_keyDown` returns before setting `_keyDownHandled`, so
|
||||
// `_keyPress` still fires and emits a bare CR for Enter — inserting the
|
||||
// newline and then submitting the prompt anyway. See the same comment
|
||||
// in TerminalView.tsx.
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import AddProjectDialog from "./AddProjectDialog";
|
||||
|
||||
const add = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useProjects", () => ({
|
||||
useProjects: () => ({ add }),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
/** A promise whose resolution this test controls, so `loading` can be held open. */
|
||||
function deferred() {
|
||||
let resolve!: (v: unknown) => void;
|
||||
const promise = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function fillValidForm() {
|
||||
fireEvent.change(screen.getByLabelText("Project name"), {
|
||||
target: { value: "my-project" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Folder 1 host path"), {
|
||||
target: { value: "/home/user/my-project" },
|
||||
});
|
||||
}
|
||||
|
||||
function submitButton() {
|
||||
return screen.getByRole("button", { name: /Add Project|Adding/ });
|
||||
}
|
||||
|
||||
describe("AddProjectDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("adds the project with the name and folder entered", async () => {
|
||||
add.mockResolvedValue({ id: "p1" });
|
||||
const onClose = vi.fn();
|
||||
render(<AddProjectDialog onClose={onClose} />);
|
||||
fillValidForm();
|
||||
fireEvent.click(submitButton());
|
||||
await waitFor(() =>
|
||||
expect(add).toHaveBeenCalledWith("my-project", [
|
||||
{ host_path: "/home/user/my-project", mount_name: "my-project" },
|
||||
]),
|
||||
);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("keeps the submit button announced, and explains why, while adding", async () => {
|
||||
const { promise, resolve } = deferred();
|
||||
add.mockReturnValue(promise);
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
fillValidForm();
|
||||
fireEvent.click(submitButton());
|
||||
|
||||
// Native `disabled` would remove the button from the accessibility tree
|
||||
// exactly when it has something to say.
|
||||
await waitFor(() =>
|
||||
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||
);
|
||||
expect(submitButton()).not.toBeDisabled();
|
||||
expect(submitButton()).toHaveAccessibleDescription(/being added/i);
|
||||
|
||||
await act(async () => resolve({ id: "p1" }));
|
||||
});
|
||||
|
||||
it("ignores clicks and Enter/Space on the submit button while adding", async () => {
|
||||
const { promise, resolve } = deferred();
|
||||
add.mockReturnValue(promise);
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
fillValidForm();
|
||||
fireEvent.click(submitButton());
|
||||
await waitFor(() =>
|
||||
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||
);
|
||||
|
||||
fireEvent.click(submitButton());
|
||||
fireEvent.keyDown(submitButton(), { key: "Enter" });
|
||||
fireEvent.keyDown(submitButton(), { key: " " });
|
||||
expect(add).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => resolve({ id: "p1" }));
|
||||
});
|
||||
|
||||
it("ignores a form submit raised from elsewhere while adding", async () => {
|
||||
const { promise, resolve } = deferred();
|
||||
add.mockReturnValue(promise);
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
fillValidForm();
|
||||
fireEvent.click(submitButton());
|
||||
await waitFor(() =>
|
||||
expect(submitButton()).toHaveAttribute("aria-disabled", "true"),
|
||||
);
|
||||
|
||||
// Enter in a text field submits a form regardless of the submit button's
|
||||
// state, so the handler has to guard itself too.
|
||||
// Modal portals to document.body, so the form is not under `container`.
|
||||
const form = document.querySelector("form");
|
||||
expect(form).not.toBeNull();
|
||||
fireEvent.submit(form!);
|
||||
expect(add).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => resolve({ id: "p1" }));
|
||||
});
|
||||
|
||||
it("leaves the submit button plainly available when idle", () => {
|
||||
render(<AddProjectDialog onClose={vi.fn()} />);
|
||||
expect(submitButton()).not.toHaveAttribute("aria-disabled");
|
||||
expect(submitButton()).toHaveAccessibleDescription("");
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,10 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
// The submit button is `aria-disabled` rather than `disabled` while an add
|
||||
// is in flight, and Enter inside a text field submits the form without
|
||||
// touching the button at all. Both routes end here, so the guard does too.
|
||||
if (loading) return;
|
||||
if (!name.trim()) {
|
||||
setError("Project name is required");
|
||||
return;
|
||||
@@ -97,7 +101,19 @@ export default function AddProjectDialog({ onClose }: Props) {
|
||||
<Button size="md" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
form={formId}
|
||||
unavailable={loading}
|
||||
unavailableReason="The project is being added. Wait for it to finish."
|
||||
title={
|
||||
loading
|
||||
? "The project is being added. Wait for it to finish."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{loading ? "Adding…" : "Add Project"}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -122,14 +122,6 @@ describe("ProjectRow", () => {
|
||||
});
|
||||
|
||||
it("only allows opening a terminal while the container runs", () => {
|
||||
const { unmount } = render(<ProjectRow project={baseProject} />);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
unmount();
|
||||
|
||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
@@ -139,6 +131,38 @@ describe("ProjectRow", () => {
|
||||
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the terminal button announced, and explains why, while stopped", () => {
|
||||
render(<ProjectRow project={baseProject} />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
});
|
||||
// Native `disabled` would drop the button out of the accessibility tree
|
||||
// and out of the tab order, taking the reason with it.
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveAccessibleDescription(/is not running/i);
|
||||
});
|
||||
|
||||
it("ignores clicks and Enter/Space on the terminal button while stopped", () => {
|
||||
render(<ProjectRow project={baseProject} />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
});
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
fireEvent.keyDown(button, { key: " " });
|
||||
expect(mockOpenClaudeTerminal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops aria-disabled once the container is running", () => {
|
||||
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
||||
const button = screen.getByRole("button", {
|
||||
name: "Open a Claude terminal for Test Project",
|
||||
});
|
||||
expect(button).not.toHaveAttribute("aria-disabled");
|
||||
expect(button).not.toHaveAccessibleDescription(/is not running/i);
|
||||
});
|
||||
|
||||
it("shows container progress inline rather than in a blocking modal", () => {
|
||||
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
||||
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Project } from "../../lib/types";
|
||||
import { useAppState, homeTabKey } from "../../store/appState";
|
||||
import { useProjectActions } from "../../hooks/useProjectActions";
|
||||
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
|
||||
import { useUnavailable } from "../ui/unavailable";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -31,6 +32,15 @@ export default function ProjectRow({ project }: Props) {
|
||||
const isTransitioning =
|
||||
project.status === "starting" || project.status === "stopping";
|
||||
|
||||
// A terminal needs a running container. Saying so out loud beats a `disabled`
|
||||
// attribute that hides the button — and the reason — from anyone not using a
|
||||
// mouse and eyes.
|
||||
const terminal = useUnavailable({
|
||||
unavailable: !isRunning,
|
||||
reason: `${project.name} is not running. Start it to open a terminal.`,
|
||||
onClick: () => openClaudeTerminal(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
|
||||
@@ -113,11 +123,14 @@ export default function ProjectRow({ project }: Props) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isRunning}
|
||||
onClick={() => openClaudeTerminal()}
|
||||
title={`Open a Claude terminal for ${project.name}`}
|
||||
{...terminal.controlProps}
|
||||
title={
|
||||
isRunning
|
||||
? `Open a Claude terminal for ${project.name}`
|
||||
: `${project.name} is not running. Start it to open a terminal.`
|
||||
}
|
||||
aria-label={`Open a Claude terminal for ${project.name}`}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
|
||||
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent aria-disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
@@ -134,6 +147,7 @@ export default function ProjectRow({ project }: Props) {
|
||||
<line x1="13" y1="15" x2="17" y2="15" />
|
||||
</svg>
|
||||
</button>
|
||||
{terminal.reasonNode}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -144,15 +144,16 @@ export default function FileViewerModal({ projectId, entry, onClose }: Props) {
|
||||
|
||||
{preview.kind === "too-large" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Open it
|
||||
from a terminal in the container, or take a backup and open it on the host.
|
||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Use
|
||||
“Save to host…” on its row to open it in a program that can, or read it from a
|
||||
terminal in the container.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "unsupported" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
There is no preview for this file type. Open it from a terminal in the container,
|
||||
or take a backup and open it on the host.
|
||||
There is no preview for this file type. Use “Save to host…” on its row to open it
|
||||
in a program that can, or read it from a terminal in the container.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
|
||||
import FilesTab from "./FilesTab";
|
||||
import type { FileContents, FileEntry, Project } from "../../../lib/types";
|
||||
|
||||
@@ -7,6 +7,8 @@ const listContainerFiles = vi.fn();
|
||||
const renameContainerPath = vi.fn(async () => "");
|
||||
const createContainerDirectory = vi.fn(async () => "");
|
||||
const readContainerFile = vi.fn();
|
||||
const uploadFilesToContainer = vi.fn();
|
||||
const downloadContainerFile = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
@@ -14,6 +16,8 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
|
||||
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
|
||||
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
|
||||
}));
|
||||
|
||||
/** Transient failures land in `ToastHost`, not in an inline string. */
|
||||
@@ -175,9 +179,15 @@ describe("FilesTab viewer", () => {
|
||||
});
|
||||
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
|
||||
expect(screen.queryByAltText("huge.png")).toBeNull();
|
||||
// The way out is named, and it is not a host path this pane could write:
|
||||
// a terminal inside the container, or a backup.
|
||||
expect(screen.getByText(/take a backup/)).toBeTruthy();
|
||||
// A refusal has to name the way out, and the way out is now the button on
|
||||
// the row rather than the `cat`-it-in-a-terminal workaround that existed
|
||||
// because the button did not.
|
||||
// Scoped to the modal: every file row also carries a "Save to host…"
|
||||
// button now, so an unscoped query matches the grid behind the overlay and
|
||||
// would pass with the refusal saying nothing at all.
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByText(/Save to host/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says so in words when only a prefix of a big text file came back", async () => {
|
||||
@@ -392,3 +402,73 @@ describe("FilesTab grid semantics", () => {
|
||||
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The pane's two host-transfer affordances.
|
||||
*
|
||||
* They are asserted at the *button* level and not only in the hook, because
|
||||
* this is the half that was actually lost: the commands behind them had been
|
||||
* deleted, but so had the controls, and a working command nobody can reach is
|
||||
* the same regression. Neither button names a host path — Rust opens the
|
||||
* dialog — so what a click is required to prove is that the container-side
|
||||
* argument reaching the backend is the one the user is looking at.
|
||||
*/
|
||||
describe("FilesTab host transfers", () => {
|
||||
beforeEach(() => {
|
||||
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
|
||||
downloadContainerFile.mockResolvedValue(4);
|
||||
});
|
||||
|
||||
it("uploads into the directory currently on screen", async () => {
|
||||
listContainerFiles.mockResolvedValue([entry("src", { is_directory: true })]);
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("src"));
|
||||
});
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/src/a.txt"],
|
||||
failures: [],
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upload…" }));
|
||||
});
|
||||
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/src");
|
||||
});
|
||||
|
||||
it("offers Save to host on a file and not on a folder", async () => {
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("notes.txt"),
|
||||
entry("src", { is_directory: true }),
|
||||
]);
|
||||
await renderTab();
|
||||
// The accessible name carries the row, per WCAG 2.5.3 — and it is how a
|
||||
// per-row action is told apart from every other row's copy of it.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save to host — notes.txt" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Save to host — src" }),
|
||||
).toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save to host — notes.txt" }));
|
||||
});
|
||||
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
|
||||
});
|
||||
|
||||
it("does not open the file viewer when Save to host is double-clicked", async () => {
|
||||
// Opening a file is a *double*-click on the row, and a double-click on a
|
||||
// button inside that row still bubbles — `onClick`'s `stopPropagation` does
|
||||
// nothing about it. So an impatient double-click on Save used to save the
|
||||
// file and drop the viewer modal over the pane at the same time, on top of
|
||||
// the save dialog the backend had just opened.
|
||||
listContainerFiles.mockResolvedValue([entry("notes.txt")]);
|
||||
readContainerFile.mockResolvedValue(contents("hello"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(
|
||||
screen.getByRole("button", { name: "Save to host — notes.txt" }),
|
||||
);
|
||||
});
|
||||
expect(readContainerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,13 +15,26 @@ const PARENT_ROW = "..";
|
||||
/**
|
||||
* The project's file browser.
|
||||
*
|
||||
* Container-side only: it lists, opens, renames and creates folders inside the
|
||||
* container, and it does no host filesystem I/O at all. A file gets *into* a
|
||||
* container by being dropped onto the Terminal tab, and a whole tree comes back
|
||||
* out through "Back up container" in the project's Workspace settings. Four
|
||||
* successive audits found that host paths crossing IPC were where the criticals
|
||||
* lived; those two paths are the ones that survived, and this pane is not one
|
||||
* of them.
|
||||
* It lists, opens, renames and creates folders inside the container, and it
|
||||
* copies single files across the boundary: "Upload…" in the toolbar, and a
|
||||
* per-row "Save to host…".
|
||||
*
|
||||
* **Neither of those names a host path, and this file must never learn how
|
||||
* to.** Four successive audits found that host paths crossing IPC were where
|
||||
* the criticals lived — a frontend `open()`/`save()` handing Rust a string is
|
||||
* exactly the shape that failed — so the picker is opened by the *backend*
|
||||
* (`pick_files_to_upload` / `pick_save_path` in `commands/file_commands.rs`).
|
||||
* What this file *sends* is a project id and a container path; the host side of
|
||||
* the transfer is chosen by a person in an OS dialog. That is why
|
||||
* `uploadFiles()` takes no argument and `saveToHost()` takes only the entry.
|
||||
* (A failed transfer does report a host path back, in the text of its error —
|
||||
* the inbound direction is the one that is closed, not both.)
|
||||
*
|
||||
* Drag-and-drop is deliberately still absent, in both directions. A file also
|
||||
* gets into a container by being dropped onto the Terminal tab, and a whole
|
||||
* tree comes back out through "Back up container" in the project's ⋯ menu —
|
||||
* which is still the right answer for a directory, since "Save to host…" is one
|
||||
* file at a time and is not offered on folders.
|
||||
*
|
||||
* Interaction model, chosen to match every desktop file manager rather than
|
||||
* the old half-and-half: **single click selects, double click opens**. That
|
||||
@@ -52,6 +65,10 @@ export default function FilesTab({ project }: Props) {
|
||||
refresh,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
uploadFiles,
|
||||
saveToHost,
|
||||
uploading,
|
||||
savingPaths,
|
||||
} = useFileManager(project.id);
|
||||
|
||||
const running = project.status === "running";
|
||||
@@ -305,6 +322,16 @@ export default function FilesTab({ project }: Props) {
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
{/* The file picker this opens belongs to Rust, not to the webview — so
|
||||
this file imports no dialog plugin and never composes a host path.
|
||||
`uploadFiles` takes no argument for the same reason. */}
|
||||
<Button
|
||||
onClick={() => void uploadFiles()}
|
||||
disabled={uploading}
|
||||
className="ml-1"
|
||||
>
|
||||
{uploading ? "Uploading…" : "Upload…"}
|
||||
</Button>
|
||||
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||
Refresh
|
||||
</Button>
|
||||
@@ -501,6 +528,34 @@ export default function FilesTab({ project }: Props) {
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
{/* Folders have no single-file equivalent — a
|
||||
recursive download is what "Back up container" is
|
||||
for, and offering one here would mean rebuilding
|
||||
the tree-walking this pane deliberately does not
|
||||
do. */}
|
||||
{!entry.is_directory && (
|
||||
<Button
|
||||
aria-label={`Save to host — ${entry.name}`}
|
||||
className="ml-1"
|
||||
// Only this row: a large file can take a while,
|
||||
// and there is no reason the rest of the pane
|
||||
// should go dead while it is written.
|
||||
disabled={savingPaths.has(entry.path)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void saveToHost(entry);
|
||||
}}
|
||||
// A double-click is its own event, and
|
||||
// `onClick`'s `stopPropagation` says nothing
|
||||
// about it — so an impatient double-click here
|
||||
// reached the row's `onDoubleClick` and dropped
|
||||
// the viewer modal over the pane, on top of the
|
||||
// save dialog the backend had just opened.
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{savingPaths.has(entry.path) ? "Saving…" : "Save to host…"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { projectRemovalIsClean } from "../../../lib/types";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import { useProjects } from "../../../hooks/useProjects";
|
||||
@@ -18,6 +19,7 @@ import ConfigTab from "./ConfigTab";
|
||||
import FilesTab from "./FilesTab";
|
||||
import BrowserTab from "./BrowserTab";
|
||||
import { formatUptime } from "./format";
|
||||
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
|
||||
|
||||
const TABS = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
@@ -282,7 +284,25 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
onConfirm={async () => {
|
||||
setConfirmRemove(false);
|
||||
try {
|
||||
await remove(project.id);
|
||||
const report = await remove(project.id);
|
||||
if (!projectRemovalIsClean(report)) {
|
||||
const verb = leftoverVerb(report);
|
||||
if (report.retry_scheduled) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "info",
|
||||
message: `“${project.name}” was removed, but Triple-C could not confirm all its Docker resources were removed`,
|
||||
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed. It will check again the next time it starts.`,
|
||||
});
|
||||
} else {
|
||||
// The pending-cleanup record itself failed to save — no
|
||||
// retry will happen, so this must not promise one.
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: `“${project.name}” was removed, but Triple-C could not confirm its Docker resources were removed`,
|
||||
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed, and could not record this for a retry. You may need to remove ${leftoverPronoun(report)} manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeLeftovers, leftoverVerb } from "./removalReport";
|
||||
import { projectRemovalIsClean } from "../../../lib/types";
|
||||
import type { ProjectRemovalReport } from "../../../lib/types";
|
||||
|
||||
function report(overrides: Partial<ProjectRemovalReport> = {}): ProjectRemovalReport {
|
||||
return {
|
||||
container: null,
|
||||
image: null,
|
||||
volumes: [],
|
||||
retry_scheduled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("projectRemovalIsClean", () => {
|
||||
it("is true only when nothing survived", () => {
|
||||
expect(projectRemovalIsClean(report())).toBe(true);
|
||||
expect(projectRemovalIsClean(report({ container: "triple-c-abc" }))).toBe(false);
|
||||
expect(projectRemovalIsClean(report({ image: "triple-c-snapshot-abc:latest" }))).toBe(false);
|
||||
expect(projectRemovalIsClean(report({ volumes: ["triple-c-home-abc"] }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeLeftovers", () => {
|
||||
it("names each kind of leftover", () => {
|
||||
expect(describeLeftovers(report({ container: "triple-c-abc" }))).toBe("its container");
|
||||
expect(describeLeftovers(report({ image: "x" }))).toBe("its saved image");
|
||||
expect(describeLeftovers(report({ volumes: ["v1"] }))).toBe("a volume");
|
||||
expect(describeLeftovers(report({ volumes: ["v1", "v2"] }))).toBe("2 volumes");
|
||||
});
|
||||
|
||||
it("joins multiple kinds together", () => {
|
||||
expect(
|
||||
describeLeftovers(report({ container: "triple-c-abc", image: "x", volumes: ["v1", "v2"] })),
|
||||
).toBe("its container, its saved image, 2 volumes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("leftoverVerb", () => {
|
||||
it("is singular for exactly one leftover of any kind", () => {
|
||||
expect(leftoverVerb(report({ container: "triple-c-abc" }))).toBe("was");
|
||||
expect(leftoverVerb(report({ image: "x" }))).toBe("was");
|
||||
expect(leftoverVerb(report({ volumes: ["v1"] }))).toBe("was");
|
||||
});
|
||||
|
||||
it("is plural once more than one thing survived, including multiple volumes alone", () => {
|
||||
expect(leftoverVerb(report({ container: "triple-c-abc", image: "x" }))).toBe("were");
|
||||
expect(leftoverVerb(report({ volumes: ["v1", "v2"] }))).toBe("were");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ProjectRemovalReport } from "../../../lib/types";
|
||||
|
||||
/**
|
||||
* Names what a `ProjectRemovalReport` says survived, for the leftover toast.
|
||||
*
|
||||
* Worded as "could not confirm" rather than "is still on disk": the same
|
||||
* report shape covers a genuine leftover (a locked volume) and a daemon that
|
||||
* was simply unreachable at the time, in which case nothing was ever created
|
||||
* and there is nothing to find — asserting certainty either way would be
|
||||
* wrong in one of those cases.
|
||||
*/
|
||||
export function describeLeftovers(report: ProjectRemovalReport): string {
|
||||
const parts: string[] = [];
|
||||
if (report.container) parts.push("its container");
|
||||
if (report.image) parts.push("its saved image");
|
||||
if (report.volumes.length === 1) parts.push("a volume");
|
||||
else if (report.volumes.length > 1) parts.push(`${report.volumes.length} volumes`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/** How many distinct things `describeLeftovers` is describing — a container
|
||||
* and an image each count as one, however many volumes are named. Shared by
|
||||
* `leftoverVerb` and `leftoverPronoun` so the two can never disagree about
|
||||
* singular vs. plural. */
|
||||
function leftoverCount(report: ProjectRemovalReport): number {
|
||||
return (report.container ? 1 : 0) + (report.image ? 1 : 0) + report.volumes.length;
|
||||
}
|
||||
|
||||
/** Verb agreement for `describeLeftovers`'s output — "its container" needs
|
||||
* "was", "its container, a volume" needs "were". */
|
||||
export function leftoverVerb(report: ProjectRemovalReport): "was" | "were" {
|
||||
return leftoverCount(report) === 1 ? "was" : "were";
|
||||
}
|
||||
|
||||
/** Pronoun agreement for referring back to `describeLeftovers`'s output —
|
||||
* "remove it manually" for one thing, "remove them manually" for more. */
|
||||
export function leftoverPronoun(report: ProjectRemovalReport): "it" | "them" {
|
||||
return leftoverCount(report) === 1 ? "it" : "them";
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import ExportSettingsModal from "./ExportSettingsModal";
|
||||
|
||||
const exportSettings = vi.fn();
|
||||
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
exportSettings: (password: string) => exportSettings(password),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function fillPasswords(password: string, confirm: string) {
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: password } });
|
||||
fireEvent.change(screen.getByLabelText("Confirm password"), { target: { value: confirm } });
|
||||
}
|
||||
|
||||
describe("ExportSettingsModal", () => {
|
||||
it("keeps the submit button disabled until the passwords are long enough and match", () => {
|
||||
render(<ExportSettingsModal onClose={vi.fn()} />);
|
||||
const submit = screen.getByRole("button", { name: /choose where to save/i });
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
fillPasswords("short", "short");
|
||||
expect(submit).toBeDisabled();
|
||||
expect(screen.getByText(/use at least 8 characters/i)).toBeInTheDocument();
|
||||
|
||||
fillPasswords("longenoughpassword", "different");
|
||||
expect(submit).toBeDisabled();
|
||||
expect(screen.getByText(/don't match/i)).toBeInTheDocument();
|
||||
|
||||
fillPasswords("longenoughpassword", "longenoughpassword");
|
||||
expect(submit).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("exports with the entered password and shows success", async () => {
|
||||
exportSettings.mockResolvedValue(true);
|
||||
render(<ExportSettingsModal onClose={vi.fn()} />);
|
||||
|
||||
fillPasswords("longenoughpassword", "longenoughpassword");
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
|
||||
|
||||
await waitFor(() => expect(exportSettings).toHaveBeenCalledWith("longenoughpassword"));
|
||||
await waitFor(() => expect(screen.getByText(/settings exported/i)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("closes quietly when the save dialog is dismissed", async () => {
|
||||
exportSettings.mockResolvedValue(false);
|
||||
const onClose = vi.fn();
|
||||
render(<ExportSettingsModal onClose={onClose} />);
|
||||
|
||||
fillPasswords("longenoughpassword", "longenoughpassword");
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(screen.queryByText(/settings exported/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error rather than closing when the export fails", async () => {
|
||||
exportSettings.mockRejectedValue("Disk is full");
|
||||
const onClose = vi.fn();
|
||||
render(<ExportSettingsModal onClose={onClose} />);
|
||||
|
||||
fillPasswords("longenoughpassword", "longenoughpassword");
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose where to save/i }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Disk is full")).toBeInTheDocument());
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import Field, { inputClass } from "../ui/Field";
|
||||
import { exportSettings } from "../../lib/tauri-commands";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* Password entry for exporting global settings. The save dialog itself opens
|
||||
* from Rust once a password is confirmed here — see the doc comment on
|
||||
* `commands::settings_export_commands` for why the host path never
|
||||
* round-trips through this component.
|
||||
*/
|
||||
export default function ExportSettingsModal({ onClose }: Props) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const mismatch = confirmPassword.length > 0 && password !== confirmPassword;
|
||||
const tooShort = password.length > 0 && password.length < MIN_PASSWORD_LENGTH;
|
||||
const canSubmit = password.length >= MIN_PASSWORD_LENGTH && password === confirmPassword;
|
||||
|
||||
const handleExport = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const saved = await exportSettings(password);
|
||||
if (saved) setDone(true);
|
||||
// `false` means the save dialog was dismissed — close quietly, same as
|
||||
// if the user had cancelled the modal itself.
|
||||
else onClose();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Export settings"
|
||||
description="Saves your global settings and any stored credentials (a shared Claude login, gateway keys) to one encrypted file. Project-specific settings and container data are not included."
|
||||
widthClassName="w-[28rem]"
|
||||
dismissible={!busy}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
done ? (
|
||||
<Button size="md" variant="primary" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
onClick={() => void handleExport()}
|
||||
disabled={!canSubmit || busy}
|
||||
>
|
||||
{busy ? "Exporting…" : "Choose where to save…"}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{done ? (
|
||||
<p className="text-[13px] text-[var(--success)]">
|
||||
Settings exported. Keep the password somewhere safe — there is no way to recover
|
||||
the file without it.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Field label="Password" hint={`At least ${MIN_PASSWORD_LENGTH} characters. You'll need this exact password to import the file later.`}>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Confirm password">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{tooShort && (
|
||||
<p className="text-xs text-[var(--error)]">
|
||||
Use at least {MIN_PASSWORD_LENGTH} characters.
|
||||
</p>
|
||||
)}
|
||||
{mismatch && <p className="text-xs text-[var(--error)]">Passwords don't match.</p>}
|
||||
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import ImportSettingsModal from "./ImportSettingsModal";
|
||||
import type { AppSettings, SettingsImportOutcome, SettingsImportPreview } from "../../lib/types";
|
||||
|
||||
const previewSettingsImport = vi.fn();
|
||||
const applySettingsImport = vi.fn();
|
||||
|
||||
vi.mock("../../lib/tauri-commands", () => ({
|
||||
previewSettingsImport: (password: string) => previewSettingsImport(password),
|
||||
applySettingsImport: (password: string) => applySettingsImport(password),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const samplePreview: SettingsImportPreview = {
|
||||
exported_at: "2026-08-27T00:00:00Z",
|
||||
app_version: "0.4.14",
|
||||
custom_env_var_count: 2,
|
||||
gateway_model_count: 0,
|
||||
has_claude_code_settings: false,
|
||||
has_claude_oauth_token: true,
|
||||
has_gateway_api_key: false,
|
||||
has_gateway_master_key: false,
|
||||
has_web_terminal_access_token: false,
|
||||
enables_web_terminal: false,
|
||||
ollama_base_url: null,
|
||||
llamacpp_base_url: null,
|
||||
openai_compatible_base_url: null,
|
||||
gateway_api_base: null,
|
||||
image_source: "registry",
|
||||
custom_image_name: null,
|
||||
};
|
||||
|
||||
function outcome(settings: AppSettings, secretRestoreWarnings: string[] = []): SettingsImportOutcome {
|
||||
return { settings, secret_restore_warnings: secretRestoreWarnings };
|
||||
}
|
||||
|
||||
describe("ImportSettingsModal", () => {
|
||||
it("keeps 'Choose file' disabled until a password is entered", () => {
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /choose file/i })).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
expect(screen.getByRole("button", { name: /choose file/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows the preview and confirms with the same password used to open it", async () => {
|
||||
previewSettingsImport.mockResolvedValue(samplePreview);
|
||||
applySettingsImport.mockResolvedValue(outcome({} as AppSettings));
|
||||
const onImported = vi.fn();
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={onImported} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
await waitFor(() => expect(previewSettingsImport).toHaveBeenCalledWith("hunter2"));
|
||||
expect(await screen.findByText(/2 global custom env vars/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/your shared claude login/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
|
||||
await waitFor(() => expect(applySettingsImport).toHaveBeenCalledWith("hunter2"));
|
||||
await waitFor(() => expect(onImported).toHaveBeenCalledWith({}));
|
||||
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a distinct warning when the import would enable the web terminal", async () => {
|
||||
previewSettingsImport.mockResolvedValue({ ...samplePreview, enables_web_terminal: true });
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
expect(await screen.findByText(/enables the remote web terminal/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns about a custom Docker image every time, not just on change", async () => {
|
||||
previewSettingsImport.mockResolvedValue({
|
||||
...samplePreview,
|
||||
image_source: "custom",
|
||||
custom_image_name: "ghcr.io/attacker/triple-c:latest",
|
||||
});
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/custom docker image: ghcr\.io\/attacker\/triple-c:latest/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a secret-restore warning alongside success rather than hiding it", async () => {
|
||||
previewSettingsImport.mockResolvedValue(samplePreview);
|
||||
applySettingsImport.mockResolvedValue(
|
||||
outcome({} as AppSettings, ["Could not restore the gateway master key: keychain locked"]),
|
||||
);
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
await screen.findByText(/2 global custom env vars/i);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
|
||||
expect(await screen.findByText(/settings imported/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/could not restore the gateway master key/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes quietly when the file picker is dismissed", async () => {
|
||||
previewSettingsImport.mockResolvedValue(null);
|
||||
const onClose = vi.fn();
|
||||
render(<ImportSettingsModal onClose={onClose} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("shows an error when the password is wrong rather than a blank preview", async () => {
|
||||
previewSettingsImport.mockRejectedValue("Wrong password, or the file is corrupted.");
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "wrong" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
|
||||
expect(await screen.findByText(/wrong password, or the file is corrupted/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error if applying the import fails, without claiming success", async () => {
|
||||
previewSettingsImport.mockResolvedValue(samplePreview);
|
||||
applySettingsImport.mockRejectedValue("Keychain write failed");
|
||||
render(<ImportSettingsModal onClose={vi.fn()} onImported={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "hunter2" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
await screen.findByText(/2 global custom env vars/i);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /^import$/i }));
|
||||
expect(await screen.findByText("Keychain write failed")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/settings imported/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "../ui/Modal";
|
||||
import Button from "../ui/Button";
|
||||
import Field, { inputClass } from "../ui/Field";
|
||||
import { applySettingsImport, previewSettingsImport } from "../../lib/tauri-commands";
|
||||
import { describeImport, describeImportWarnings } from "../../lib/settingsImportPreview";
|
||||
import type { AppSettings, SettingsImportPreview } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
/** Fired once the import is actually applied, so the caller can refresh
|
||||
* whatever reads settings from the store. */
|
||||
onImported: (settings: AppSettings) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two phases: enter the password and pick the file (backend resolves the
|
||||
* file dialog itself — see `commands::settings_export_commands`), then
|
||||
* confirm a preview before anything is actually applied. The same password
|
||||
* is reused for the second call rather than asking again; nothing about
|
||||
* that call needs a fresh secret; the backend just doesn't cache the
|
||||
* *decrypted payload* between the two.
|
||||
*/
|
||||
export default function ImportSettingsModal({ onClose, onImported }: Props) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<SettingsImportPreview | null>(null);
|
||||
const [applied, setApplied] = useState(false);
|
||||
const [secretWarnings, setSecretWarnings] = useState<string[]>([]);
|
||||
|
||||
const handleChooseFile = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await previewSettingsImport(password);
|
||||
if (result) setPreview(result);
|
||||
else onClose(); // File picker dismissed.
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const outcome = await applySettingsImport(password);
|
||||
setApplied(true);
|
||||
setSecretWarnings(outcome.secret_restore_warnings);
|
||||
onImported(outcome.settings);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Import settings"
|
||||
description={
|
||||
preview
|
||||
? "Review what this file will change before applying it."
|
||||
: "Choose a Triple-C settings export and enter the password it was created with."
|
||||
}
|
||||
widthClassName="w-[28rem]"
|
||||
dismissible={!busy}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
applied ? (
|
||||
<Button size="md" variant="primary" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
) : preview ? (
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="md" variant="primary" onClick={() => void handleConfirm()} disabled={busy}>
|
||||
{busy ? "Importing…" : "Import"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button size="md" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
onClick={() => void handleChooseFile()}
|
||||
disabled={!password || busy}
|
||||
>
|
||||
{busy ? "Opening…" : "Choose file…"}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{applied ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[13px] text-[var(--success)]">Settings imported.</p>
|
||||
{secretWarnings.map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
className="px-2.5 py-2 text-xs text-[var(--error)] bg-[var(--error-muted)] border border-[var(--error)]/40 rounded-[var(--radius-control)] leading-snug"
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : preview ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Exported {new Date(preview.exported_at).toLocaleString()} from Triple-C{" "}
|
||||
{preview.app_version}.
|
||||
</p>
|
||||
{/* Warnings render before the replace list, deliberately: the list
|
||||
* below can run long, and the one thing here that most needs to
|
||||
* stay above the fold while scrolling is "this turns on a
|
||||
* network-listening service" or "this runs a different image" —
|
||||
* not a bullet buried among ordinary settings. */}
|
||||
{describeImportWarnings(preview).map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
className="px-2.5 py-2 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] leading-snug break-all"
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-[var(--text-primary)]">This will replace:</p>
|
||||
<ul className="mt-1 list-disc pl-4 text-[13px] text-[var(--text-secondary)] space-y-0.5">
|
||||
{describeImport(preview).map((item) => (
|
||||
<li key={item} className="break-all">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Field label="Password">
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={busy}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{error && <p className="text-xs text-[var(--error)]">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -15,13 +15,17 @@ import type { EnvVar } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
import AccordionSection from "../ui/AccordionSection";
|
||||
import Toggle from "../ui/Toggle";
|
||||
import SegmentedControl from "../ui/SegmentedControl";
|
||||
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||
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 +37,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(() => {
|
||||
@@ -63,6 +69,14 @@ export default function SettingsPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGpuRenderingChange = async (value: "auto" | "on" | "off") => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
terminal_gpu_rendering: value === "auto" ? null : value === "on",
|
||||
});
|
||||
};
|
||||
|
||||
const handleAutoCheckToggle = async () => {
|
||||
if (!appSettings) return;
|
||||
await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates });
|
||||
@@ -238,6 +252,45 @@ export default function SettingsPanel() {
|
||||
<SttSettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="terminal" title="Terminal" defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-[var(--text-secondary)]">GPU rendering</label>
|
||||
<SegmentedControl
|
||||
label="Terminal GPU rendering"
|
||||
value={
|
||||
appSettings?.terminal_gpu_rendering == null
|
||||
? "auto"
|
||||
: appSettings.terminal_gpu_rendering
|
||||
? "on"
|
||||
: "off"
|
||||
}
|
||||
onChange={handleGpuRenderingChange}
|
||||
segments={[
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto",
|
||||
hint: resolveTerminalGpuRendering(null, navigator.userAgent)
|
||||
? "On for this platform."
|
||||
: "Off on Linux — the DMA-BUF workaround leaves WebGL on software rendering, which is slower than the canvas renderer.",
|
||||
},
|
||||
{
|
||||
value: "on",
|
||||
label: "On",
|
||||
hint: "Always load the WebGL renderer.",
|
||||
},
|
||||
{
|
||||
value: "off",
|
||||
label: "Off",
|
||||
hint: "Always use xterm's canvas renderer. Try this if typing feels laggy.",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Takes effect when a terminal tab is next switched to.
|
||||
</p>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="updates" title="Updates" defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
{appVersion && (
|
||||
@@ -269,6 +322,39 @@ export default function SettingsPanel() {
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="backup" title="Backup" defaultOpen={false}>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Export your global settings and stored credentials (a shared Claude login,
|
||||
gateway keys) to one password-encrypted file, or restore them on a new machine.
|
||||
Project-specific settings and container data are never included.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowExportModal(true)}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Export settings…
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Import settings…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{showExportModal && <ExportSettingsModal onClose={() => setShowExportModal(false)} />}
|
||||
|
||||
{showImportModal && (
|
||||
<ImportSettingsModal
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onImported={(settings) => setAppSettings(settings)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInstructionsModal && (
|
||||
<ClaudeInstructionsModal
|
||||
instructions={globalInstructions}
|
||||
|
||||
@@ -137,19 +137,36 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("TerminalView — Shift+Enter", () => {
|
||||
it("sends ESC+CR and nothing else in a Claude session", () => {
|
||||
it("sends ESC+CR and cancels the keydown, so no bare CR follows", () => {
|
||||
// **The cancel is the load-bearing half, and this test could not see it.**
|
||||
//
|
||||
// Returning `false` from xterm's custom key handler does not cancel the
|
||||
// event: `_keyDown` returns before setting `_keyDownHandled`, so
|
||||
// `_keyPress` still runs and emits a bare CR for Enter's charCode 13. In a
|
||||
// real browser that submitted the prompt straight after inserting the
|
||||
// newline. jsdom never synthesizes the follow-up keypress, so the old
|
||||
// `expect(sent()).not.toContain("\r")` assertion below could not fail no
|
||||
// matter what the code did — it was named for a behaviour it could not
|
||||
// exercise.
|
||||
//
|
||||
// Asserting `defaultPrevented` pins the actual mechanism that stops the
|
||||
// keypress, which is a property jsdom *can* observe.
|
||||
const { container } = mountSession("claude");
|
||||
|
||||
fireEvent.keyDown(helperTextarea(container), {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
keyCode: 13,
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
helperTextarea(container).dispatchEvent(event);
|
||||
|
||||
// The bytes `/terminal-setup` installs for every other editor.
|
||||
expect(sent()).toEqual(["\x1b\r"]);
|
||||
// And specifically not the bare CR that would have submitted the prompt.
|
||||
expect(sent()).not.toContain("\r");
|
||||
// Without this, the browser fires keypress and xterm submits.
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a plain Enter alone", () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import UrlToast, {
|
||||
URL_TOAST_SHORTCUT,
|
||||
} from "./UrlToast";
|
||||
import { trimSelection } from "./trimSelection";
|
||||
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||
import TerminalContextMenu from "./TerminalContextMenu";
|
||||
|
||||
interface Props {
|
||||
@@ -95,6 +96,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const webglRef = useRef<WebglAddon | null>(null);
|
||||
const detectorRef = useRef<UrlDetector | null>(null);
|
||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
||||
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
|
||||
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
|
||||
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
|
||||
@@ -414,7 +416,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
sessionTypeRef.current === "claude"
|
||||
) {
|
||||
sendInput(sessionId, "\x1b\r");
|
||||
return false; // xterm must not also send a bare CR, which submits
|
||||
// **`preventDefault()` is what stops the submit, not the `return false`.**
|
||||
//
|
||||
// xterm's `_keyDown` returns the instant a custom handler says `false`
|
||||
// — *before* it sets `_keyDownHandled` and before it cancels the event.
|
||||
// `_keyPress` then checks that same flag, finds it still false, and
|
||||
// emits a bare CR for Enter's charCode 13. So returning `false` alone
|
||||
// sent ESC+CR *and* a submit: the newline was inserted and the
|
||||
// half-written prompt went to Claude with a stray blank line in it.
|
||||
// Cancelling the keydown is what stops the browser firing keypress at
|
||||
// all. Verified in Chromium; jsdom never synthesizes the follow-up
|
||||
// keypress, which is why the unit test could not see this.
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -479,7 +493,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
// Handle user input -> backend
|
||||
const inputDisposable = term.onData((data) => {
|
||||
sendInput(sessionId, data);
|
||||
// Ordered and coalesced by the queue in `useTerminal`; a rejection here
|
||||
// means the session is gone, which the exit listener already reports.
|
||||
sendInput(sessionId, data).catch((e) =>
|
||||
console.error("Failed to send terminal input:", e)
|
||||
);
|
||||
});
|
||||
|
||||
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
||||
@@ -672,7 +690,16 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
if (active) {
|
||||
// Auto on macOS/Windows, off on Linux, overridable either way — see
|
||||
// `resolveTerminalGpuRendering`. Loading the addon under a software-GL
|
||||
// WebKitGTK is slower than xterm's canvas renderer, not faster.
|
||||
const useGpu = resolveTerminalGpuRendering(gpuRenderingSetting, navigator.userAgent);
|
||||
|
||||
// The renderer and the activation work are independent: a terminal with
|
||||
// GPU rendering switched off still has to fit and take focus when its tab
|
||||
// becomes active. Keeping these in one branch made "GPU off" silently mean
|
||||
// "never re-fit, never focus".
|
||||
if (active && useGpu) {
|
||||
// Attach WebGL renderer
|
||||
if (!webglRef.current) {
|
||||
try {
|
||||
@@ -687,19 +714,21 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// WebGL not available, canvas renderer is fine
|
||||
}
|
||||
}
|
||||
} else if (webglRef.current) {
|
||||
// Release the context — for inactive terminals, and when the setting
|
||||
// turns GPU rendering off while this terminal is on screen.
|
||||
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
||||
webglRef.current = null;
|
||||
}
|
||||
|
||||
if (active) {
|
||||
fitRef.current?.fit();
|
||||
if (autoFollowRef.current) {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
term.focus();
|
||||
} else {
|
||||
// Release WebGL context for inactive terminals
|
||||
if (webglRef.current) {
|
||||
try { webglRef.current.dispose(); } catch { /* ignore */ }
|
||||
webglRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [active]);
|
||||
}, [active, gpuRenderingSetting]);
|
||||
|
||||
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
|
||||
// A keyboard user who has just jumped into the toast is mid-decision, and
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import Button from "./Button";
|
||||
|
||||
const onClick = vi.fn();
|
||||
const onKeyDown = vi.fn();
|
||||
|
||||
describe("Button", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("still supports the native disabled attribute", () => {
|
||||
render(
|
||||
<Button disabled onClick={onClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("stays in the accessibility tree when unavailable, and says why", () => {
|
||||
render(
|
||||
<Button unavailable unavailableReason="Stop the container first.">
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveAccessibleDescription("Stop the container first.");
|
||||
// The reason is a description, not part of the name.
|
||||
expect(button).toHaveAccessibleName("Save");
|
||||
});
|
||||
|
||||
it("guards clicks and Enter/Space while unavailable", () => {
|
||||
render(
|
||||
<Button unavailable unavailableReason="Stop the container first." onClick={onClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
fireEvent.keyDown(button, { key: " " });
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still forwards keys that are not activation keys", () => {
|
||||
render(
|
||||
<Button
|
||||
unavailable
|
||||
unavailableReason="Stop the container first."
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Save" }), {
|
||||
key: "Escape",
|
||||
});
|
||||
expect(onKeyDown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("behaves like an ordinary button when available", () => {
|
||||
render(
|
||||
<Button unavailable={false} unavailableReason="Stop the container first." onClick={onClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
expect(button).not.toHaveAttribute("aria-disabled");
|
||||
expect(button).toHaveAccessibleDescription("");
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { useUnavailable } from "./unavailable";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
export type ButtonSize = "sm" | "md";
|
||||
@@ -7,22 +8,37 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Unavailable, but still announced. Renders `aria-disabled` and wires
|
||||
* `unavailableReason` to `aria-describedby` instead of using the native
|
||||
* `disabled` attribute, which would take the button out of the tab order and
|
||||
* out of the accessibility tree — reason and all. Clicks and Enter/Space are
|
||||
* guarded for you. Prefer this over `disabled` whenever there is a reason
|
||||
* worth telling the user.
|
||||
*/
|
||||
unavailable?: boolean;
|
||||
/** Why the button cannot be used. Required for `unavailable` to say anything. */
|
||||
unavailableReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real buttons with visible bounds and a ≥24px hit target.
|
||||
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
|
||||
* `--accent` stays reserved for foreground/link use.
|
||||
*
|
||||
* The `aria-disabled:` class mirrors below exist because Tailwind's
|
||||
* `disabled:` variant only matches the native attribute, which `unavailable`
|
||||
* deliberately does not set. Keep the two lists in step.
|
||||
*/
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]",
|
||||
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] aria-disabled:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
||||
secondary:
|
||||
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]",
|
||||
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
|
||||
danger:
|
||||
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent",
|
||||
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-transparent",
|
||||
ghost:
|
||||
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent",
|
||||
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent",
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
@@ -35,16 +51,30 @@ export default function Button({
|
||||
size = "sm",
|
||||
className = "",
|
||||
type = "button",
|
||||
unavailable = false,
|
||||
unavailableReason = "",
|
||||
children,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { controlProps, reasonNode } = useUnavailable({
|
||||
unavailable,
|
||||
reason: unavailableReason,
|
||||
onClick: rest.onClick,
|
||||
onKeyDown: rest.onKeyDown,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type={type}
|
||||
{...rest}
|
||||
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
||||
{...controlProps}
|
||||
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed aria-disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{/* Outside the button: inside, the reason would join its accessible name. */}
|
||||
{reasonNode}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,16 @@ function ToastCard({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
|
||||
{tone.glyph}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[var(--text-primary)] break-words">{toast.message}</div>
|
||||
{/* Clamped. A toast message is normally a sentence, but some of them
|
||||
quote text a *container* wrote — and this card is `z-[60]`, above
|
||||
every modal, with its dismiss button at the top. An unclamped
|
||||
message of a few kilobytes is a card taller than the viewport whose
|
||||
✕ has been pushed off-screen, i.e. an unclosable overlay. The
|
||||
`detail` block below has always had `max-h-40 overflow-auto`; this
|
||||
half did not. */}
|
||||
<div className="text-[var(--text-primary)] break-words max-h-40 overflow-y-auto">
|
||||
{toast.message}
|
||||
</div>
|
||||
{toast.detail && (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
useId,
|
||||
type KeyboardEventHandler,
|
||||
type MouseEventHandler,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
/** Keys a native `<button>` turns into a click. */
|
||||
const ACTIVATION_KEYS = new Set([" ", "Spacebar", "Enter"]);
|
||||
|
||||
export interface UnavailableControlProps {
|
||||
"aria-disabled"?: true;
|
||||
"aria-describedby"?: string;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export interface UnavailableControl {
|
||||
/** Spread onto the control. Carries the guarded handlers. */
|
||||
controlProps: UnavailableControlProps;
|
||||
/**
|
||||
* Render as a *sibling* of the control — inside it the reason would be
|
||||
* appended to the accessible name instead of the description.
|
||||
*/
|
||||
reasonNode: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a control unavailable without hiding it from assistive technology.
|
||||
*
|
||||
* `disabled` takes an element out of the tab order *and* out of the
|
||||
* accessibility tree, so the `title` explaining why it cannot be used is
|
||||
* announced to nobody and shown only to a sighted user with a mouse. That is
|
||||
* backwards: the people who most need the reason are the ones who never get
|
||||
* it. `aria-disabled` keeps the control focusable and announced, and
|
||||
* `aria-describedby` hands over the reason.
|
||||
*
|
||||
* The catch is that `aria-disabled` is advisory — it does not block clicks or
|
||||
* Enter/Space the way `disabled` does. This hook therefore returns the guards
|
||||
* along with the attributes, so a call site cannot take the announcement
|
||||
* without the guard. Handlers that a form can reach without going through the
|
||||
* control (Enter inside a text field submits the form) still have to guard
|
||||
* themselves.
|
||||
*/
|
||||
export function useUnavailable({
|
||||
unavailable,
|
||||
reason,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
}: {
|
||||
unavailable: boolean;
|
||||
reason: string;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
|
||||
}): UnavailableControl {
|
||||
const reasonId = `${useId()}unavailable`;
|
||||
|
||||
if (!unavailable) {
|
||||
return { controlProps: { onClick, onKeyDown }, reasonNode: null };
|
||||
}
|
||||
|
||||
return {
|
||||
controlProps: {
|
||||
"aria-disabled": true,
|
||||
"aria-describedby": reasonId,
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
if (!ACTIVATION_KEYS.has(e.key)) {
|
||||
onKeyDown?.(e);
|
||||
return;
|
||||
}
|
||||
// Suppress the default action before it can become a click, submit a
|
||||
// form, or scroll the page.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
},
|
||||
reasonNode: (
|
||||
<span id={reasonId} className="sr-only">
|
||||
{reason}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -6,12 +6,16 @@ import type { FileEntry } from "../lib/types";
|
||||
const listContainerFiles = vi.fn();
|
||||
const renameContainerPath = vi.fn();
|
||||
const createContainerDirectory = vi.fn();
|
||||
const uploadFilesToContainer = vi.fn();
|
||||
const downloadContainerFile = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
|
||||
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
|
||||
readContainerFile: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -45,6 +49,8 @@ const file = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listContainerFiles.mockResolvedValue([file("a.txt")]);
|
||||
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
|
||||
downloadContainerFile.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
describe("useFileManager navigation", () => {
|
||||
@@ -292,3 +298,264 @@ describe("useFileManager surfaces written refusals as prose", () => {
|
||||
expect(lastToast().detail).toBe("no space left on device");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Both of these actions are *dialog-driven from Rust* — the hook passes a
|
||||
* project and a directory and gets back an answer, and there is deliberately no
|
||||
* host path anywhere in this file. What is worth pinning is the vocabulary of
|
||||
* that answer, because two of its values look like failure and are not: `null`
|
||||
* means the user dismissed the picker, and `0` bytes means an empty file was
|
||||
* saved successfully.
|
||||
*/
|
||||
describe("useFileManager saving to the host", () => {
|
||||
it("treats a zero-byte save as a success", async () => {
|
||||
// The bug this exists for: `if (!bytes) return` reads a genuine
|
||||
// zero-length file — an empty `.gitkeep`, a truncated log — as a
|
||||
// dismissal, so the file lands on the host and the app says nothing at
|
||||
// all. The sentinel is `null`, and only `null`.
|
||||
downloadContainerFile.mockResolvedValueOnce(0);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("empty.txt"));
|
||||
});
|
||||
expect(result.current.completed).toContain("empty.txt");
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says nothing at all when the dialog is dismissed", async () => {
|
||||
downloadContainerFile.mockResolvedValueOnce(null);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("a.txt"));
|
||||
});
|
||||
expect(result.current.completed).toBeNull();
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the file in a refusal", async () => {
|
||||
downloadContainerFile.mockRejectedValueOnce("/etc/shadow is not readable");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("secret.txt"));
|
||||
});
|
||||
expect(toastText()).toContain("secret.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager uploading from the host", () => {
|
||||
it("uploads into the directory on screen and shows the result", async () => {
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/app/one.txt", "/workspace/app/two.txt"],
|
||||
failures: [],
|
||||
});
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/app");
|
||||
expect(result.current.completed).toContain("2 files");
|
||||
// The directory is named. `target` is captured at click time and the
|
||||
// picker is a modal dialog, so "Uploaded 2 files." on its own can be shown
|
||||
// in front of a grid those files are not in.
|
||||
expect(result.current.completed).toContain("/workspace/app");
|
||||
// The new files are only on screen if the listing was asked for again.
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/app");
|
||||
});
|
||||
|
||||
it("reports every file that failed, not just a count", async () => {
|
||||
// "3 of 5 uploaded" without naming the two is not a report — the user
|
||||
// cannot tell which ones to retry, or why.
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/ok.txt"],
|
||||
failures: [
|
||||
"/home/j/Pictures is a folder — upload its files individually.",
|
||||
"/home/j/vm.img is too large to upload (900 MB; limit 256 MB).",
|
||||
],
|
||||
});
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(pushToast).toHaveBeenCalledTimes(2);
|
||||
expect(toastText()).toContain("is a folder");
|
||||
expect(toastText()).toContain("too large");
|
||||
// A partial batch still succeeded partially, and the pane must show it.
|
||||
expect(result.current.completed).toContain("1 file");
|
||||
});
|
||||
|
||||
it("does not refresh when the picker was dismissed", async () => {
|
||||
uploadFilesToContainer.mockResolvedValueOnce(null);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
expect(pushToast).not.toHaveBeenCalled();
|
||||
expect(result.current.completed).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a refusal that happened before the picker once, not per file", async () => {
|
||||
// No container, not running, or a directory this pane may not write to.
|
||||
// There is no selection yet, so there is nothing to enumerate.
|
||||
uploadFilesToContainer.mockRejectedValueOnce(
|
||||
"Start the project before uploading files — it runs inside the running container.",
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(pushToast).toHaveBeenCalledTimes(1);
|
||||
expect(toastText()).toContain("Start the project");
|
||||
});
|
||||
|
||||
it("does not drag the pane back when the user navigated during the upload", async () => {
|
||||
// The same rule rename and new-folder follow: a slow operation must not
|
||||
// relist a directory the user has already left.
|
||||
let release: (v: unknown) => void = () => {};
|
||||
uploadFilesToContainer.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/app");
|
||||
});
|
||||
let uploading: Promise<void>;
|
||||
act(() => {
|
||||
uploading = result.current.uploadFiles();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.navigate("/workspace/other");
|
||||
});
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
release({ uploaded: ["/workspace/app/one.txt"], failures: [] });
|
||||
await uploading;
|
||||
});
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
expect(result.current.currentPath).toBe("/workspace/other");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useFileManager transfer state", () => {
|
||||
it("marks an upload in flight for as long as it runs", async () => {
|
||||
// Without this the button stays live: a second click opens a second OS
|
||||
// dialog and runs a second concurrent exec, and a slow transfer looks
|
||||
// exactly like a click that did nothing.
|
||||
let release: (v: unknown) => void = () => {};
|
||||
uploadFilesToContainer.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
expect(result.current.uploading).toBe(false);
|
||||
let uploading: Promise<void>;
|
||||
act(() => {
|
||||
uploading = result.current.uploadFiles();
|
||||
});
|
||||
expect(result.current.uploading).toBe(true);
|
||||
await act(async () => {
|
||||
release({ uploaded: [], failures: [] });
|
||||
await uploading;
|
||||
});
|
||||
expect(result.current.uploading).toBe(false);
|
||||
});
|
||||
|
||||
it("stays in flight through the refresh, not just the transfer", async () => {
|
||||
// Clearing the flag the moment the command settled put the button back
|
||||
// while the re-listing was still running, so a second click landed
|
||||
// mid-refresh on a grid that was still showing the old contents.
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/a.txt"],
|
||||
failures: [],
|
||||
});
|
||||
let finishListing: (v: unknown) => void = () => {};
|
||||
listContainerFiles.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
finishListing = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
let uploading: Promise<void>;
|
||||
act(() => {
|
||||
uploading = result.current.uploadFiles();
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
// The transfer is done; the listing it triggered is not.
|
||||
expect(result.current.uploading).toBe(true);
|
||||
await act(async () => {
|
||||
finishListing([file("a.txt")]);
|
||||
await uploading;
|
||||
});
|
||||
expect(result.current.uploading).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the upload flag when the transfer fails", async () => {
|
||||
// The `catch` returns early, so without a `finally` the button is disabled
|
||||
// for the rest of the session — the failure mode is a pane that can never
|
||||
// upload again, with no error left on screen to explain it.
|
||||
uploadFilesToContainer.mockRejectedValueOnce("Start the project first");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadFiles();
|
||||
});
|
||||
expect(result.current.uploading).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks each save separately, so one finishing does not free another", async () => {
|
||||
// The bug this exists for: `savingPath` was a single string. Starting a
|
||||
// second save overwrote it, so the first row went live again mid-transfer,
|
||||
// and whichever save settled first cleared the flag for both — dismissing
|
||||
// the second dialog was enough. A set is what the design needs, because
|
||||
// "only the row being saved is disabled" is exactly what makes a second
|
||||
// save startable.
|
||||
let releaseBig: (v: unknown) => void = () => {};
|
||||
let releaseSmall: (v: unknown) => void = () => {};
|
||||
downloadContainerFile
|
||||
.mockReturnValueOnce(new Promise((r) => { releaseBig = r; }))
|
||||
.mockReturnValueOnce(new Promise((r) => { releaseSmall = r; }));
|
||||
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
let big: Promise<void>;
|
||||
let small: Promise<void>;
|
||||
act(() => { big = result.current.saveToHost(file("big.bin")); });
|
||||
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
|
||||
|
||||
act(() => { small = result.current.saveToHost(file("notes.txt")); });
|
||||
// Both, at once — a scalar could only hold the second.
|
||||
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
|
||||
expect(result.current.savingPaths.has("/workspace/notes.txt")).toBe(true);
|
||||
|
||||
// The second one finishing must not re-enable the first, which is still
|
||||
// streaming. `null` is the dismissal path, which is how this was cheapest
|
||||
// to trigger in practice.
|
||||
await act(async () => { releaseSmall(null); await small; });
|
||||
expect(result.current.savingPaths.has("/workspace/notes.txt")).toBe(false);
|
||||
expect(result.current.savingPaths.has("/workspace/big.bin")).toBe(true);
|
||||
|
||||
await act(async () => { releaseBig(10); await big; });
|
||||
expect(result.current.savingPaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("clears a row's saving flag when its save fails", async () => {
|
||||
downloadContainerFile.mockRejectedValueOnce("Permission denied");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.saveToHost(file("b.txt"));
|
||||
});
|
||||
expect(result.current.savingPaths.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FileEntry } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { useAppState } from "../store/appState";
|
||||
import { errorText, readableRefusal } from "../lib/refusalText";
|
||||
import { formatBytes } from "../lib/formatBytes";
|
||||
|
||||
/**
|
||||
* ## Where failures are reported
|
||||
@@ -13,8 +14,8 @@ import { errorText, readableRefusal } from "../lib/refusalText";
|
||||
* (empty) grid. It is on screen, it is in context, it explains why there are
|
||||
* no rows, and it is not transient — it stands until the directory lists.
|
||||
*
|
||||
* Every **transient operation** failure — rename, create folder — goes to
|
||||
* `ToastHost` instead. Those used to land in the same inline `error` div, which
|
||||
* Every **transient operation** failure — rename, create folder, upload, save
|
||||
* to host — goes to `ToastHost` instead. Those used to land in the same inline `error` div, which
|
||||
* is the first child of the *scrolling* list: three hundred rows down, a
|
||||
* refused rename produced no visible change at all, just a rename box that
|
||||
* stayed open for no stated reason. The toast host is a persistent `aria-live`
|
||||
@@ -42,6 +43,28 @@ export function useFileManager(projectId: string) {
|
||||
* change a sighted user sees in the grid and a screen reader user does not.
|
||||
*/
|
||||
const [completed, setCompleted] = useState<string | null>(null);
|
||||
/**
|
||||
* Which host transfers are in flight.
|
||||
*
|
||||
* Both actions open an OS dialog and can then run for a long time on a large
|
||||
* file, with nothing on screen to say so. Without this the buttons stay live:
|
||||
* a second click opens a second dialog and runs a second concurrent exec
|
||||
* against the same file, and a multi-gigabyte save is indistinguishable from
|
||||
* a click that did nothing.
|
||||
*
|
||||
* `savingPaths` is a **set**, not one path. Keeping only the row being
|
||||
* disabled is what makes the pane usable during a big transfer — and that is
|
||||
* precisely what makes a *second* save startable, so the state has to be able
|
||||
* to hold two. As a scalar it could not: starting a save on `notes.txt` while
|
||||
* `big.bin` was still streaming overwrote it, so `big.bin`'s button went live
|
||||
* again mid-transfer; and whichever save finished first cleared the flag for
|
||||
* both. Dismissing the second dialog was enough to do it.
|
||||
*
|
||||
* Paths are unique within a listing, so a path is a usable key — `FilesTab`
|
||||
* relies on the same fact for its row keys.
|
||||
*/
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [savingPaths, setSavingPaths] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
const currentPathRef = useRef(currentPath);
|
||||
|
||||
@@ -154,6 +177,86 @@ export function useFileManager(projectId: string) {
|
||||
[projectId, navigate, report],
|
||||
);
|
||||
|
||||
/**
|
||||
* Copy host files into the directory on screen.
|
||||
*
|
||||
* The picker is opened by **Rust**, not here — `upload_files_to_container`
|
||||
* shows it, reads what the user chose and never lets a host path near IPC.
|
||||
* So this passes a directory and gets back an outcome; `null` means the user
|
||||
* dismissed the dialog, which is not a failure and says nothing.
|
||||
*
|
||||
* One dialog can select several files and they need not agree, hence two
|
||||
* lists. Every failure is reported, because "3 of 5 uploaded" without saying
|
||||
* which two is not a report. The listing is refreshed once, at the end, and
|
||||
* only if the user is still looking at the directory that was targeted.
|
||||
*/
|
||||
const uploadFiles = useCallback(async () => {
|
||||
const target = currentPathRef.current;
|
||||
setUploading(true);
|
||||
try {
|
||||
let outcome;
|
||||
try {
|
||||
outcome = await commands.uploadFilesToContainer(projectId, target);
|
||||
} catch (e) {
|
||||
// A failure *before* the picker: no container, not running, or a
|
||||
// directory this pane may not write to. One toast, not one per file.
|
||||
report("Could not upload", e);
|
||||
return;
|
||||
}
|
||||
if (!outcome) return;
|
||||
for (const failure of outcome.failures) {
|
||||
useAppState.getState().pushToast({ kind: "error", message: failure });
|
||||
}
|
||||
if (outcome.uploaded.length === 0) return;
|
||||
// The directory is named, not implied. `target` is captured at click time
|
||||
// and the picker is a modal OS dialog — the user has all the time in the
|
||||
// world to browse somewhere else while it is open, and the files land
|
||||
// where they started. "Uploaded 2 files." in front of a grid that does
|
||||
// not contain them is a worse answer than no message at all.
|
||||
const count = outcome.uploaded.length;
|
||||
setCompleted(
|
||||
`Uploaded ${count === 1 ? "1 file" : `${count} files`} to ${target}.`,
|
||||
);
|
||||
if (currentPathRef.current === target) await navigate(target);
|
||||
} finally {
|
||||
// Around the *whole* body, refresh included. Clearing it the moment the
|
||||
// command settled put the button back before the re-listing had run, so
|
||||
// a second click landed mid-refresh on a grid that was still the old one.
|
||||
setUploading(false);
|
||||
}
|
||||
}, [projectId, navigate, report]);
|
||||
|
||||
/**
|
||||
* Save one file out to the host, with Rust opening the save dialog.
|
||||
*
|
||||
* No refresh: nothing in the container changed. The save dialog is also what
|
||||
* asks about overwriting an existing host file, which is why the backend has
|
||||
* no collision handling of its own to get wrong. `null` is a dismissal.
|
||||
*/
|
||||
const saveToHost = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
setSavingPaths((live) => new Set(live).add(entry.path));
|
||||
try {
|
||||
const bytes = await commands.downloadContainerFile(projectId, entry.path);
|
||||
// `0` is a real answer — an empty file saved is a success — so this
|
||||
// tests for the dismissal sentinel, not for falsiness.
|
||||
if (bytes === null) return;
|
||||
setCompleted(`Saved "${entry.name}" (${formatBytes(bytes)}).`);
|
||||
} catch (e) {
|
||||
report(`Could not save "${entry.name}"`, e);
|
||||
} finally {
|
||||
// Remove only this one. A save that finishes while another is still
|
||||
// streaming must not re-enable the other's row.
|
||||
setSavingPaths((live) => {
|
||||
const next = new Set(live);
|
||||
next.delete(entry.path);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[projectId, report],
|
||||
);
|
||||
|
||||
return {
|
||||
currentPath,
|
||||
entries,
|
||||
@@ -168,5 +271,10 @@ export function useFileManager(projectId: string) {
|
||||
refresh,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
uploadFiles,
|
||||
saveToHost,
|
||||
/** A host transfer is in flight — see the state declarations above. */
|
||||
uploading,
|
||||
savingPaths,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { save } from "@tauri-apps/plugin-dialog";
|
||||
import type { Project } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { formatBytes } from "../lib/formatBytes";
|
||||
import { describeResetLeftovers, resetLeftoverPronoun } from "../lib/resetOutcome";
|
||||
import { useAppState } from "../store/appState";
|
||||
import { useProjects } from "./useProjects";
|
||||
import { useTerminal } from "./useTerminal";
|
||||
@@ -28,13 +29,14 @@ export function useProjectActions(project: Project) {
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async (label: string, fn: () => Promise<unknown>) => {
|
||||
async <T,>(label: string, fn: () => Promise<T>): Promise<T | undefined> => {
|
||||
setBusy(true);
|
||||
setContainerProgress(project.id, null);
|
||||
try {
|
||||
await fn();
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
fail(`${label} failed for “${project.name}”`, e);
|
||||
return undefined;
|
||||
} finally {
|
||||
setContainerProgress(project.id, null);
|
||||
setBusy(false);
|
||||
@@ -54,8 +56,25 @@ export function useProjectActions(project: Project) {
|
||||
);
|
||||
|
||||
const handleReset = useCallback(
|
||||
() => run("Reset", () => rebuild(project.id)),
|
||||
[run, rebuild, project.id],
|
||||
() =>
|
||||
run("Reset", async () => {
|
||||
const outcome = await rebuild(project.id);
|
||||
if (outcome.leftover_image || outcome.leftover_volumes.length > 0) {
|
||||
// Not "run `docker volume rm`" — by the time this renders, the new
|
||||
// container this same call just started already has the leftover
|
||||
// volume mounted, so that command would just hit the same 409
|
||||
// Reset did. Stopping the project first is what actually frees it.
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: `Reset for “${project.name}” did not fully clean up`,
|
||||
detail: `Triple-C could not remove ${describeResetLeftovers(outcome)} from before the reset, so \
|
||||
the new container may still be built from, or contain, old data. Stop the project, then try \
|
||||
Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
|
||||
});
|
||||
}
|
||||
return outcome;
|
||||
}),
|
||||
[run, rebuild, project.id, project.name, pushToast],
|
||||
);
|
||||
|
||||
const openClaudeTerminal = useCallback(async () => {
|
||||
|
||||
@@ -140,3 +140,27 @@ describe("useProjects puts the status back when a refused command never ran", ()
|
||||
expect(statusOf()).toBe("stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useProjects.rebuild on success", () => {
|
||||
it("puts the outcome's project, not the whole outcome, into the list", async () => {
|
||||
const rebuilt = project("running");
|
||||
rebuildProjectContainer.mockResolvedValue({
|
||||
project: rebuilt,
|
||||
leftover_image: null,
|
||||
leftover_volumes: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
let outcome!: Awaited<ReturnType<typeof result.current.rebuild>>;
|
||||
await act(async () => {
|
||||
outcome = await result.current.rebuild("p1");
|
||||
});
|
||||
|
||||
// A regression here would put the `{ project, leftover_image,
|
||||
// leftover_volumes }` wrapper into the projects list instead of the
|
||||
// `Project` it wraps — a shape mismatch `tsc` would not catch inside a
|
||||
// callback typed to take `unknown` per Tauri's `invoke`.
|
||||
expect(useAppState.getState().projects.find((p) => p.id === "p1")).toEqual(rebuilt);
|
||||
expect(outcome.leftover_volumes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,8 +44,9 @@ export function useProjects() {
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await commands.removeProject(id);
|
||||
const report = await commands.removeProject(id);
|
||||
removeProjectFromList(id);
|
||||
return report;
|
||||
},
|
||||
[removeProjectFromList],
|
||||
);
|
||||
@@ -135,9 +136,9 @@ export function useProjects() {
|
||||
const rebuild = useCallback(
|
||||
(id: string) =>
|
||||
withOptimisticStatus(id, "starting", async () => {
|
||||
const updated = await commands.rebuildProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
const outcome = await commands.rebuildProjectContainer(id);
|
||||
updateProjectInList(outcome.project);
|
||||
return outcome;
|
||||
}),
|
||||
[updateProjectInList, withOptimisticStatus],
|
||||
);
|
||||
|
||||
@@ -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,83 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// The queue lives at module scope in useTerminal, so the command layer is
|
||||
// mocked and the hook's `sendInput` is exercised through `renderHook`.
|
||||
const terminalInput = vi.fn<(sessionId: string, data: number[]) => Promise<void>>();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
terminalInput: (sessionId: string, data: number[]) => terminalInput(sessionId, data),
|
||||
openTerminalSession: vi.fn(),
|
||||
closeTerminalSession: vi.fn(),
|
||||
terminalResize: vi.fn(),
|
||||
pasteImageToTerminal: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() }));
|
||||
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useTerminal } from "./useTerminal";
|
||||
|
||||
const decode = (bytes: number[]) => new TextDecoder().decode(new Uint8Array(bytes));
|
||||
|
||||
describe("useTerminal input ordering", () => {
|
||||
beforeEach(() => {
|
||||
terminalInput.mockReset();
|
||||
});
|
||||
|
||||
it("preserves order even when the underlying invokes resolve out of order", async () => {
|
||||
// Make the *first* call the slowest, which is exactly the race that put a
|
||||
// backspace behind the characters typed after it.
|
||||
const resolvers: Array<() => void> = [];
|
||||
terminalInput.mockImplementation(
|
||||
() => new Promise<void>((resolve) => resolvers.push(resolve)),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTerminal());
|
||||
|
||||
const first = result.current.sendInput("s1", "\x7f"); // backspace
|
||||
const rest = ["a", "b", "c"].map((ch) => result.current.sendInput("s1", ch));
|
||||
|
||||
// Only one write may be in flight at a time.
|
||||
expect(terminalInput).toHaveBeenCalledTimes(1);
|
||||
expect(decode(terminalInput.mock.calls[0][1])).toBe("\x7f");
|
||||
|
||||
resolvers.shift()!();
|
||||
await first;
|
||||
|
||||
// The three queued keystrokes coalesce into one ordered write.
|
||||
expect(terminalInput).toHaveBeenCalledTimes(2);
|
||||
expect(decode(terminalInput.mock.calls[1][1])).toBe("abc");
|
||||
|
||||
resolvers.shift()!();
|
||||
await Promise.all(rest);
|
||||
|
||||
const sent = terminalInput.mock.calls.map((c) => decode(c[1])).join("");
|
||||
expect(sent).toBe("\x7fabc");
|
||||
});
|
||||
|
||||
it("settles each caller's promise and does not drop later writes on failure", async () => {
|
||||
terminalInput.mockRejectedValueOnce(new Error("boom")).mockResolvedValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useTerminal());
|
||||
|
||||
await expect(result.current.sendInput("s2", "x")).rejects.toThrow("boom");
|
||||
await expect(result.current.sendInput("s2", "y")).resolves.toBeUndefined();
|
||||
|
||||
expect(decode(terminalInput.mock.calls[1][1])).toBe("y");
|
||||
});
|
||||
|
||||
it("keeps separate sessions independent", async () => {
|
||||
terminalInput.mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() => useTerminal());
|
||||
|
||||
await Promise.all([
|
||||
result.current.sendInput("a", "1"),
|
||||
result.current.sendInput("b", "2"),
|
||||
]);
|
||||
|
||||
const bySession = terminalInput.mock.calls.map((c) => [c[0], decode(c[1])]);
|
||||
expect(bySession).toContainEqual(["a", "1"]);
|
||||
expect(bySession).toContainEqual(["b", "2"]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,86 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { useAppState } from "../store/appState";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
/**
|
||||
* Per-session ordered write queue.
|
||||
*
|
||||
* Every keystroke used to be its own `invoke("terminal_input")`, and because
|
||||
* that command is `async` on the Rust side Tauri spawns each one as an
|
||||
* independent task. Those tasks then race for the session mutex in
|
||||
* `ExecSessionManager::send_input`, so nothing preserved the order the bytes
|
||||
* were typed in — the visible symptom was a backspace landing *after* the
|
||||
* characters typed behind it. The serial writer task downstream cannot help,
|
||||
* because the order is already lost by the time anything reaches the channel.
|
||||
*
|
||||
* The queue restores ordering the same way the web terminal gets it for free:
|
||||
* one write in flight at a time, the next only after the previous resolves.
|
||||
* Anything typed while a write is in flight coalesces into the next chunk,
|
||||
* which also collapses a burst of typing into a couple of IPC round trips
|
||||
* rather than one per key. Concatenating the byte arrays is safe — a PTY
|
||||
* cannot tell one write of "ab" from writes of "a" then "b" — and each
|
||||
* caller's promise still settles only when its own bytes have gone, so
|
||||
* `await sendInput(...)` keeps the meaning it had.
|
||||
*
|
||||
* Module scope, not hook scope, because `useTerminal()` is called from several
|
||||
* components (App for speech-to-text, TerminalView for typing and image paste,
|
||||
* useProjectActions for tile commands). A per-hook queue would give each caller
|
||||
* its own ordering and leave them racing against each other.
|
||||
*/
|
||||
type PendingWrite = {
|
||||
bytes: number[];
|
||||
resolve: () => void;
|
||||
reject: (reason: unknown) => void;
|
||||
};
|
||||
|
||||
const inputQueues = new Map<string, { pending: PendingWrite[]; draining: boolean }>();
|
||||
|
||||
async function drainInputQueue(sessionId: string): Promise<void> {
|
||||
const q = inputQueues.get(sessionId);
|
||||
if (!q || q.draining) return;
|
||||
|
||||
q.draining = true;
|
||||
try {
|
||||
while (q.pending.length > 0) {
|
||||
// Take everything queued so far as one batch, preserving order.
|
||||
const batch = q.pending.splice(0, q.pending.length);
|
||||
const bytes = batch.flatMap((w) => w.bytes);
|
||||
try {
|
||||
await commands.terminalInput(sessionId, bytes);
|
||||
batch.forEach((w) => w.resolve());
|
||||
} catch (err) {
|
||||
// Reject only the writes in this batch. Anything queued while it was
|
||||
// in flight is still pending and gets its own attempt on the next lap.
|
||||
batch.forEach((w) => w.reject(err));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
q.draining = false;
|
||||
// Drop the entry once idle so closed sessions do not accumulate.
|
||||
if (q.pending.length === 0) inputQueues.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueInput(sessionId: string, bytes: number[]): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let q = inputQueues.get(sessionId);
|
||||
if (!q) {
|
||||
q = { pending: [], draining: false };
|
||||
inputQueues.set(sessionId, q);
|
||||
}
|
||||
q.pending.push({ bytes, resolve, reject });
|
||||
void drainInputQueue(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop any queued input for a session that is going away. */
|
||||
function discardInputQueue(sessionId: string): void {
|
||||
const q = inputQueues.get(sessionId);
|
||||
if (!q) return;
|
||||
const dropped = q.pending.splice(0, q.pending.length);
|
||||
dropped.forEach((w) => w.reject(new Error(`Session ${sessionId} closed`)));
|
||||
if (!q.draining) inputQueues.delete(sessionId);
|
||||
}
|
||||
|
||||
export function useTerminal() {
|
||||
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
||||
useAppState(
|
||||
@@ -33,6 +113,7 @@ export function useTerminal() {
|
||||
const session = currentSessions.find((s) => s.id === sessionId);
|
||||
const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
|
||||
|
||||
discardInputQueue(sessionId);
|
||||
await commands.closeTerminalSession(sessionId);
|
||||
removeSession(sessionId);
|
||||
|
||||
@@ -54,7 +135,7 @@ export function useTerminal() {
|
||||
const sendInput = useCallback(
|
||||
async (sessionId: string, data: string) => {
|
||||
const bytes = Array.from(new TextEncoder().encode(data));
|
||||
await commands.terminalInput(sessionId, bytes);
|
||||
await enqueueInput(sessionId, bytes);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* payload position inside my rect? A hidden pane is `display:none` and so
|
||||
* has a zero-size rect, which is what stops two panes both claiming the
|
||||
* same drop. `TerminalView` is the only pane that takes dropped files
|
||||
* today — the Files pane is container-side only — but the routing is what
|
||||
* keeps it honest when a second one appears.
|
||||
* today — the Files pane copies files through buttons and a backend-opened
|
||||
* dialog, not through a drop — but the routing is what keeps it honest when
|
||||
* a second one appears.
|
||||
* 2. **Should the app accept a drop at all right now?** `dropIsBlocked` —
|
||||
* document-wide, no geometry, no z-order. While a modal or a blocking
|
||||
* overlay is on screen anywhere, every drop is refused.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeResetLeftovers, resetLeftoverPronoun } from "./resetOutcome";
|
||||
import type { ProjectResetOutcome } from "./types";
|
||||
|
||||
function outcome(overrides: Partial<ProjectResetOutcome> = {}): ProjectResetOutcome {
|
||||
return {
|
||||
project: {} as ProjectResetOutcome["project"],
|
||||
leftover_image: null,
|
||||
leftover_volumes: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("describeResetLeftovers", () => {
|
||||
it("names the image first, then the volumes", () => {
|
||||
expect(describeResetLeftovers(outcome({ leftover_image: "x" }))).toBe(
|
||||
"its previous container image",
|
||||
);
|
||||
expect(describeResetLeftovers(outcome({ leftover_volumes: ["v1"] }))).toBe("a volume");
|
||||
expect(describeResetLeftovers(outcome({ leftover_volumes: ["v1", "v2"] }))).toBe("2 volumes");
|
||||
expect(
|
||||
describeResetLeftovers(outcome({ leftover_image: "x", leftover_volumes: ["v1", "v2"] })),
|
||||
).toBe("its previous container image and 2 volumes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetLeftoverPronoun", () => {
|
||||
it("is singular for exactly one leftover", () => {
|
||||
expect(resetLeftoverPronoun(outcome({ leftover_image: "x" }))).toBe("it");
|
||||
expect(resetLeftoverPronoun(outcome({ leftover_volumes: ["v1"] }))).toBe("it");
|
||||
});
|
||||
|
||||
it("is plural once more than one thing survived", () => {
|
||||
expect(resetLeftoverPronoun(outcome({ leftover_image: "x", leftover_volumes: ["v1"] }))).toBe(
|
||||
"them",
|
||||
);
|
||||
expect(resetLeftoverPronoun(outcome({ leftover_volumes: ["v1", "v2"] }))).toBe("them");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ProjectResetOutcome } from "./types";
|
||||
|
||||
/**
|
||||
* Names what a `ProjectResetOutcome` says Reset could not clear, for
|
||||
* `useProjectActions`'s Reset toast.
|
||||
*
|
||||
* The image is named first and phrased as "its previous container image"
|
||||
* rather than folded in with the volumes — it is the more serious of the
|
||||
* two: the new container is built from it whenever it exists, so a
|
||||
* surviving image means Reset silently rebuilt the exact system layer it
|
||||
* was asked to discard, while a surviving volume only means old data rides
|
||||
* along.
|
||||
*/
|
||||
export function describeResetLeftovers(outcome: ProjectResetOutcome): string {
|
||||
const parts: string[] = [];
|
||||
if (outcome.leftover_image) parts.push("its previous container image");
|
||||
if (outcome.leftover_volumes.length === 1) parts.push("a volume");
|
||||
else if (outcome.leftover_volumes.length > 1) parts.push(`${outcome.leftover_volumes.length} volumes`);
|
||||
return parts.join(" and ");
|
||||
}
|
||||
|
||||
/** How many distinct things `describeResetLeftovers` is describing — the
|
||||
* image counts as one, however many volumes are named alongside it. */
|
||||
function resetLeftoverCount(outcome: ProjectResetOutcome): number {
|
||||
return (outcome.leftover_image ? 1 : 0) + outcome.leftover_volumes.length;
|
||||
}
|
||||
|
||||
/** Pronoun agreement for referring back to `describeResetLeftovers`'s
|
||||
* output — "remove it manually" for one thing, "remove them" for more. */
|
||||
export function resetLeftoverPronoun(outcome: ProjectResetOutcome): "it" | "them" {
|
||||
return resetLeftoverCount(outcome) === 1 ? "it" : "them";
|
||||
}
|
||||
@@ -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, 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 } 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");
|
||||
@@ -13,7 +13,7 @@ export const listProjects = () => invoke<Project[]>("list_projects");
|
||||
export const addProject = (name: string, paths: ProjectPath[]) =>
|
||||
invoke<Project>("add_project", { name, paths });
|
||||
export const removeProject = (projectId: string) =>
|
||||
invoke<void>("remove_project", { projectId });
|
||||
invoke<ProjectRemovalReport>("remove_project", { projectId });
|
||||
export const updateProject = (project: Project) =>
|
||||
invoke<Project>("update_project", { project });
|
||||
export const startProjectContainer = (projectId: string) =>
|
||||
@@ -21,7 +21,7 @@ export const startProjectContainer = (projectId: string) =>
|
||||
export const stopProjectContainer = (projectId: string) =>
|
||||
invoke<void>("stop_project_container", { projectId });
|
||||
export const rebuildProjectContainer = (projectId: string) =>
|
||||
invoke<Project>("rebuild_project_container", { projectId });
|
||||
invoke<ProjectResetOutcome>("rebuild_project_container", { projectId });
|
||||
export const reconcileProjectStatuses = () =>
|
||||
invoke<Project[]>("reconcile_project_statuses");
|
||||
|
||||
@@ -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 });
|
||||
@@ -69,6 +78,25 @@ export const stopAudioBridge = (sessionId: string) =>
|
||||
// Files
|
||||
export const listContainerFiles = (projectId: string, path: string) =>
|
||||
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
||||
/**
|
||||
* Save one container file to the host.
|
||||
*
|
||||
* The **backend** opens the save dialog, so this call cannot name a place on
|
||||
* the host — that is the point (see `pick_save_path` in `file_commands.rs`).
|
||||
* Paths do come *back* inside error text; what is closed is the inbound
|
||||
* direction.
|
||||
* Resolves to the number of bytes written, or `null` if the user dismissed the
|
||||
* dialog. Zero bytes is a success: an empty file is a file.
|
||||
*/
|
||||
export const downloadContainerFile = (projectId: string, containerPath: string) =>
|
||||
invoke<number | null>("download_container_file", { projectId, containerPath });
|
||||
/**
|
||||
* Upload host files into `containerDir`, with the backend opening the file
|
||||
* picker. Resolves to `null` if the user dismissed it, otherwise to what
|
||||
* happened — one dialog can select several files and they need not all succeed.
|
||||
*/
|
||||
export const uploadFilesToContainer = (projectId: string, containerDir: string) =>
|
||||
invoke<UploadOutcome | null>("upload_files_to_container", { projectId, containerDir });
|
||||
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
|
||||
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
|
||||
export const readContainerFile = (projectId: string, path: string, maxBytes?: number) =>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isLinuxWebview, resolveTerminalGpuRendering } from "./terminalRenderer";
|
||||
|
||||
const LINUX = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 Safari/605.1.15";
|
||||
const MAC = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15";
|
||||
const WINDOWS = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36";
|
||||
const ANDROID = "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36";
|
||||
|
||||
describe("isLinuxWebview", () => {
|
||||
it("recognises desktop Linux", () => {
|
||||
expect(isLinuxWebview(LINUX)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not count Android as desktop Linux", () => {
|
||||
expect(isLinuxWebview(ANDROID)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the other desktop platforms", () => {
|
||||
expect(isLinuxWebview(MAC)).toBe(false);
|
||||
expect(isLinuxWebview(WINDOWS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTerminalGpuRendering", () => {
|
||||
it("auto is off on Linux, where WebGL falls back to software rendering", () => {
|
||||
expect(resolveTerminalGpuRendering(null, LINUX)).toBe(false);
|
||||
expect(resolveTerminalGpuRendering(undefined, LINUX)).toBe(false);
|
||||
});
|
||||
|
||||
it("auto is on elsewhere", () => {
|
||||
expect(resolveTerminalGpuRendering(null, MAC)).toBe(true);
|
||||
expect(resolveTerminalGpuRendering(null, WINDOWS)).toBe(true);
|
||||
});
|
||||
|
||||
it("an explicit setting wins on every platform", () => {
|
||||
expect(resolveTerminalGpuRendering(true, LINUX)).toBe(true);
|
||||
expect(resolveTerminalGpuRendering(false, MAC)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Decides whether the terminal loads `@xterm/addon-webgl`.
|
||||
*
|
||||
* Split out of `TerminalView` so it can be unit-tested without standing up a
|
||||
* terminal, and so the platform rule lives in exactly one place.
|
||||
*/
|
||||
|
||||
/** True when the webview is running on Linux (WebKitGTK), excluding Android. */
|
||||
export function isLinuxWebview(userAgent: string): boolean {
|
||||
return /\bLinux\b/.test(userAgent) && !/\bAndroid\b/.test(userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective WebGL setting.
|
||||
*
|
||||
* `setting` is `AppSettings.terminal_gpu_rendering`: `true`/`false` force the
|
||||
* answer, `null`/`undefined` mean auto. Auto is on everywhere except Linux —
|
||||
* there the app disables WebKitGTK's DMA-BUF renderer at startup (triple-c#34),
|
||||
* which leaves WebGL present but software-rasterised, so loading the addon is
|
||||
* slower than the canvas renderer it would otherwise have fallen back to.
|
||||
*/
|
||||
export function resolveTerminalGpuRendering(
|
||||
setting: boolean | null | undefined,
|
||||
userAgent: string,
|
||||
): boolean {
|
||||
if (typeof setting === "boolean") return setting;
|
||||
return !isLinuxWebview(userAgent);
|
||||
}
|
||||
@@ -77,6 +77,37 @@ export type ProjectStatus =
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
/** What `removeProject` could not delete. The project is removed from the
|
||||
* sidebar either way. When `retry_scheduled` is true, anything named here
|
||||
* was recorded on the host and will be retried automatically the next time
|
||||
* the app starts; when false, the record itself could not be saved and
|
||||
* nothing will retry it. `retry_scheduled` is meaningless when nothing was
|
||||
* left behind. */
|
||||
export interface ProjectRemovalReport {
|
||||
container: string | null;
|
||||
image: string | null;
|
||||
volumes: string[];
|
||||
retry_scheduled: boolean;
|
||||
}
|
||||
|
||||
/** True when a `ProjectRemovalReport` left nothing behind. Mirrors the
|
||||
* Rust-side `ProjectRemovalReport::is_clean`. */
|
||||
export function projectRemovalIsClean(report: ProjectRemovalReport): boolean {
|
||||
return !report.container && !report.image && report.volumes.length === 0;
|
||||
}
|
||||
|
||||
/** What Reset (`rebuildProjectContainer`) produced: the project as it stands
|
||||
* after restarting, and any volume Reset could not clear — which is reused
|
||||
* as-is by the new container instead of starting clean. */
|
||||
export interface ProjectResetOutcome {
|
||||
project: Project;
|
||||
/** The saved container image, if Reset could not remove it — the new
|
||||
* container is built from it whenever it exists, so this means Reset
|
||||
* silently rebuilt the system layer it was asked to discard. */
|
||||
leftover_image: string | null;
|
||||
leftover_volumes: string[];
|
||||
}
|
||||
|
||||
export type Backend =
|
||||
| "anthropic"
|
||||
| "bedrock"
|
||||
@@ -259,6 +290,52 @@ export interface AppSettings {
|
||||
stt: SttSettings;
|
||||
gateway: GatewaySettings;
|
||||
global_claude_code_settings: ClaudeCodeSettings | null;
|
||||
/** Whether the terminal loads the WebGL renderer. `null` is auto: on
|
||||
* everywhere except Linux, where the DMA-BUF workaround leaves WebGL
|
||||
* backed by software rasterisation and the addon ends up slower than the
|
||||
* canvas renderer it would otherwise fall back to. See
|
||||
* `resolveTerminalGpuRendering` in `lib/terminalRenderer.ts`. */
|
||||
terminal_gpu_rendering: boolean | 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
|
||||
@@ -365,6 +442,22 @@ export interface FileEntry {
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What one upload dialog's worth of files did — mirrors `UploadOutcome` in
|
||||
* `commands/file_commands.rs`.
|
||||
*
|
||||
* Two lists rather than a count and a flag, because one dialog can select
|
||||
* several files and they do not have to agree: a folder among the selection, or
|
||||
* a file over the size ceiling, must not cost the user the ones either side of
|
||||
* it. Each `failures` entry is already a finished sentence naming its file.
|
||||
*/
|
||||
export interface UploadOutcome {
|
||||
/** In-container paths, in the order they landed. */
|
||||
uploaded: string[];
|
||||
/** One sentence per file that did not. */
|
||||
failures: string[];
|
||||
}
|
||||
|
||||
/** A file read out of the container for the in-app viewer. */
|
||||
export interface FileContents {
|
||||
/** Base64 — a byte array would cross IPC as JSON numbers. */
|
||||
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/bin/sh
|
||||
# Register a Triple-C AppImage with the desktop, so it appears in the app
|
||||
# launcher with its icon instead of only being runnable from a file manager.
|
||||
#
|
||||
# An AppImage is a single executable file and nothing more: it ships a
|
||||
# `.desktop` entry and icons *inside* itself, but nothing on the host ever
|
||||
# reads them, because nothing installed it. This script does what a package
|
||||
# manager's install hooks would — copies the icons into the user's icon theme
|
||||
# and writes a `.desktop` entry pointing at wherever the AppImage actually
|
||||
# lives.
|
||||
#
|
||||
# ./scripts/install-appimage.sh ~/Apps/Triple-C_0.4.17_amd64.AppImage
|
||||
# ./scripts/install-appimage.sh --uninstall
|
||||
#
|
||||
# Everything goes under ~/.local/share, so there is no sudo and no root-owned
|
||||
# file to clean up later. The AppImage itself is never copied or moved — the
|
||||
# launcher entry points at the path you give here, so keep the file somewhere
|
||||
# stable (`~/Apps` or `~/.local/bin`, not `~/Downloads`) or re-run this after
|
||||
# moving it.
|
||||
#
|
||||
# Extraction uses `--appimage-extract`, which unpacks the payload directly and
|
||||
# needs no FUSE. So this script works even on a system where *running* the
|
||||
# AppImage would need `fuse2` installed first.
|
||||
|
||||
set -eu
|
||||
|
||||
APP_ID="triple-c"
|
||||
DESKTOP_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
|
||||
ICON_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor"
|
||||
DESKTOP_FILE="${DESKTOP_DIR}/${APP_ID}.desktop"
|
||||
|
||||
refresh_caches() {
|
||||
# Both are best-effort: a minimal desktop may ship neither, and neither
|
||||
# failing means the install did not work.
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database "${DESKTOP_DIR}" 2>/dev/null || true
|
||||
fi
|
||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||
gtk-update-icon-cache -f -t "${ICON_DIR}" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
uninstall() {
|
||||
rm -f "${DESKTOP_FILE}"
|
||||
find "${ICON_DIR}" -name "${APP_ID}.png" -delete 2>/dev/null || true
|
||||
refresh_caches
|
||||
echo "Removed the Triple-C launcher entry and icons."
|
||||
echo "The AppImage itself was not touched."
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--uninstall" ]; then
|
||||
uninstall
|
||||
exit 0
|
||||
fi
|
||||
|
||||
APPIMAGE="${1:-}"
|
||||
if [ -z "${APPIMAGE}" ]; then
|
||||
echo "usage: $0 [--uninstall] /path/to/Triple-C_<version>_amd64.AppImage" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ ! -f "${APPIMAGE}" ]; then
|
||||
echo "No such file: ${APPIMAGE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# An absolute path, because the .desktop Exec line is read from anywhere.
|
||||
APPIMAGE=$(cd "$(dirname "${APPIMAGE}")" && printf '%s/%s' "$(pwd)" "$(basename "${APPIMAGE}")")
|
||||
|
||||
if [ ! -x "${APPIMAGE}" ]; then
|
||||
echo "Making ${APPIMAGE} executable"
|
||||
chmod +x "${APPIMAGE}"
|
||||
fi
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
# shellcheck disable=SC2064 # WORK is expanded now on purpose.
|
||||
trap "rm -rf '${WORK}'" EXIT INT TERM
|
||||
|
||||
echo "Extracting bundled icons from $(basename "${APPIMAGE}")..."
|
||||
( cd "${WORK}" && "${APPIMAGE}" --appimage-extract >/dev/null )
|
||||
|
||||
SRC="${WORK}/squashfs-root"
|
||||
if [ ! -d "${SRC}/usr/share/icons/hicolor" ]; then
|
||||
echo "That AppImage has no bundled icons — is it really Triple-C?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy every size the bundle ships, keeping the theme's directory layout.
|
||||
COUNT=0
|
||||
while IFS= read -r icon; do
|
||||
[ -n "${icon}" ] || continue
|
||||
rel=${icon#"${SRC}/usr/share/icons/hicolor/"}
|
||||
install -Dm644 "${icon}" "${ICON_DIR}/${rel}"
|
||||
COUNT=$((COUNT + 1))
|
||||
done <<EOF
|
||||
$(find "${SRC}/usr/share/icons/hicolor" -name "${APP_ID}.png")
|
||||
EOF
|
||||
echo "Installed ${COUNT} icon size(s) into ${ICON_DIR}"
|
||||
|
||||
# Written rather than copied from the bundle. The bundled entry has
|
||||
# `Exec=triple-c`, which resolves only inside the running AppImage's own mount
|
||||
# — from the host it names a binary that is not on PATH, so the launcher entry
|
||||
# would appear and then fail to start anything. `Categories` is empty in the
|
||||
# bundle too, which leaves the entry to fall into "Other" in most menus.
|
||||
# `StartupWMClass` is kept exactly as the bundle sets it: it is what lets the
|
||||
# shell match the running window to this entry, so the taskbar shows the real
|
||||
# icon instead of a generic placeholder.
|
||||
mkdir -p "${DESKTOP_DIR}"
|
||||
cat > "${DESKTOP_FILE}" <<DESKTOP
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Triple-C
|
||||
Comment=Run Claude Code sandboxed in Docker containers
|
||||
Exec=${APPIMAGE} %U
|
||||
Icon=${APP_ID}
|
||||
Terminal=false
|
||||
Categories=Development;
|
||||
StartupWMClass=${APP_ID}
|
||||
DESKTOP
|
||||
chmod 644 "${DESKTOP_FILE}"
|
||||
echo "Wrote ${DESKTOP_FILE}"
|
||||
|
||||
refresh_caches
|
||||
|
||||
echo
|
||||
echo "Done. Triple-C should now be in your app launcher."
|
||||
echo "If the icon is generic or the entry is missing, log out and back in —"
|
||||
echo "see \"App Icon Missing After Installing (Linux)\" in HOW-TO-USE.md."
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/bin/sh
|
||||
# Refuse to let a live credential into the repository.
|
||||
#
|
||||
# Written after one got in: `the_custom_env_fingerprint_never_carries_the_value`
|
||||
# used the maintainer's real Gitea site-admin token as its fixture. It survived
|
||||
# 92 commits and fourteen days in a public mirror, past five audit rounds and two
|
||||
# independent reviews — because every one of those looked at the code under
|
||||
# change, and this sat in a test nobody had reason to open. A grep would have
|
||||
# caught it on the first day. This is that grep.
|
||||
#
|
||||
# Usage:
|
||||
# scan-secrets.sh --staged what `git commit` is about to record (the hook)
|
||||
# scan-secrets.sh --range A..B every line added between two commits (CI)
|
||||
# scan-secrets.sh --tracked every tracked file, as it stands now
|
||||
#
|
||||
# Exit 0 clean, 1 on a finding, 2 on misuse.
|
||||
#
|
||||
# ## Why it scans *added lines* and not the whole file
|
||||
#
|
||||
# The repository already contains long opaque strings — 317 literals of 32+
|
||||
# characters, almost all of them legitimate. A scanner that failed on those would
|
||||
# be turned off within a day, which is the normal way this kind of check dies.
|
||||
# Judging only what a commit *adds* keeps the signal where a person can act on it.
|
||||
#
|
||||
# ## Escape hatch
|
||||
#
|
||||
# A line carrying `pragma: allowlist secret` is skipped. Deliberately wordy: it
|
||||
# should be uncomfortable enough to type that it is read as a claim, and it
|
||||
# leaves something greppable behind.
|
||||
|
||||
set -eu
|
||||
|
||||
MODE="${1:---staged}"
|
||||
RANGE="${2:-}"
|
||||
|
||||
case "$MODE" in
|
||||
--staged) ADDED=$(git diff --cached --unified=0 --no-color -- . 2>/dev/null || true) ;;
|
||||
--range) [ -n "$RANGE" ] || { echo "scan-secrets: --range needs A..B" >&2; exit 2; }
|
||||
ADDED=$(git diff --unified=0 --no-color "$RANGE" -- . 2>/dev/null || true) ;;
|
||||
--tracked)
|
||||
# `grep -Iq .` first: without it a binary blob's bytes reach the
|
||||
# rules below, and GNU grep answers "binary file matches" instead
|
||||
# of the line — so a real finding inside one would be reported as
|
||||
# a sentence nobody can act on, and a stray NUL can end the scan
|
||||
# early. Text files only; binaries are not where source secrets
|
||||
# live, and `--staged` never sees them either (git emits
|
||||
# "Binary files differ", not content).
|
||||
ADDED=$(git ls-files -z \
|
||||
| xargs -0 -I{} sh -c 'grep -Iq . "{}" 2>/dev/null && sed "s/^/+/" "{}" 2>/dev/null' \
|
||||
|| true) ;;
|
||||
*) echo "scan-secrets: unknown mode $MODE" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# Only added lines; drop diff headers (+++ b/path) so a filename never matches.
|
||||
CANDIDATES=$(printf '%s\n' "$ADDED" \
|
||||
| grep '^+' \
|
||||
| grep -v '^+++' \
|
||||
| grep -v 'pragma: allowlist secret' \
|
||||
|| true)
|
||||
|
||||
[ -n "$CANDIDATES" ] || exit 0
|
||||
|
||||
FOUND=0
|
||||
report() {
|
||||
FOUND=1
|
||||
printf '\n %s\n' "$1"
|
||||
printf '%s\n' "$2" | sed 's/^/ /' | cut -c1-160
|
||||
}
|
||||
|
||||
# --- Rule 1: vendor-issued credentials. Shape alone identifies these, so there
|
||||
# --- is no false-positive story to tell and no identifier context needed.
|
||||
VENDOR=$(printf '%s\n' "$CANDIDATES" | grep -nE \
|
||||
'gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9]{32,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|ya29\.[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{35}|npm_[A-Za-z0-9]{36}|dckr_pat_[A-Za-z0-9_-]{20,}' \
|
||||
|| true)
|
||||
[ -n "$VENDOR" ] && report "A vendor-issued credential (its prefix identifies the provider):" "$VENDOR"
|
||||
|
||||
# --- Rule 2: private key material.
|
||||
KEYS=$(printf '%s\n' "$CANDIDATES" | grep -nE -- '-----BEGIN [A-Z ]*PRIVATE KEY-----' || true)
|
||||
[ -n "$KEYS" ] && report "Private key material:" "$KEYS"
|
||||
|
||||
# --- Rule 3: an opaque literal assigned to a secret-shaped name.
|
||||
#
|
||||
# This is the rule that would have caught the Gitea token — `let secret =
|
||||
# "<40 hex>"`. Both halves are required, and that is what keeps it usable:
|
||||
# the *name* must read as a credential, and the *whole literal* must be hex or
|
||||
# base64 with no word structure. `"aws-secret-access-key"` is a keychain key
|
||||
# name sitting right next to the word `secret`, and its hyphens are what keep it
|
||||
# out; measured against the tree, name-proximity alone flagged four lines of
|
||||
# which three were that shape.
|
||||
OPAQUE=$(printf '%s\n' "$CANDIDATES" | grep -niE \
|
||||
'(secret|token|api[_-]?key|apikey|passwo?rd|passwd|credential|auth[_-]?(key|token))[^A-Za-z0-9]{0,12}[:=][^"'"'"']{0,12}["'"'"']([0-9a-fA-F]{32,}|[A-Za-z0-9+/]{40,}={0,2})["'"'"']' \
|
||||
|| true)
|
||||
[ -n "$OPAQUE" ] && report "An opaque literal assigned to a secret-shaped name:" "$OPAQUE"
|
||||
|
||||
if [ "$FOUND" -eq 1 ]; then
|
||||
cat >&2 <<'MSG'
|
||||
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
Refusing the commit: it adds something shaped like a live credential.
|
||||
|
||||
If it IS live: do not amend and move on. Rotate it first — anything that
|
||||
reaches a branch is on the mirror, and the mirror is public.
|
||||
|
||||
If it is genuinely not a secret — a fixture, a public key id, test data —
|
||||
make the literal obviously fake ("not-a-real-token-0000…"), or append
|
||||
`pragma: allowlist secret` to the line to say so on the record.
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
MSG
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
Reference in New Issue
Block a user