Compare commits
25
Commits
1a79852f65
...
v0.4.16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -43,7 +43,18 @@ name: Build App (Preview)
|
|||||||
# prunes previous previews itself, keeping the newest few. Bundles are ~130 MB a
|
# 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.
|
# 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:
|
env:
|
||||||
GITEA_URL: ${{ gitea.server_url }}
|
GITEA_URL: ${{ gitea.server_url }}
|
||||||
@@ -70,12 +81,23 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.VERSION }}
|
version: ${{ steps.version.outputs.VERSION }}
|
||||||
sha: ${{ steps.version.outputs.SHA }}
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fetch all tags
|
||||||
|
run: git fetch --tags
|
||||||
|
|
||||||
- name: Compute preview version
|
- name: Compute preview version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
@@ -86,21 +108,60 @@ jobs:
|
|||||||
# is testing and not something to hang a tag on.
|
# is testing and not something to hang a tag on.
|
||||||
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
# The patch number is computed exactly as build-app.yml does it, so a
|
# The patch number must be the same "one past the highest patch
|
||||||
# preview is labelled with the version the release it previews would
|
# already used" build-app.yml computes for a real release — not a
|
||||||
# carry. This used to be hard-coded `.0`, which made every preview
|
# distance from the latest tag. It used to be
|
||||||
# installer claim to be x.y.0 no matter what it contained.
|
# `git rev-list --count <latest tag>..HEAD`, which build-app.yml's
|
||||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
# own history section documents as broken for exactly this reason:
|
||||||
if [ -n "$LATEST_TAG" ]; then
|
# it resets to zero on every tag cut, so previews went *backwards*
|
||||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
# (0.4.62 -> 0.4.0) the moment a release landed, and nothing stopped
|
||||||
echo "Latest matching tag: ${LATEST_TAG} (+${PATCH} commits)"
|
# 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
|
else
|
||||||
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||||
PATCH=0
|
PATCH=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
VERSION="${MAJOR_MINOR}.${PATCH}-preview.${SHORT_SHA}"
|
SUFFIX="preview.${SHORT_SHA}"
|
||||||
|
VERSION="${MAJOR_MINOR}.${PATCH}-${SUFFIX}"
|
||||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT
|
||||||
echo "Computed preview version: ${VERSION}"
|
echo "Computed preview version: ${VERSION}"
|
||||||
|
|
||||||
# One release, created once. The three build jobs run concurrently, so
|
# One release, created once. The three build jobs run concurrently, so
|
||||||
@@ -249,6 +310,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Tauri app
|
- name: Build Tauri app
|
||||||
working-directory: ./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: |
|
run: |
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
npx tauri build
|
npx tauri build
|
||||||
@@ -361,6 +429,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Tauri app (universal)
|
- name: Build Tauri app (universal)
|
||||||
working-directory: ./app
|
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: |
|
run: |
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
npx tauri build --target universal-apple-darwin
|
npx tauri build --target universal-apple-darwin
|
||||||
@@ -489,6 +560,8 @@ jobs:
|
|||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
env:
|
env:
|
||||||
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
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: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
cargo tauri build
|
cargo tauri build
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
name: Publish Arch Package
|
||||||
|
|
||||||
|
# Builds the `triple-c-bin` Arch package (packaging/arch/PKGBUILD) for a
|
||||||
|
# given release, or the latest one if none is given, and attaches the built
|
||||||
|
# .pkg.tar.zst to that release on GitHub as a downloadable asset. Manual
|
||||||
|
# dispatch only — deliberately not triggered by `release` or `push`, for the
|
||||||
|
# same reason sync-release.yml (removed in triple-c#32) never worked safely
|
||||||
|
# as an automatic trigger: this repo's releases are assembled by
|
||||||
|
# build-app.yml across three separate platform jobs, and there is no single
|
||||||
|
# automatic event that fires only once everything (including the Linux .deb
|
||||||
|
# this workflow needs) is actually uploaded. A human deciding "this release
|
||||||
|
# is ready, go package it" is the correct trigger, the same reasoning
|
||||||
|
# backfill-releases.yml already uses for its own manual-only GitHub sync.
|
||||||
|
#
|
||||||
|
# ## What this does and does not do
|
||||||
|
#
|
||||||
|
# It renders `packaging/arch/PKGBUILD` for one specific version (real
|
||||||
|
# download URL, real sha256sums — never guessed; see the resolve-asset step),
|
||||||
|
# validates it with `makepkg`/`namcap` in a real Arch container, and uploads
|
||||||
|
# the resulting `.pkg.tar.zst` to the GitHub release it was built from —
|
||||||
|
# installable by hand with `pacman -U`. It does NOT commit anything back to
|
||||||
|
# this repo — `packaging/arch/PKGBUILD` stays a hand-maintained template with
|
||||||
|
# a placeholder version, and the workflow never starts from or writes to it.
|
||||||
|
#
|
||||||
|
# ## Not published to the AUR (yet)
|
||||||
|
#
|
||||||
|
# This originally also pushed the rendered PKGBUILD to an AUR git repo, which
|
||||||
|
# needs a maintainer AUR account and its SSH key registered as a secret here
|
||||||
|
# — both manual, one-time steps neither this workflow nor anyone but a
|
||||||
|
# maintainer can do. Until that setup happens, a downloadable release asset
|
||||||
|
# gets the same package to users without it. The AUR push step is still in
|
||||||
|
# this file's git history (see the commit that added this comment) if that
|
||||||
|
# setup is ever done and it's worth reinstating.
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: >-
|
||||||
|
Release version to package, without a leading "v" (e.g. "0.4.14").
|
||||||
|
Leave empty to use the latest published GitHub release.
|
||||||
|
required: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITHUB_REPO: shadowdao/triple-c
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve version and find the Linux asset
|
||||||
|
id: resolve
|
||||||
|
env:
|
||||||
|
VERSION_INPUT: ${{ inputs.version }}
|
||||||
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Authenticated when the secret is available (it is, everywhere
|
||||||
|
# else in this repo's workflows) to avoid the unauthenticated
|
||||||
|
# 60-requests/hour-per-IP cap; still works without it, just at that
|
||||||
|
# lower limit, since this hits nothing but a public repo's public
|
||||||
|
# releases.
|
||||||
|
AUTH=()
|
||||||
|
[ -n "${GH_PAT}" ] && AUTH=(-H "Authorization: Bearer ${GH_PAT}")
|
||||||
|
|
||||||
|
if [ -z "${VERSION_INPUT}" ]; then
|
||||||
|
echo "No version given — resolving the latest GitHub release"
|
||||||
|
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest")
|
||||||
|
else
|
||||||
|
echo "Using requested version ${VERSION_INPUT}"
|
||||||
|
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${VERSION_INPUT}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
|
||||||
|
VERSION="${TAG#v}"
|
||||||
|
echo "Resolved to ${TAG}"
|
||||||
|
|
||||||
|
# Discovered from the real release, not assumed: Tauri names the
|
||||||
|
# asset after `productName` verbatim ("Triple-C"), not the
|
||||||
|
# lowercase Cargo binary name, and asset naming is exactly the kind
|
||||||
|
# of thing that silently drifts if a future Tauri upgrade changes
|
||||||
|
# bundler defaults — a hardcoded pattern here would then 404
|
||||||
|
# forever until someone noticed. `head -1` guards against a release
|
||||||
|
# somehow carrying more than one matching asset, which would
|
||||||
|
# otherwise pass the emptiness check below and then break the
|
||||||
|
# download step with two URLs on one line.
|
||||||
|
DEB_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .browser_download_url' | head -1)
|
||||||
|
DEB_NAME=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .name' | head -1)
|
||||||
|
if [ -z "$DEB_URL" ] || [ "$DEB_URL" = "null" ]; then
|
||||||
|
echo "No *_amd64.deb asset found on release ${TAG}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Found asset: ${DEB_NAME}"
|
||||||
|
|
||||||
|
# For attaching the built package to this same release later —
|
||||||
|
# every release object carries its own `upload_url` regardless of
|
||||||
|
# whether it was just created or (as here) already existed, and
|
||||||
|
# the `{?name,label}` URI-template suffix has to come off before
|
||||||
|
# this is usable as a plain URL to POST to.
|
||||||
|
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
|
||||||
|
UPLOAD_URL=$(echo "$RELEASE_JSON" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||||
|
|
||||||
|
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "deb_name=${DEB_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "upload_url=${UPLOAD_URL}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Download the release asset and compute real checksums
|
||||||
|
id: checksums
|
||||||
|
env:
|
||||||
|
DEB_URL: ${{ steps.resolve.outputs.deb_url }}
|
||||||
|
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
||||||
|
TAG: ${{ steps.resolve.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
curl -fsSL -o "${DEB_NAME}" "${DEB_URL}"
|
||||||
|
curl -fsSL -o LICENSE "https://raw.githubusercontent.com/${GITHUB_REPO}/${TAG}/LICENSE"
|
||||||
|
|
||||||
|
echo "deb_sha256=$(sha256sum "${DEB_NAME}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "license_sha256=$(sha256sum LICENSE | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Render PKGBUILD
|
||||||
|
id: render
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.resolve.outputs.version }}
|
||||||
|
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
||||||
|
DEB_SHA256: ${{ steps.checksums.outputs.deb_sha256 }}
|
||||||
|
LICENSE_SHA256: ${{ steps.checksums.outputs.license_sha256 }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p rendered
|
||||||
|
cp packaging/arch/PKGBUILD rendered/PKGBUILD
|
||||||
|
cd rendered
|
||||||
|
|
||||||
|
# Plain string replacement throughout, not sed — the source URL
|
||||||
|
# contains slashes and the repo name does too, and getting a sed
|
||||||
|
# delimiter choice AND its escaping right for that is exactly the
|
||||||
|
# kind of thing that looks correct, passes review, and breaks the
|
||||||
|
# next time someone touches it. `re.sub` with `count=1` and an
|
||||||
|
# exact `.format`-free literal match is boring and that's the
|
||||||
|
# point: every substitution below fails loudly (an assertion /
|
||||||
|
# the checks after) rather than silently no-op'ing if the
|
||||||
|
# template's shape ever drifts from what this expects.
|
||||||
|
#
|
||||||
|
# pkgrel resets to 1 for a new pkgver — a packaging-only fix to the
|
||||||
|
# same upstream version (a dependency bump, say) is what pkgrel is
|
||||||
|
# for, and this workflow always republishes the current PKGBUILD
|
||||||
|
# verbatim rather than incrementing anything, so 1 is always
|
||||||
|
# correct for what this workflow does. It is NOT correct for a
|
||||||
|
# dependency-only fix republished at the *same* pkgver: pkgrel
|
||||||
|
# would be forced back to 1, and no existing installation sees an
|
||||||
|
# upgrade. That case needs a manual pkgrel bump in the template
|
||||||
|
# before dispatching, which this workflow has no input for.
|
||||||
|
python3 - "$VERSION" "$DEB_NAME" "$DEB_SHA256" "$LICENSE_SHA256" "$GITHUB_REPO" <<'PY'
|
||||||
|
import re, sys
|
||||||
|
version, deb_name, deb_sha, license_sha, github_repo = sys.argv[1:6]
|
||||||
|
|
||||||
|
with open("PKGBUILD") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
text, n = re.subn(r"(?m)^pkgver=.*$", f"pkgver={version}", text, count=1)
|
||||||
|
assert n == 1, "pkgver=... line not found"
|
||||||
|
text, n = re.subn(r"(?m)^pkgrel=.*$", "pkgrel=1", text, count=1)
|
||||||
|
assert n == 1, "pkgrel=... line not found"
|
||||||
|
|
||||||
|
# Built with a "$" variable and plain "+" concatenation rather than
|
||||||
|
# an f-string's double-brace escape for a literal brace: writing
|
||||||
|
# this as an f-string put a dollar sign directly against two open
|
||||||
|
# braces, right here in this workflow's own YAML text — and this
|
||||||
|
# runner's own expression templating scans a run: block for that
|
||||||
|
# exact two-character opening sequence and tries to evaluate
|
||||||
|
# whatever sits inside as one of ITS OWN expressions (a step
|
||||||
|
# output, a secret, ...) before the shell ever sees this script.
|
||||||
|
# "pkgver" isn't one of those, so that lookup failed and silently
|
||||||
|
# emptied this whole step rather than raising anything here.
|
||||||
|
# Spelling the dollar sign out of a variable instead means this
|
||||||
|
# file's own text never contains that trigger sequence.
|
||||||
|
DOLLAR = "$"
|
||||||
|
old_source = (
|
||||||
|
"source=(\"Triple-C_" + DOLLAR + "{pkgver}_amd64.deb::"
|
||||||
|
+ "https://github.com/" + github_repo + "/releases/download/v" + DOLLAR + "{pkgver}/"
|
||||||
|
+ "Triple-C_" + DOLLAR + "{pkgver}_amd64.deb\""
|
||||||
|
)
|
||||||
|
new_source = (
|
||||||
|
f'source=("{deb_name}::'
|
||||||
|
f'https://github.com/{github_repo}/releases/download/v{version}/{deb_name}"'
|
||||||
|
)
|
||||||
|
assert old_source in text, "source=() line does not match the expected template shape"
|
||||||
|
text = text.replace(old_source, new_source, 1)
|
||||||
|
|
||||||
|
old_sums = "sha256sums=('SKIP'\n 'SKIP')"
|
||||||
|
assert old_sums in text, "sha256sums=() placeholders not found"
|
||||||
|
text = text.replace(old_sums, f"sha256sums=('{deb_sha}'\n '{license_sha}')", 1)
|
||||||
|
|
||||||
|
with open("PKGBUILD", "w") as f:
|
||||||
|
f.write(text)
|
||||||
|
PY
|
||||||
|
|
||||||
|
grep -q "pkgver=${VERSION}$" PKGBUILD
|
||||||
|
! grep -q "SKIP" PKGBUILD
|
||||||
|
|
||||||
|
- name: Validate with makepkg and namcap
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# A bind mount (`docker run -v "$PWD/...":/work`) is the more
|
||||||
|
# obvious way to write this, and was the first draft — but on a
|
||||||
|
# containerized Gitea act_runner job, `$PWD` is a path inside this
|
||||||
|
# job's own container, which the daemon's host cannot resolve; the
|
||||||
|
# mount would silently attach an empty directory instead of failing
|
||||||
|
# loudly. `docker cp` moves real bytes across that boundary
|
||||||
|
# regardless of where the daemon actually lives, which is what
|
||||||
|
# makes this work under both a bind-mount-capable runner and a
|
||||||
|
# containerized one.
|
||||||
|
docker pull archlinux:latest
|
||||||
|
CID=$(docker create -w /work archlinux:latest bash -c '
|
||||||
|
set -euo pipefail
|
||||||
|
pacman -Syu --noconfirm --needed base-devel namcap sudo git openssh >/dev/null
|
||||||
|
useradd -m builder
|
||||||
|
chown -R builder:builder /work
|
||||||
|
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
|
||||||
|
sudo -u builder bash -c "cd /work && makepkg --printsrcinfo > .SRCINFO"
|
||||||
|
sudo -u builder bash -c "cd /work && makepkg -s --noconfirm"
|
||||||
|
# Named once here, inside the container, rather than guessed
|
||||||
|
# from options=(!strip !debug) plus pkgver/pkgrel/arch on the
|
||||||
|
# host after the fact — makepkg is the one place that actually
|
||||||
|
# knows its own output name, and `!debug` already guarantees
|
||||||
|
# this glob can only ever match the one real package (no
|
||||||
|
# -debug split package gets produced).
|
||||||
|
basename /work/*.pkg.tar.* > /work/.pkgfile
|
||||||
|
echo "--- namcap ---"
|
||||||
|
NAMCAP_OUT=$(sudo -u builder bash -c "cd /work && namcap PKGBUILD *.pkg.tar.*" || true)
|
||||||
|
echo "$NAMCAP_OUT"
|
||||||
|
# Matches "triple-c-bin E:", "PKGBUILD (triple-c-bin) E:" and any
|
||||||
|
# split-package variant ("triple-c-bin-debug E:") alike — namcap
|
||||||
|
# uses more than one line shape for its two rule families, and
|
||||||
|
# namcap itself exits 0 regardless of what it reports, so this
|
||||||
|
# grep is the only thing standing between an E: and a green job.
|
||||||
|
if echo "$NAMCAP_OUT" | grep -q " E: "; then
|
||||||
|
echo "namcap reported an error — see above" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
')
|
||||||
|
mkdir -p rendered
|
||||||
|
docker cp rendered/. "${CID}:/work"
|
||||||
|
# `docker start -a` streams output and its exit code is the
|
||||||
|
# container's own — the same failure this would have hit with a
|
||||||
|
# bind mount still fails the job the same way.
|
||||||
|
docker start -a "${CID}"
|
||||||
|
docker cp "${CID}:/work/.SRCINFO" rendered/.SRCINFO
|
||||||
|
docker cp "${CID}:/work/.pkgfile" rendered/.pkgfile
|
||||||
|
PKG_FILE=$(cat rendered/.pkgfile)
|
||||||
|
docker cp "${CID}:/work/${PKG_FILE}" "rendered/${PKG_FILE}"
|
||||||
|
docker rm -f "${CID}" >/dev/null
|
||||||
|
|
||||||
|
echo "pkg_file=${PKG_FILE}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Attach the package to the GitHub release
|
||||||
|
env:
|
||||||
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
|
TAG: ${{ steps.resolve.outputs.tag }}
|
||||||
|
RELEASE_ID: ${{ steps.resolve.outputs.release_id }}
|
||||||
|
UPLOAD_URL: ${{ steps.resolve.outputs.upload_url }}
|
||||||
|
PKG_FILE: ${{ steps.build.outputs.pkg_file }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GH_PAT}" ]; then
|
||||||
|
echo "GH_PAT is not set — this step needs it to attach a release asset." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A manual re-dispatch for a version that's already been packaged
|
||||||
|
# would otherwise hit GitHub's 422 "already_exists" here instead
|
||||||
|
# of just replacing the stale build with this one.
|
||||||
|
EXISTING_ID=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| jq -r --arg name "$PKG_FILE" '.[] | select(.name == $name) | .id')
|
||||||
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
|
echo "Replacing the existing ${PKG_FILE} (asset id ${EXISTING_ID}) already on ${TAG}"
|
||||||
|
curl -fsS -X DELETE -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
|
||||||
|
"https://api.github.com/repos/${GITHUB_REPO}/releases/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: Bearer ${GH_PAT}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@rendered/${PKG_FILE}" \
|
||||||
|
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${PKG_FILE}")" \
|
||||||
|
> /dev/null
|
||||||
|
|
||||||
|
echo "Attached ${PKG_FILE} to ${TAG}"
|
||||||
@@ -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."
|
|
||||||
@@ -552,6 +552,131 @@ survived 92 commits and fourteen days in the public GitHub mirror, past five aud
|
|||||||
independent reviews, because every one of them read the code under change and this sat in a test
|
independent reviews, because every one of them read the code under change and this sat in a test
|
||||||
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
|
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
|
||||||
|
|
||||||
|
## Settings export/import
|
||||||
|
|
||||||
|
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
|
||||||
|
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that
|
||||||
|
live in the OS keychain instead: the shared Claude Code OAuth login and the model gateway's two
|
||||||
|
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are
|
||||||
|
deliberately out of scope — this is not a project backup.
|
||||||
|
|
||||||
|
- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
|
||||||
|
caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
|
||||||
|
credential for a server that binds every interface — exporting `AppSettings` wholesale would
|
||||||
|
have carried it along as if it were as inert as a port number, and importing it would have
|
||||||
|
applied `web_terminal.enabled` and the token together with no more warning than any other
|
||||||
|
setting, letting a crafted export silently stand up a LAN-listening terminal on the next launch.
|
||||||
|
`export_settings`/`apply_settings_import` carve this one field out into `ExportedSecrets`
|
||||||
|
instead, with the same "only overwrite what the import actually has" treatment as the other
|
||||||
|
three secrets — except "leave it alone" has to be done by hand in `apply_settings_import`, since
|
||||||
|
unlike the keychain secrets this one lives inside the `AppSettings` blob that gets replaced
|
||||||
|
wholesale. `SettingsImportPreview::enables_web_terminal` also exists because of this: `enabled`
|
||||||
|
and the token are independent fields, and "this turns on a listening service" must not hide
|
||||||
|
inside a generic "settings replaced" summary. Read this as the standing example of the class of
|
||||||
|
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
|
||||||
|
like config but is actually a live credential would have the same problem.
|
||||||
|
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
|
||||||
|
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
|
||||||
|
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
|
||||||
|
encryption. A wrong password fails GCM's authentication tag rather than producing silent
|
||||||
|
garbage. The salt and nonce are not secret and are written in the clear in the file's own
|
||||||
|
header — the salt's job is only to make two exports of the same password derive different keys,
|
||||||
|
and the nonce's only requirement is per-encryption uniqueness, which a fresh random draw on
|
||||||
|
every export already gives it.
|
||||||
|
- **The save/open dialogs are opened from Rust**, the same boundary `file_commands.rs`'s
|
||||||
|
`pick_save_path`/`pick_files_to_upload` draw and document at length: a frontend-driven dialog
|
||||||
|
handing Rust a host path string is the exact shape of bug that produced this app's past
|
||||||
|
criticals. `preview_settings_import` resolves the chosen path itself and remembers it
|
||||||
|
(`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without
|
||||||
|
a path ever crossing back over IPC. It also pins a hash of the file's ciphertext next to that
|
||||||
|
path, and `apply_settings_import` refuses to proceed if the file on disk no longer matches it —
|
||||||
|
otherwise confirming a preview would not actually be binding on what gets applied, which matters
|
||||||
|
given this feature's own threat model: a file shared between people may sit in a synced or
|
||||||
|
otherwise shared directory that changes between the two calls.
|
||||||
|
- **The decrypted payload is not cached between preview and apply — only the password is reused.**
|
||||||
|
The frontend holds the password in React state and passes it to both calls; nothing in Rust
|
||||||
|
holds decrypted plaintext — secrets included — in memory for longer than one command's
|
||||||
|
execution, so `apply_settings_import` always re-decrypts rather than reusing anything
|
||||||
|
`preview_settings_import` computed. `preview_settings_import` returns counts and presence flags
|
||||||
|
only (`SettingsImportPreview`), never a secret value, so it's safe to hand to the frontend and
|
||||||
|
render directly.
|
||||||
|
- **Import replaces settings wholesale, but only writes secrets actually present in the file.**
|
||||||
|
An import is "restore this environment," so the settings half is a full replace, not a
|
||||||
|
field-by-field merge. Secrets are different on purpose: an absent secret in the export means
|
||||||
|
"the source machine never had this configured," not "delete this on import" — a user who wants
|
||||||
|
to clear a secret already has dedicated UI for that (signing out of shared auth, clearing the
|
||||||
|
gateway key). Secrets are restored *before* the settings replace runs, not after — replacing
|
||||||
|
settings is what triggers `reconcile_gateway`, and restoring the other way round leaves a real
|
||||||
|
window where a gateway recreation happens against the destination's old keys.
|
||||||
|
- **A restored gateway secret nudges a running gateway container to recreate itself, even when
|
||||||
|
nothing about the gateway's *shape* changed.** `reconcile_gateway`'s `gateway_shape_changed` only
|
||||||
|
compares port/provider/base URL/models — deliberately, since that's what's rendered into the
|
||||||
|
container's config — so a secret-only change (same shape, new key) is invisible to it. Left
|
||||||
|
alone, a running container would keep serving the old key material indefinitely after an import
|
||||||
|
that restored a new one. `apply_settings_import` tracks whether either gateway secret was
|
||||||
|
actually written and, if the gateway is enabled and its container both exists and is running,
|
||||||
|
calls `docker::gateway::ensure_gateway_running` directly afterward — its own fingerprint already
|
||||||
|
includes the secret rotation id (`storage::secure::get_gateway_secret_version`), so it recreates
|
||||||
|
exactly when it should and no more.
|
||||||
|
- **A keychain write failing during import is reported back, not only logged.** Each of the three
|
||||||
|
`secure::store_*` calls collects its error into `SettingsImportOutcome::secret_restore_warnings`
|
||||||
|
in addition to logging it — an import that silently restores two of three secrets but not the
|
||||||
|
third must not read as unqualified success just because the settings half of the import (which
|
||||||
|
runs after, and is validated before any of this) went through. `apply_settings_import` returns
|
||||||
|
`SettingsImportOutcome { settings, secret_restore_warnings }` rather than bare `AppSettings` for
|
||||||
|
this reason; `ImportSettingsModal` shows any warnings alongside the "Settings imported" message.
|
||||||
|
- **The imported settings are validated *before* any secret is written, not just before the
|
||||||
|
settings replace.** `apply_settings_import` calls
|
||||||
|
`settings_commands::validate_settings_update(¤t, &settings)` — the same checks
|
||||||
|
`update_settings` runs internally, pulled out into its own function specifically so this caller
|
||||||
|
can run them first — and only proceeds to the three keychain writes if that passes. A review
|
||||||
|
caught the earlier ordering: writing secrets first meant a rejected import (a bad env var name, a
|
||||||
|
disallowed host path) still left the keychain overwritten with the file's secrets while the
|
||||||
|
settings themselves stayed unchanged, a silently half-applied state the error message gave no
|
||||||
|
hint of.
|
||||||
|
- **`read_and_decrypt` checks `format_version` before attempting to parse the full payload, not
|
||||||
|
after.** A version bump that isn't deserialize-compatible is exactly the case that check exists
|
||||||
|
for, and parsing the full struct first would fail on the shape mismatch before the version check
|
||||||
|
ever ran. Neither error path interpolates what `serde_json` actually says into the message
|
||||||
|
shown to the user — its type-mismatch errors quote the offending value inline, and the plaintext
|
||||||
|
here can hold a live credential.
|
||||||
|
- **The 8-character password minimum is enforced in `export_settings` itself, not only in the
|
||||||
|
export modal.** The frontend minimum is a UX nudge; the Rust command is the actual boundary a
|
||||||
|
weak password has to cross, and Argon2id's memory-hardness buys little against an attacker who
|
||||||
|
can just try a short password directly. Measured with `.chars().count()` (Unicode scalar values)
|
||||||
|
rather than `.len()` (bytes), to stay as close as this pair of languages allows to the frontend's
|
||||||
|
`.length` check (UTF-16 code units) — the two only diverge on astral-plane characters. The
|
||||||
|
derived key and both plaintext buffers — the payload built for export, and whatever `decrypt`
|
||||||
|
recovers on import — are wrapped in `zeroize::Zeroizing` for the same reason every other secret
|
||||||
|
in this codebase gets handled carefully — cheap insurance (`zeroize` is already pulled in
|
||||||
|
transitively via `aes-gcm`) for material that exists only to hold or produce live credentials.
|
||||||
|
- **The preview also discloses non-blank custom base URLs** (`global_ollama`, `global_llamacpp`,
|
||||||
|
`global_openai_compatible`, `gateway.api_base`) so an import that would redirect model traffic to
|
||||||
|
a different server is visible in the confirmation dialog rather than discovered later — these are
|
||||||
|
endpoints, not secrets, so `SettingsImportPreview` carries and `describeImport` renders the actual
|
||||||
|
URL rather than just a presence flag. `describeImportWarnings` additionally calls out a web
|
||||||
|
terminal token that arrives with the terminal left *off*: `start_web_terminal` only mints a fresh
|
||||||
|
token when none is already set, so a planted token would otherwise activate silently the next
|
||||||
|
time someone turns the terminal on, with no import-time signal that it wasn't freshly generated.
|
||||||
|
- **The preview also discloses a custom Docker image, and warns on one every time — not just on
|
||||||
|
change.** `custom_image_name`/`image_source` weren't in scope for the base-URL disclosure above,
|
||||||
|
but a review pointed out they're a sharper version of the same problem: this is the image *every*
|
||||||
|
project container is created from (`models::container_config::resolve_image_name`), so a crafted
|
||||||
|
export pointing it at an attacker-controlled image is a path to running arbitrary code with
|
||||||
|
whatever a project's containers are allowed to reach, not merely a redirected API endpoint.
|
||||||
|
`describeImportWarnings` fires on `image_source == Custom` unconditionally rather than only when
|
||||||
|
it differs from the destination's current value, since re-importing the same risky configuration
|
||||||
|
is still worth surfacing every time a user confirms an import.
|
||||||
|
- **Every free-form string a preview surfaces is sanitized and length-capped before it's built.**
|
||||||
|
`SettingsImportPreview::from_payload`'s `sanitize_for_preview` strips control characters and caps
|
||||||
|
at 100 characters (`MAX_PREVIEW_STRING_LEN`) for every base URL and the custom image name — a
|
||||||
|
review noted that, unlike the count- and boolean-derived fields the preview started with, these
|
||||||
|
are verbatim strings from a not-yet-trusted decrypted payload rendered directly into the
|
||||||
|
confirmation dialog. Unbounded, a single pathological value (very long, or holding embedded
|
||||||
|
newlines) could push the security warnings above the scroll fold in the dialog that exists
|
||||||
|
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
|
||||||
|
as a second layer against the same failure mode.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||||
|
|||||||
+8
-3
@@ -412,13 +412,18 @@ triple-c/
|
|||||||
│
|
│
|
||||||
├── .gitea/
|
├── .gitea/
|
||||||
│ └── workflows/
|
│ └── 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-app-preview.yml # Preview builds
|
||||||
│ ├── build.yml # Build container image (multi-arch)
|
│ ├── build.yml # Build container image (multi-arch)
|
||||||
│ ├── build-stt.yml # Build the STT image
|
│ ├── build-stt.yml # Build the STT image
|
||||||
│ ├── sync-release.yml # Mirror releases to GitHub
|
|
||||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||||
│ └── cleanup-releases.yml # Prune old releases
|
│ ├── cleanup-releases.yml # Prune old releases
|
||||||
|
│ └── publish-arch-package.yml # Build triple-c-bin, attach it to the GitHub release (packaging/arch/)
|
||||||
|
│
|
||||||
|
├── packaging/
|
||||||
|
│ └── arch/ # triple-c-bin Arch package — see packaging/arch/README.md
|
||||||
|
│ ├── PKGBUILD
|
||||||
|
│ └── README.md
|
||||||
│
|
│
|
||||||
└── app/ # Tauri v2 desktop application
|
└── app/ # Tauri v2 desktop application
|
||||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||||
|
|||||||
Generated
+144
@@ -8,6 +8,41 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aead"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes-gcm"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"aes",
|
||||||
|
"cipher",
|
||||||
|
"ctr",
|
||||||
|
"ghash",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -47,6 +82,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -280,6 +327,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -310,6 +363,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -569,6 +631,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -753,6 +826,15 @@ version = "0.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.20.11"
|
version = "0.20.11"
|
||||||
@@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1550,6 +1633,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ghash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||||
|
dependencies = [
|
||||||
|
"opaque-debug",
|
||||||
|
"polyval",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gio"
|
name = "gio"
|
||||||
version = "0.18.4"
|
version = "0.18.4"
|
||||||
@@ -2114,6 +2207,15 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -2831,6 +2933,12 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.3.3"
|
version = "5.3.3"
|
||||||
@@ -2913,6 +3021,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -3194,6 +3313,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polyval"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"opaque-debug",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -5149,6 +5280,8 @@ dependencies = [
|
|||||||
name = "triple-c"
|
name = "triple-c"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bollard",
|
"bollard",
|
||||||
@@ -5174,6 +5307,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5287,6 +5421,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "universal-hash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ tower-http = { version = "0.6", features = ["cors"] }
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
local-ip-address = "0.6"
|
local-ip-address = "0.6"
|
||||||
|
argon2 = "0.5"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
zeroize = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub mod install_helper_commands;
|
|||||||
pub mod migration_commands;
|
pub mod migration_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
|
pub mod settings_export_commands;
|
||||||
pub mod stt_commands;
|
pub mod stt_commands;
|
||||||
pub mod terminal_commands;
|
pub mod terminal_commands;
|
||||||
pub mod update_commands;
|
pub mod update_commands;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use tauri::{Emitter, State};
|
|||||||
|
|
||||||
use crate::commands::aws_commands;
|
use crate::commands::aws_commands;
|
||||||
use crate::docker;
|
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::storage::secure;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -696,7 +696,7 @@ pub async fn add_project(
|
|||||||
pub async fn remove_project(
|
pub async fn remove_project(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<ProjectRemovalReport, String> {
|
||||||
// **H-2: the only writer of these three categories that held nothing.**
|
// **H-2: the only writer of these three categories that held nothing.**
|
||||||
// This purges migration artifacts, removes `triple-c-snapshot-{id}` and
|
// This purges migration artifacts, removes `triple-c-snapshot-{id}` and
|
||||||
// both named volumes — and a compaction resolves that same tag when its
|
// 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.
|
// holding an entire snapshot image that nothing will ever reference again.
|
||||||
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
crate::commands::migration_commands::purge_migration_artifacts(&project_id).await;
|
||||||
|
|
||||||
// Stop and remove container if it exists
|
// Stop and remove container if it exists. Everything named in `report`
|
||||||
if let Some(ref project) = state.projects_store.get(&project_id) {
|
// below is what will be unreachable the moment this function drops the
|
||||||
if let Some(ref container_id) = project.container_id {
|
// 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;
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||||
|
}
|
||||||
let _ = docker::stop_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
|
// 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
|
// Clean up the snapshot image + volumes
|
||||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
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 {
|
report.volumes = docker::remove_project_volumes(project).await;
|
||||||
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up keychain secrets for this project
|
// 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);
|
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]
|
#[tauri::command]
|
||||||
@@ -1226,7 +1498,7 @@ pub async fn rebuild_project_container(
|
|||||||
project_id: String,
|
project_id: String,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Project, String> {
|
) -> Result<ProjectResetOutcome, String> {
|
||||||
// Reset deletes both volumes and the snapshot image. Doing that while a
|
// 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
|
// 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
|
// 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.
|
// `start_project_container` below re-arms it against the new one.
|
||||||
state.auth_bridge.stop(&project_id).await;
|
state.auth_bridge.stop(&project_id).await;
|
||||||
|
|
||||||
// Remove existing container
|
// Remove existing container. Resolved via `find_existing_container`
|
||||||
if let Some(ref container_id) = project.container_id {
|
// 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;
|
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||||
|
}
|
||||||
let _ = docker::stop_container(container_id).await;
|
let _ = docker::stop_container(container_id).await;
|
||||||
docker::remove_container(container_id).await?;
|
docker::remove_container(container_id).await?;
|
||||||
state.projects_store.set_container_id(&project_id, None)?;
|
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 {
|
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
||||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
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 {
|
let leftover_volumes = docker::remove_project_volumes(&project).await;
|
||||||
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
|
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
|
// Start fresh. The locked variant, because `_guard` above is this project's
|
||||||
// claim and the public command would be refused by it.
|
// 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.
|
/// Reconcile project statuses against actual Docker container state.
|
||||||
@@ -1379,6 +1684,54 @@ fn default_docker_socket() -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn path(host: &str, mount: &str) -> ProjectPath {
|
||||||
ProjectPath {
|
ProjectPath {
|
||||||
host_path: host.to_string(),
|
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())
|
Ok(state.settings_store.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
/// Everything `update_settings` refuses a save over, run against the store's
|
||||||
pub async fn update_settings(
|
/// *current* value and the incoming one.
|
||||||
settings: AppSettings,
|
///
|
||||||
state: State<'_, AppState>,
|
/// Pulled out so a caller that does other, harder-to-undo work alongside a
|
||||||
) -> Result<AppSettings, String> {
|
/// settings save — `settings_export_commands::apply_settings_import`
|
||||||
let before = state.settings_store.get();
|
/// restores three keychain secrets in the same command — can run this
|
||||||
|
/// *first* and bail before touching anything, rather than discovering the
|
||||||
|
/// rejection only when `update_settings` itself runs partway through.
|
||||||
|
pub fn validate_settings_update(
|
||||||
|
before: &AppSettings,
|
||||||
|
incoming: &AppSettings,
|
||||||
|
) -> Result<(), String> {
|
||||||
// The global half of the same rule the project half gets in
|
// The global half of the same rule the project half gets in
|
||||||
// `update_project`: a global custom env var is merged into every project's
|
// `update_project`: a global custom env var is merged into every project's
|
||||||
// container environment, so an unchecked name here reaches all of them.
|
// container environment, so an unchecked name here reaches all of them.
|
||||||
crate::models::validate_env_vars_update(
|
crate::models::validate_env_vars_update(
|
||||||
&before.global_custom_env_vars,
|
&before.global_custom_env_vars,
|
||||||
&settings.global_custom_env_vars,
|
&incoming.global_custom_env_vars,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// The same for the two host paths this struct owns. `update_project`
|
// The same for the two host paths this struct owns. `update_project`
|
||||||
@@ -40,14 +45,37 @@ pub async fn update_settings(
|
|||||||
crate::commands::project_commands::validate_mounted_host_path(
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
"SSH key path",
|
"SSH key path",
|
||||||
before.default_ssh_key_path.as_deref(),
|
before.default_ssh_key_path.as_deref(),
|
||||||
settings.default_ssh_key_path.as_deref(),
|
incoming.default_ssh_key_path.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
crate::commands::project_commands::validate_mounted_host_path(
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
"CA certificate path",
|
"CA certificate path",
|
||||||
before.ca_cert_path.as_deref(),
|
before.ca_cert_path.as_deref(),
|
||||||
settings.ca_cert_path.as_deref(),
|
incoming.ca_cert_path.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Third host path this struct owns, same reasoning: any project with
|
||||||
|
// `allow_docker_access` bind-mounts this path in as the Docker socket
|
||||||
|
// (`project_commands.rs`'s container creation), so an unchecked value
|
||||||
|
// here is a read-write bind mount of whatever it names into every such
|
||||||
|
// project's container.
|
||||||
|
crate::commands::project_commands::validate_mounted_host_path(
|
||||||
|
"Docker socket path",
|
||||||
|
before.docker_socket_path.as_deref(),
|
||||||
|
incoming.docker_socket_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_settings(
|
||||||
|
settings: AppSettings,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<AppSettings, String> {
|
||||||
|
let before = state.settings_store.get();
|
||||||
|
|
||||||
|
validate_settings_update(&before, &settings)?;
|
||||||
|
|
||||||
let saved = state.settings_store.update(settings)?;
|
let saved = state.settings_store.update(settings)?;
|
||||||
|
|
||||||
// Persisting a setting is not the same as applying it. The gateway is the
|
// Persisting a setting is not the same as applying it. The gateway is the
|
||||||
@@ -122,7 +150,10 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
|||||||
GatewayAction::StopIfRunning => {
|
GatewayAction::StopIfRunning => {
|
||||||
log::info!("Model gateway disabled in settings — stopping the container");
|
log::info!("Model gateway disabled in settings — stopping the container");
|
||||||
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
||||||
log::error!("Failed to stop the model gateway after it was disabled: {}", e);
|
log::error!(
|
||||||
|
"Failed to stop the model gateway after it was disabled: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GatewayAction::RestartIfRunning => {
|
GatewayAction::RestartIfRunning => {
|
||||||
@@ -138,10 +169,7 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn pull_image(
|
pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
image_name: String,
|
|
||||||
app_handle: tauri::AppHandle,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
docker::pull_image(&image_name, move |msg| {
|
docker::pull_image(&image_name, move |msg| {
|
||||||
let _ = app_handle.emit("image-pull-progress", msg);
|
let _ = app_handle.emit("image-pull-progress", msg);
|
||||||
@@ -334,7 +362,10 @@ mod tests {
|
|||||||
let before = enabled_gateway();
|
let before = enabled_gateway();
|
||||||
let mut after = before.clone();
|
let mut after = before.clone();
|
||||||
after.enabled = false;
|
after.enabled = false;
|
||||||
assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
|
assert_eq!(
|
||||||
|
gateway_action(&before, &after),
|
||||||
|
GatewayAction::StopIfRunning
|
||||||
|
);
|
||||||
// Still true when it was already off — a stray running container is
|
// Still true when it was already off — a stray running container is
|
||||||
// still a container that shouldn't be up.
|
// still a container that shouldn't be up.
|
||||||
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
||||||
|
|||||||
@@ -0,0 +1,654 @@
|
|||||||
|
//! Settings export/import — see triple-c#35.
|
||||||
|
//!
|
||||||
|
//! Exports the *host* environment (global `AppSettings` plus the global
|
||||||
|
//! secrets kept in the OS keychain: the shared Claude Code OAuth login and
|
||||||
|
//! the model gateway's two keys), encrypted with a user-chosen password —
|
||||||
|
//! see `storage::settings_crypto` for the actual cryptography. Deliberately
|
||||||
|
//! out of scope: per-project settings, per-project secrets, and anything
|
||||||
|
//! living in a project's Docker volumes.
|
||||||
|
//!
|
||||||
|
//! **The save/open dialogs are opened from Rust**, the same pattern
|
||||||
|
//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already
|
||||||
|
//! establish and document at length: a frontend-driven dialog handing Rust a
|
||||||
|
//! host path string is the exact shape of bug that produced this app's past
|
||||||
|
//! criticals, so the boundary here is drawn the same place. The frontend can
|
||||||
|
//! ask for a picker; it cannot name a host path as an *input*. `preview_
|
||||||
|
//! settings_import` resolves the chosen path itself and remembers it
|
||||||
|
//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads
|
||||||
|
//! the same file without the path ever crossing back over IPC.
|
||||||
|
//!
|
||||||
|
//! The *decrypted payload* is not cached between preview and apply — the
|
||||||
|
//! password the frontend passes to each call is what it already held for
|
||||||
|
//! the first, not a fresh secret extracted from the user, but nothing here
|
||||||
|
//! keeps the plaintext itself — export/import secrets included — around for
|
||||||
|
//! longer than one command's execution; `apply_settings_import` re-decrypts
|
||||||
|
//! the file rather than reusing anything `preview_settings_import` computed.
|
||||||
|
//!
|
||||||
|
//! **This is new attack surface**: a settings export is a file one person
|
||||||
|
//! can hand another and ask them to import, together with a password, and
|
||||||
|
//! `apply_settings_import` applies whatever `AppSettings` it decrypts to
|
||||||
|
//! wholesale — see the module doc on `models::settings_export` for the
|
||||||
|
//! `web_terminal.access_token` carve-out a review of this feature found,
|
||||||
|
//! and treat that as the standing example of the class of thing to keep
|
||||||
|
//! checking for here, not a one-off fixed bug.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::models::{
|
||||||
|
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
|
||||||
|
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
};
|
||||||
|
use crate::storage::{secure, settings_crypto};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// What `preview_settings_import` pins so `apply_settings_import` can tell
|
||||||
|
/// whether the file it's about to re-read is the same one the user actually
|
||||||
|
/// saw a preview of. Confirming a preview is only meaningful if it's binding
|
||||||
|
/// on what gets applied — without this, a file replaced on disk between the
|
||||||
|
/// two calls (this app's own stated threat model is a file shared between
|
||||||
|
/// people, which may sit in a synced or shared directory) would decrypt and
|
||||||
|
/// apply silently different content than what the confirmation dialog showed.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PendingSettingsImport {
|
||||||
|
path: PathBuf,
|
||||||
|
ciphertext_hash: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_ciphertext(data: &[u8]) -> [u8; 32] {
|
||||||
|
Sha256::digest(data).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_EXTENSION: &str = "triplec";
|
||||||
|
|
||||||
|
/// Enforced here, not only in the export modal: the frontend's minimum is a
|
||||||
|
/// UX nudge, but `export_settings` is the actual boundary a weak password
|
||||||
|
/// has to cross, and Argon2id's memory-hardness buys little against an
|
||||||
|
/// attacker who can just try a three-character password directly.
|
||||||
|
const MIN_PASSWORD_LEN: usize = 8;
|
||||||
|
|
||||||
|
fn suggested_export_name() -> String {
|
||||||
|
// Timestamped so exporting more than once doesn't silently overwrite an
|
||||||
|
// earlier file just because the save dialog defaults to the same name.
|
||||||
|
format!(
|
||||||
|
"triple-c-settings-{}.{}",
|
||||||
|
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||||
|
FILE_EXTENSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
window
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_parent(window)
|
||||||
|
.set_title("Export Triple-C settings")
|
||||||
|
.set_file_name(suggested)
|
||||||
|
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||||
|
.save_file(move |picked| {
|
||||||
|
let _ = tx.send(picked);
|
||||||
|
});
|
||||||
|
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pick_import_open_path(window: &tauri::Window) -> Option<PathBuf> {
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
window
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_parent(window)
|
||||||
|
.set_title("Import Triple-C settings")
|
||||||
|
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||||
|
.pick_file(move |picked| {
|
||||||
|
let _ = tx.send(picked);
|
||||||
|
});
|
||||||
|
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gather the current global secrets, and hand back the `AppSettings` to
|
||||||
|
/// export with the web-terminal token blanked out of it — see the module
|
||||||
|
/// doc comment on `models::settings_export` for why that field cannot
|
||||||
|
/// travel through `settings` like the rest of this struct.
|
||||||
|
///
|
||||||
|
/// A missing keychain secret reads as `None` — a keychain read failure is
|
||||||
|
/// treated as "nothing to export" for that one entry rather than aborting
|
||||||
|
/// the whole export, matching how the rest of this app degrades a keychain
|
||||||
|
/// error to "absent" (`has_claude_oauth_token`, `has_gateway_api_key`)
|
||||||
|
/// rather than surfacing it as a hard failure.
|
||||||
|
fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSecrets) {
|
||||||
|
let mut settings = current;
|
||||||
|
let web_terminal_access_token = settings.web_terminal.access_token.take();
|
||||||
|
|
||||||
|
let secrets = ExportedSecrets {
|
||||||
|
claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(),
|
||||||
|
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
|
||||||
|
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
|
||||||
|
web_terminal_access_token,
|
||||||
|
};
|
||||||
|
|
||||||
|
(settings, secrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the current global settings and secrets to a password-encrypted
|
||||||
|
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
|
||||||
|
/// deliberately distinguishable from one so the frontend shows nothing
|
||||||
|
/// rather than a "failed" toast for a plain cancel.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_settings(
|
||||||
|
password: String,
|
||||||
|
window: tauri::Window,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
// `.chars().count()` — Unicode scalar values, not bytes — to stay as
|
||||||
|
// close as this pair of languages allows to the frontend's `.length`
|
||||||
|
// check (UTF-16 code units); the two only diverge on astral-plane
|
||||||
|
// characters, which no reasonable password touches.
|
||||||
|
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||||
|
return Err(format!(
|
||||||
|
"Use a password of at least {} characters.",
|
||||||
|
MIN_PASSWORD_LEN
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (settings, secrets) = split_settings_and_secrets(state.settings_store.get());
|
||||||
|
if secrets.is_empty() {
|
||||||
|
log::info!("Exporting settings with no global secrets configured on this machine");
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = SettingsExportPayload {
|
||||||
|
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
settings,
|
||||||
|
secrets,
|
||||||
|
};
|
||||||
|
|
||||||
|
let plaintext = Zeroizing::new(
|
||||||
|
serde_json::to_vec(&payload)
|
||||||
|
.map_err(|e| format!("Failed to prepare settings for export: {}", e))?,
|
||||||
|
);
|
||||||
|
let encrypted = settings_crypto::encrypt(&plaintext, &password)?;
|
||||||
|
|
||||||
|
std::fs::write(&dest, &encrypted).map_err(|e| format!("Failed to write export file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a file picker, decrypt the chosen file with `password`, and return a
|
||||||
|
/// preview (counts and presence flags only — never a secret value) for a
|
||||||
|
/// confirmation UI. `Ok(None)` means the picker was dismissed.
|
||||||
|
///
|
||||||
|
/// Remembers the resolved path *and a hash of the file's ciphertext* in
|
||||||
|
/// `AppState::pending_settings_import` for `apply_settings_import` to check
|
||||||
|
/// against — does **not** remember the decrypted payload itself, so the
|
||||||
|
/// password must be supplied again to actually apply it — seeing the preview
|
||||||
|
/// is not the same as committing to it. The hash exists so it also can't be
|
||||||
|
/// swapped out from under that commitment: `apply_settings_import` refuses to
|
||||||
|
/// proceed if the file on disk no longer matches what was just previewed.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn preview_settings_import(
|
||||||
|
password: String,
|
||||||
|
window: tauri::Window,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Option<SettingsImportPreview>, String> {
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err("A password is required to open a settings export.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(path) = pick_import_open_path(&window).await else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||||
|
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
|
||||||
|
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
|
||||||
|
path,
|
||||||
|
ciphertext_hash: hash_ciphertext(&encrypted),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Some(preview))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the import a prior `preview_settings_import` call resolved a path
|
||||||
|
/// for. Fails if no preview is pending — this is not a general "decrypt and
|
||||||
|
/// apply this file" entry point, deliberately: seeing the preview first is
|
||||||
|
/// required, not just encouraged, since it is the only place a user is told
|
||||||
|
/// what an import is about to touch before it touches it. That requirement
|
||||||
|
/// is only real if the file can't change out from under it, so this also
|
||||||
|
/// refuses to proceed if the file's ciphertext no longer matches the hash
|
||||||
|
/// `preview_settings_import` pinned — a file replaced on disk between the
|
||||||
|
/// two calls (this feature's own threat model is a file shared between
|
||||||
|
/// people, which may sit in a synced or shared directory) must not be able
|
||||||
|
/// to apply silently different content than what the confirmation dialog
|
||||||
|
/// showed.
|
||||||
|
///
|
||||||
|
/// Global settings are replaced wholesale — an import is "restore this
|
||||||
|
/// environment," not a field-by-field merge. Global secrets are handled
|
||||||
|
/// differently and on purpose: **only secrets actually present in the
|
||||||
|
/// import are written**; a secret the export doesn't have is left alone on
|
||||||
|
/// this machine rather than cleared, because an absent secret in the export
|
||||||
|
/// means "the source machine never had this configured," not "delete this
|
||||||
|
/// on import." A user who wants to clear a secret already has dedicated UI
|
||||||
|
/// for that (signing out of shared auth, clearing the gateway key).
|
||||||
|
///
|
||||||
|
/// Order matters here, twice over.
|
||||||
|
///
|
||||||
|
/// First: the imported settings are **validated before any secret is
|
||||||
|
/// written**, using the same checks `update_settings` itself runs
|
||||||
|
/// (`settings_commands::validate_settings_update`). Restoring a secret is
|
||||||
|
/// hard to undo unnoticed — a stale env-var-name rejection or a disallowed
|
||||||
|
/// host path used to be caught only when `update_settings` ran, by which
|
||||||
|
/// point the three keychain secrets below were already overwritten with the
|
||||||
|
/// file's, each with a fresh rotation id, silently flagging every project
|
||||||
|
/// container for recreation — while the error the user saw talked only
|
||||||
|
/// about the rejected setting and said nothing about the credentials that
|
||||||
|
/// had already moved. Failing this check first makes a rejected import
|
||||||
|
/// leave nothing touched, matching what "the import failed" is supposed to
|
||||||
|
/// mean.
|
||||||
|
///
|
||||||
|
/// Second, among the things that *do* get written: secrets are restored
|
||||||
|
/// **before** the settings replace runs (which is what triggers
|
||||||
|
/// `reconcile_gateway`), so a gateway recreation that replace provokes sees
|
||||||
|
/// the final key material rather than racing it — restoring the other way
|
||||||
|
/// round left a real window where the running gateway and the keychain
|
||||||
|
/// briefly disagreed. A gateway *secret* alone (same shape, new key) is
|
||||||
|
/// invisible to `reconcile_gateway`'s shape comparison, so this additionally
|
||||||
|
/// nudges a running gateway container to recreate itself whenever a secret
|
||||||
|
/// this import carried was actually written — otherwise the running
|
||||||
|
/// container keeps serving the old key material indefinitely while every
|
||||||
|
/// project container is handed the new one.
|
||||||
|
///
|
||||||
|
/// A keychain write failing is reported back rather than only logged: an
|
||||||
|
/// import that silently restores two of three secrets but not the third
|
||||||
|
/// must not read as unqualified success.
|
||||||
|
///
|
||||||
|
/// The pending import is only cleared on success. A failure here (rejected
|
||||||
|
/// by the validation above, a stale-file mismatch, or some other error)
|
||||||
|
/// leaves it pending so the frontend can let the user retry `apply` without
|
||||||
|
/// making them pick the file and re-enter the password again — the
|
||||||
|
/// preview's job was confirming *what* to import, not spending the one
|
||||||
|
/// attempt at applying it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn apply_settings_import(
|
||||||
|
password: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SettingsImportOutcome, String> {
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err("A password is required to import settings.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending = state
|
||||||
|
.pending_settings_import
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
|
||||||
|
|
||||||
|
let encrypted = std::fs::read(&pending.path)
|
||||||
|
.map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||||
|
if hash_ciphertext(&encrypted) != pending.ciphertext_hash {
|
||||||
|
return Err(
|
||||||
|
"This file changed since you reviewed it — choose it again to see an up-to-date preview."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||||
|
|
||||||
|
let current = state.settings_store.get();
|
||||||
|
|
||||||
|
// The web-terminal token lives inside `AppSettings` itself rather than
|
||||||
|
// the keychain, so "leave an absent secret alone" has to be done by
|
||||||
|
// hand here: carry the destination's current token forward when the
|
||||||
|
// import doesn't have one, instead of letting the wholesale replace
|
||||||
|
// below blank it (every export writes `None` there — see
|
||||||
|
// `split_settings_and_secrets`).
|
||||||
|
let mut settings = payload.settings;
|
||||||
|
settings.web_terminal.access_token = non_blank(payload.secrets.web_terminal_access_token)
|
||||||
|
.or_else(|| current.web_terminal.access_token.clone());
|
||||||
|
|
||||||
|
crate::commands::settings_commands::validate_settings_update(¤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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,37 @@ const REGISTRY_API_BASE: &str =
|
|||||||
const GHCR_TOKEN_URL: &str =
|
const GHCR_TOKEN_URL: &str =
|
||||||
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
"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]
|
#[tauri::command]
|
||||||
pub fn get_app_version() -> String {
|
pub fn get_app_version() -> String {
|
||||||
env!("CARGO_PKG_VERSION").to_string()
|
format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -51,30 +79,20 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
&[".AppImage", ".deb", ".rpm"]
|
&[".AppImage", ".deb", ".rpm"]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter releases that have at least one asset matching the current platform
|
// `current_version` above is always the bare, stripped `CARGO_PKG_VERSION`
|
||||||
let platform_releases: Vec<&GitHubRelease> = releases
|
// — the preview workflow patches `Cargo.toml` with that before compiling,
|
||||||
.iter()
|
// never the `-preview.<sha>`-suffixed one `get_app_version()` reports —
|
||||||
.filter(|r| {
|
// so a preview build and the release it precedes compile to the identical
|
||||||
r.assets.iter().any(|a| {
|
// numeric tuple by construction (see `build-app-preview.yml`'s "highest
|
||||||
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
// 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
|
||||||
.collect();
|
// 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
|
match pick_update(&releases, current_semver, platform_extensions, is_preview_build) {
|
||||||
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
|
Some(release) => {
|
||||||
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, _)) => {
|
|
||||||
// Only include assets matching the current platform
|
// Only include assets matching the current platform
|
||||||
let assets = release
|
let assets = release
|
||||||
.assets
|
.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)
|
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||||
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||||
let clean = version.trim_start_matches('v');
|
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))
|
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.
|
/// Check whether a newer container image is available in the registry.
|
||||||
///
|
///
|
||||||
/// Compares the local image digest with the remote registry digest using the
|
/// 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)",
|
"Removing container {} (v=false: named volumes such as claude config are preserved)",
|
||||||
container_id
|
container_id
|
||||||
);
|
);
|
||||||
docker
|
match docker
|
||||||
.remove_container(
|
.remove_container(
|
||||||
container_id,
|
container_id,
|
||||||
Some(RemoveContainerOptions {
|
Some(RemoveContainerOptions {
|
||||||
@@ -1945,7 +1945,17 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.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.
|
/// 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).
|
/// 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> {
|
pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
remove_image_by_name(&get_snapshot_image_name(project)).await
|
||||||
let image_name = get_snapshot_image_name(project);
|
}
|
||||||
|
|
||||||
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(
|
.remove_image(
|
||||||
&image_name,
|
image_name,
|
||||||
Some(RemoveImageOptions {
|
Some(RemoveImageOptions {
|
||||||
force: true,
|
force: true,
|
||||||
noprune: false,
|
noprune: false,
|
||||||
@@ -3510,25 +3534,91 @@ pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to remove snapshot image {}: {}", image_name, e))?;
|
{
|
||||||
|
Ok(_) => {
|
||||||
log::info!("Removed snapshot image {}", image_name);
|
log::info!("Removed snapshot image {}", image_name);
|
||||||
Ok(())
|
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).
|
/// 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()?;
|
/// Returns the names of volumes that still exist afterwards — empty means
|
||||||
for vol in [
|
/// 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),
|
home_volume_name(&project.id),
|
||||||
config_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),
|
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
|
/// Check whether the existing container's configuration still matches the
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ pub struct AppState {
|
|||||||
pub auth_bridge: Arc<AuthBridgeManager>,
|
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||||
pub lifecycle: Arc<Lifecycle>,
|
pub lifecycle: Arc<Lifecycle>,
|
||||||
|
/// The file `preview_settings_import` last decrypted successfully, held
|
||||||
|
/// so `apply_settings_import` can re-read and re-decrypt the same file
|
||||||
|
/// without the frontend ever passing a host path back to Rust as an
|
||||||
|
/// argument — see the doc comment on `commands::settings_export_commands`
|
||||||
|
/// for why that direction specifically is the one this app treats as
|
||||||
|
/// dangerous. Deliberately re-decrypted rather than cached in plaintext:
|
||||||
|
/// nothing here holds a decrypted secret in memory for longer than one
|
||||||
|
/// command's execution.
|
||||||
|
///
|
||||||
|
/// Also pins a hash of the file's ciphertext at preview time, so
|
||||||
|
/// `apply_settings_import` can refuse to proceed if the file on disk
|
||||||
|
/// changed underneath the pending import — otherwise confirming a
|
||||||
|
/// preview is not actually binding on what gets applied.
|
||||||
|
pub pending_settings_import:
|
||||||
|
Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -222,6 +237,7 @@ pub fn run() {
|
|||||||
auth_bridge,
|
auth_bridge,
|
||||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
})
|
})
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||||
@@ -250,6 +266,7 @@ pub fn run() {
|
|||||||
// an image open and the sweep will not force; pins are untagged
|
// an image open and the sweep will not force; pins are untagged
|
||||||
// second so the images they were holding are dangling by the time
|
// second so the images they were holding are dangling by the time
|
||||||
// the sweep lists them; the sweep runs last and collects both.
|
// 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 {
|
tauri::async_runtime::spawn(async move {
|
||||||
crate::docker::reap_probe_containers().await;
|
crate::docker::reap_probe_containers().await;
|
||||||
let reaped = crate::docker::reap_stale_migration_pins().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);
|
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
||||||
}
|
}
|
||||||
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
|
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
|
// 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::inspect_ca_cert_path,
|
||||||
commands::settings_commands::list_aws_profiles,
|
commands::settings_commands::list_aws_profiles,
|
||||||
commands::settings_commands::detect_host_timezone,
|
commands::settings_commands::detect_host_timezone,
|
||||||
|
// Settings export/import
|
||||||
|
commands::settings_export_commands::export_settings,
|
||||||
|
commands::settings_export_commands::preview_settings_import,
|
||||||
|
commands::settings_export_commands::apply_settings_import,
|
||||||
// Terminal
|
// Terminal
|
||||||
commands::terminal_commands::open_terminal_session,
|
commands::terminal_commands::open_terminal_session,
|
||||||
commands::terminal_commands::terminal_input,
|
commands::terminal_commands::terminal_input,
|
||||||
|
|||||||
@@ -1,6 +1,54 @@
|
|||||||
// Prevents additional console window on Windows in release
|
// Prevents additional console window on Windows in release
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
||||||
|
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
||||||
|
/// under Wayland, printing `Could not create default EGL display:
|
||||||
|
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
|
||||||
|
/// code and killing the webview before Triple-C's own logging even starts —
|
||||||
|
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||||
|
///
|
||||||
|
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
|
||||||
|
/// variable is exported into an XWayland client's environment too, so a
|
||||||
|
/// gate on it wouldn't even cleanly separate "Wayland" from "X11" — and
|
||||||
|
/// there is no reliable heuristic at all for the actual variable that
|
||||||
|
/// matters, which Mesa/driver/compositor combination is affected. This is
|
||||||
|
/// the blunt instrument, chosen deliberately because the fallback is a real
|
||||||
|
/// trade, not a free one: the terminal's `@xterm/addon-webgl` renderer
|
||||||
|
/// (`TerminalView.tsx`) is the one surface in this app actually asking for
|
||||||
|
/// GPU compositing, and it degrades to xterm's canvas renderer under this
|
||||||
|
/// setting — slower on very heavy output, but the addon's own construction
|
||||||
|
/// is already wrapped in a fallback (`WebGL not available` is a handled
|
||||||
|
/// case, not a crash), so this is a real but graceful downgrade, traded
|
||||||
|
/// against a startup abort that has no fallback at all.
|
||||||
|
///
|
||||||
|
/// Must be set before `triple_c_lib::run()` — GTK/WebKitGTK reads it at
|
||||||
|
/// their own init time, which happens inside the Tauri builder that
|
||||||
|
/// function calls into, not at binary load.
|
||||||
|
///
|
||||||
|
/// A user who has already set this themselves is left alone. That includes
|
||||||
|
/// setting it to `0`, on the assumption WebKitGTK treats it as a boolean
|
||||||
|
/// rather than presence-only — not verified against WebKitGTK's own source,
|
||||||
|
/// so if it turns out to be presence-only, `=0` still reads as "set" here
|
||||||
|
/// and disables DMA-BUF the same as any other value, which is at least the
|
||||||
|
/// safe direction to be wrong in.
|
||||||
|
///
|
||||||
|
/// This env var also leaks to whatever the app spawns afterwards — notably
|
||||||
|
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
||||||
|
/// call. Narrow in practice (an already-running browser just receives the
|
||||||
|
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
||||||
|
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
||||||
|
/// as a separate, unrelated cause.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn apply_webkit_wayland_workaround() {
|
||||||
|
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||||
|
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
apply_webkit_wayland_workaround();
|
||||||
|
|
||||||
triple_c_lib::run()
|
triple_c_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod container_config;
|
|||||||
pub mod app_settings;
|
pub mod app_settings;
|
||||||
pub mod gateway_settings;
|
pub mod gateway_settings;
|
||||||
pub mod migration;
|
pub mod migration;
|
||||||
|
pub mod settings_export;
|
||||||
pub mod update_info;
|
pub mod update_info;
|
||||||
|
|
||||||
pub use project::*;
|
pub use project::*;
|
||||||
@@ -10,4 +11,5 @@ pub use container_config::*;
|
|||||||
pub use app_settings::*;
|
pub use app_settings::*;
|
||||||
pub use gateway_settings::*;
|
pub use gateway_settings::*;
|
||||||
pub use migration::*;
|
pub use migration::*;
|
||||||
|
pub use settings_export::*;
|
||||||
pub use update_info::*;
|
pub use update_info::*;
|
||||||
|
|||||||
@@ -422,6 +422,61 @@ pub enum ProjectStatus {
|
|||||||
Error,
|
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.
|
/// Which AI model backend/provider the project uses.
|
||||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||||
@@ -650,6 +705,25 @@ impl Project {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 ─────────────────────────────────
|
// ── Custom environment variable names ─────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[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 body: String,
|
||||||
pub assets: Vec<GitHubAsset>,
|
pub assets: Vec<GitHubAsset>,
|
||||||
pub published_at: String,
|
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).
|
/// GitHub API asset response (internal).
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod migration_store;
|
pub mod migration_store;
|
||||||
|
pub mod pending_cleanup;
|
||||||
pub mod projects_store;
|
pub mod projects_store;
|
||||||
pub mod secure;
|
pub mod secure;
|
||||||
|
pub mod settings_crypto;
|
||||||
pub mod settings_store;
|
pub mod settings_store;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
|
|||||||
@@ -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
|
/// only enforces auth when a master key is configured, so Triple-C always
|
||||||
/// configures one.
|
/// configures one.
|
||||||
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
||||||
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
|
if let Some(existing) = get_gateway_master_key()? {
|
||||||
if !existing.trim().is_empty() {
|
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
regenerate_gateway_master_key()
|
regenerate_gateway_master_key()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the gateway master key without minting one if none exists yet.
|
||||||
|
/// Distinct from [`get_or_create_gateway_master_key`], which mints as a side
|
||||||
|
/// effect the read half of that function must not have — settings export
|
||||||
|
/// (triple-c#35) needs "is there one, and if so what is it", not "make sure
|
||||||
|
/// one exists".
|
||||||
|
pub fn get_gateway_master_key() -> Result<Option<String>, String> {
|
||||||
|
Ok(read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")?
|
||||||
|
.filter(|k| !k.trim().is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
||||||
/// previous value must be updated.
|
/// previous value must be updated.
|
||||||
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
||||||
// LiteLLM requires the master key to start with `sk-`.
|
// LiteLLM requires the master key to start with `sk-`.
|
||||||
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
store_gateway_master_key(&key)?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an exact given gateway master key, replacing any previous one.
|
||||||
|
///
|
||||||
|
/// Distinct from [`regenerate_gateway_master_key`], which always mints a
|
||||||
|
/// fresh random value: this exists for settings import (triple-c#35), where
|
||||||
|
/// restoring the *same* key an export captured is the point — projects on
|
||||||
|
/// the destination machine may not exist yet, but a project migrated or
|
||||||
|
/// re-added later that still has the old key pasted into its config must
|
||||||
|
/// keep working against it. Blank input is rejected rather than silently
|
||||||
|
/// stored, matching every other `store_*` function in this module.
|
||||||
|
pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
|
||||||
|
if key.trim().is_empty() {
|
||||||
|
return Err("Refusing to store an empty gateway master key.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
entry
|
entry
|
||||||
.set_password(&key)
|
.set_password(key.trim())
|
||||||
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
||||||
|
|
||||||
bump_gateway_secret_version()?;
|
bump_gateway_secret_version()
|
||||||
Ok(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//! Password-based encryption for the settings export/import file — see
|
||||||
|
//! triple-c#35.
|
||||||
|
//!
|
||||||
|
//! The exported payload can carry live credentials (the shared Claude OAuth
|
||||||
|
//! token, the gateway provider/master keys — see
|
||||||
|
//! `commands::settings_export_commands`), so this is not encryption for its
|
||||||
|
//! own sake; a wrong or missing key here is a real credential leak, not a
|
||||||
|
//! cosmetic bug. Argon2id derives a 256-bit key from the password (memory-
|
||||||
|
//! hard, meaningfully resistant to GPU/ASIC brute-forcing in a way PBKDF2 at
|
||||||
|
//! any reasonable iteration count is not), and AES-256-GCM is what actually
|
||||||
|
//! encrypts — authenticated, so a wrong password is detected by a failed tag
|
||||||
|
//! check rather than producing silent garbage.
|
||||||
|
//!
|
||||||
|
//! File format: `MAGIC (4 bytes) | salt (16 bytes) | nonce (12 bytes) |
|
||||||
|
//! ciphertext+tag`. The salt and nonce are not secret — they are written in
|
||||||
|
//! the clear right here, on purpose. The salt's only job is to make two
|
||||||
|
//! exports with the same password derive different keys (defeats a
|
||||||
|
//! precomputed-table attack against the password alone); the nonce's job is
|
||||||
|
//! GCM's requirement that a (key, nonce) pair never repeat. Both hold
|
||||||
|
//! because a fresh random value is drawn for each, on every call to
|
||||||
|
//! [`encrypt`].
|
||||||
|
//!
|
||||||
|
//! The whole header (magic + salt + nonce) is passed to AES-GCM as
|
||||||
|
//! associated data, not just placed alongside the ciphertext — free to do,
|
||||||
|
//! and it makes tampering with any header byte fail the same authentication
|
||||||
|
//! check the ciphertext gets, by construction rather than as a side effect
|
||||||
|
//! of the salt/nonce also feeding key derivation and the cipher.
|
||||||
|
|
||||||
|
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||||
|
use aes_gcm::{Aes256Gcm, Nonce};
|
||||||
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
|
use rand::RngCore;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
/// Identifies the file as a Triple-C settings export and pins the format —
|
||||||
|
/// a change to the salt/nonce lengths or the KDF/cipher choice below needs a
|
||||||
|
/// new magic value, not a silent reinterpretation of old bytes.
|
||||||
|
const MAGIC: &[u8; 4] = b"TCX1";
|
||||||
|
const SALT_LEN: usize = 16;
|
||||||
|
const NONCE_LEN: usize = 12;
|
||||||
|
const KEY_LEN: usize = 32;
|
||||||
|
const HEADER_LEN: usize = MAGIC.len() + SALT_LEN + NONCE_LEN;
|
||||||
|
|
||||||
|
/// Argon2id parameters: memory cost in KiB, time cost (iterations),
|
||||||
|
/// parallelism. `(19 MiB, 2, 1)` is OWASP's documented minimum recommendation
|
||||||
|
/// for Argon2id — deliberately heavier than a login-flow KDF would use, since
|
||||||
|
/// this runs once per export/import rather than on every request, so trading
|
||||||
|
/// roughly a second of wall time for real brute-force resistance costs
|
||||||
|
/// nothing a user would notice.
|
||||||
|
fn argon2_params() -> Params {
|
||||||
|
Params::new(19 * 1024, 2, 1, Some(KEY_LEN)).expect("hardcoded Argon2 params are valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The derived key is wrapped in `Zeroizing` so it is overwritten with zeros
|
||||||
|
/// when it drops rather than left in freed memory for whatever reuses that
|
||||||
|
/// stack slot next — cheap insurance (`zeroize` is already in the dependency
|
||||||
|
/// tree via `aes-gcm`) for material that exists only to decrypt live
|
||||||
|
/// credentials.
|
||||||
|
fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, String> {
|
||||||
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params());
|
||||||
|
let mut key = Zeroizing::new([0u8; KEY_LEN]);
|
||||||
|
argon2
|
||||||
|
.hash_password_into(password.as_bytes(), salt, &mut *key)
|
||||||
|
.map_err(|e| format!("Failed to derive encryption key: {}", e))?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt `plaintext` with a key derived from `password`. Returns the whole
|
||||||
|
/// file's bytes (header + ciphertext) — see the module doc for the layout.
|
||||||
|
pub fn encrypt(plaintext: &[u8], password: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let mut salt = [0u8; SALT_LEN];
|
||||||
|
rand::rng().fill_bytes(&mut salt);
|
||||||
|
let key = derive_key(password, &salt)?;
|
||||||
|
|
||||||
|
let mut nonce_bytes = [0u8; NONCE_LEN];
|
||||||
|
rand::rng().fill_bytes(&mut nonce_bytes);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let mut header = Vec::with_capacity(HEADER_LEN);
|
||||||
|
header.extend_from_slice(MAGIC);
|
||||||
|
header.extend_from_slice(&salt);
|
||||||
|
header.extend_from_slice(&nonce_bytes);
|
||||||
|
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(&*key)
|
||||||
|
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
|
||||||
|
// The header (magic + salt + nonce) is authenticated as associated data
|
||||||
|
// even though none of it is secret: it costs nothing extra here, and it
|
||||||
|
// means tampering with any header byte is caught by the same tag check
|
||||||
|
// that already covers the ciphertext, by construction rather than as a
|
||||||
|
// side effect of the header also feeding key/nonce derivation.
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(nonce, Payload { msg: plaintext, aad: &header })
|
||||||
|
.map_err(|e| format!("Encryption failed: {}", e))?;
|
||||||
|
|
||||||
|
let mut out = header;
|
||||||
|
out.extend_from_slice(&ciphertext);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a file produced by [`encrypt`]. The one error this returns for a
|
||||||
|
/// wrong password is deliberately generic ("wrong password, or the file is
|
||||||
|
/// corrupted") rather than distinguishing the two: GCM's authentication tag
|
||||||
|
/// fails to verify for the wrong key on essentially any ciphertext, so there
|
||||||
|
/// is no reliable way to tell "wrong password" from "corrupted file" apart,
|
||||||
|
/// and guessing would be worse than saying so.
|
||||||
|
///
|
||||||
|
/// Returns `Zeroizing<Vec<u8>>` rather than a plain `Vec<u8>` — the plaintext
|
||||||
|
/// this recovers is the whole settings-plus-secrets payload, so it gets the
|
||||||
|
/// same "wipe it when it drops" treatment as the derived key in
|
||||||
|
/// [`derive_key`].
|
||||||
|
pub fn decrypt(data: &[u8], password: &str) -> Result<Zeroizing<Vec<u8>>, String> {
|
||||||
|
if data.len() < HEADER_LEN {
|
||||||
|
return Err("This does not look like a Triple-C settings export (file too short).".to_string());
|
||||||
|
}
|
||||||
|
if &data[..MAGIC.len()] != MAGIC {
|
||||||
|
return Err("This does not look like a Triple-C settings export (unrecognized file).".to_string());
|
||||||
|
}
|
||||||
|
let header = &data[..HEADER_LEN];
|
||||||
|
let salt = &data[MAGIC.len()..MAGIC.len() + SALT_LEN];
|
||||||
|
let nonce_bytes = &data[MAGIC.len() + SALT_LEN..HEADER_LEN];
|
||||||
|
let ciphertext = &data[HEADER_LEN..];
|
||||||
|
|
||||||
|
let key = derive_key(password, salt)?;
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(&*key)
|
||||||
|
.map_err(|e| format!("Failed to initialize cipher: {}", e))?;
|
||||||
|
let nonce = Nonce::from_slice(nonce_bytes);
|
||||||
|
cipher
|
||||||
|
.decrypt(nonce, Payload { msg: ciphertext, aad: header })
|
||||||
|
.map(Zeroizing::new)
|
||||||
|
.map_err(|_| "Wrong password, or the file is corrupted.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_round_trip_with_the_right_password_recovers_the_plaintext() {
|
||||||
|
let plaintext = b"{\"settings\": \"whatever\"}";
|
||||||
|
let encrypted = encrypt(plaintext, "correct horse battery staple").unwrap();
|
||||||
|
let decrypted = decrypt(&encrypted, "correct horse battery staple").unwrap();
|
||||||
|
assert_eq!(&*decrypted, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wrong_password_fails_rather_than_returning_garbage() {
|
||||||
|
let encrypted = encrypt(b"secret payload", "correct password").unwrap();
|
||||||
|
let result = decrypt(&encrypted, "wrong password");
|
||||||
|
assert!(result.is_err(), "decrypting with the wrong password must fail, not silently succeed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_exports_of_the_same_plaintext_and_password_produce_different_files() {
|
||||||
|
// If this ever failed it would mean the salt or nonce stopped being
|
||||||
|
// randomized — either one repeating is a real security regression
|
||||||
|
// (a fixed salt lets an attacker precompute against the password
|
||||||
|
// alone; a repeated (key, nonce) pair breaks GCM's guarantees
|
||||||
|
// outright), not just a cosmetic one.
|
||||||
|
let a = encrypt(b"same plaintext", "same password").unwrap();
|
||||||
|
let b = encrypt(b"same plaintext", "same password").unwrap();
|
||||||
|
assert_ne!(a, b, "two independent exports must not be byte-identical");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupting_a_single_byte_of_ciphertext_is_detected() {
|
||||||
|
let mut encrypted = encrypt(b"tamper-evident payload", "a password").unwrap();
|
||||||
|
let last = encrypted.len() - 1;
|
||||||
|
encrypted[last] ^= 0xFF;
|
||||||
|
assert!(decrypt(&encrypted, "a password").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_that_is_too_short_is_rejected_cleanly_not_by_panicking() {
|
||||||
|
assert!(decrypt(b"short", "any password").is_err());
|
||||||
|
assert!(decrypt(b"", "any password").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_with_the_wrong_magic_is_rejected() {
|
||||||
|
let mut encrypted = encrypt(b"payload", "password").unwrap();
|
||||||
|
encrypted[0] = b'X';
|
||||||
|
assert!(decrypt(&encrypted, "password").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { projectRemovalIsClean } from "../../../lib/types";
|
||||||
import { useAppState } from "../../../store/appState";
|
import { useAppState } from "../../../store/appState";
|
||||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||||
import { useProjects } from "../../../hooks/useProjects";
|
import { useProjects } from "../../../hooks/useProjects";
|
||||||
@@ -18,6 +19,7 @@ import ConfigTab from "./ConfigTab";
|
|||||||
import FilesTab from "./FilesTab";
|
import FilesTab from "./FilesTab";
|
||||||
import BrowserTab from "./BrowserTab";
|
import BrowserTab from "./BrowserTab";
|
||||||
import { formatUptime } from "./format";
|
import { formatUptime } from "./format";
|
||||||
|
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: "overview", label: "Overview" },
|
{ id: "overview", label: "Overview" },
|
||||||
@@ -282,7 +284,25 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
setConfirmRemove(false);
|
setConfirmRemove(false);
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
useAppState.getState().pushToast({
|
useAppState.getState().pushToast({
|
||||||
kind: "error",
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,9 +19,11 @@ import WebTerminalSettings from "./WebTerminalSettings";
|
|||||||
import SttSettings from "./SttSettings";
|
import SttSettings from "./SttSettings";
|
||||||
import SharedAuthSettings from "./SharedAuthSettings";
|
import SharedAuthSettings from "./SharedAuthSettings";
|
||||||
import CertificateSettings from "./CertificateSettings";
|
import CertificateSettings from "./CertificateSettings";
|
||||||
|
import ExportSettingsModal from "./ExportSettingsModal";
|
||||||
|
import ImportSettingsModal from "./ImportSettingsModal";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings, setAppSettings } = useSettings();
|
||||||
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
|
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
|
||||||
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||||
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||||
@@ -33,6 +35,8 @@ export default function SettingsPanel() {
|
|||||||
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
||||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
|
const [showClaudeCodeSettingsModal, setShowClaudeCodeSettingsModal] = useState(false);
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
|
||||||
// Sync local state when appSettings change
|
// Sync local state when appSettings change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -269,6 +273,39 @@ export default function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
|
|
||||||
|
<AccordionSection id="backup" title="Backup" defaultOpen={false}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||||
|
Export your global settings and stored credentials (a shared Claude login,
|
||||||
|
gateway keys) to one password-encrypted file, or restore them on a new machine.
|
||||||
|
Project-specific settings and container data are never included.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowExportModal(true)}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
Export settings…
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportModal(true)}
|
||||||
|
className="px-3 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||||
|
>
|
||||||
|
Import settings…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionSection>
|
||||||
|
|
||||||
|
{showExportModal && <ExportSettingsModal onClose={() => setShowExportModal(false)} />}
|
||||||
|
|
||||||
|
{showImportModal && (
|
||||||
|
<ImportSettingsModal
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
onImported={(settings) => setAppSettings(settings)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showInstructionsModal && (
|
{showInstructionsModal && (
|
||||||
<ClaudeInstructionsModal
|
<ClaudeInstructionsModal
|
||||||
instructions={globalInstructions}
|
instructions={globalInstructions}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { save } from "@tauri-apps/plugin-dialog";
|
|||||||
import type { Project } from "../lib/types";
|
import type { Project } from "../lib/types";
|
||||||
import * as commands from "../lib/tauri-commands";
|
import * as commands from "../lib/tauri-commands";
|
||||||
import { formatBytes } from "../lib/formatBytes";
|
import { formatBytes } from "../lib/formatBytes";
|
||||||
|
import { describeResetLeftovers, resetLeftoverPronoun } from "../lib/resetOutcome";
|
||||||
import { useAppState } from "../store/appState";
|
import { useAppState } from "../store/appState";
|
||||||
import { useProjects } from "./useProjects";
|
import { useProjects } from "./useProjects";
|
||||||
import { useTerminal } from "./useTerminal";
|
import { useTerminal } from "./useTerminal";
|
||||||
@@ -28,13 +29,14 @@ export function useProjectActions(project: Project) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const run = useCallback(
|
const run = useCallback(
|
||||||
async (label: string, fn: () => Promise<unknown>) => {
|
async <T,>(label: string, fn: () => Promise<T>): Promise<T | undefined> => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setContainerProgress(project.id, null);
|
setContainerProgress(project.id, null);
|
||||||
try {
|
try {
|
||||||
await fn();
|
return await fn();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
fail(`${label} failed for “${project.name}”`, e);
|
fail(`${label} failed for “${project.name}”`, e);
|
||||||
|
return undefined;
|
||||||
} finally {
|
} finally {
|
||||||
setContainerProgress(project.id, null);
|
setContainerProgress(project.id, null);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -54,8 +56,25 @@ export function useProjectActions(project: Project) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleReset = useCallback(
|
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 () => {
|
const openClaudeTerminal = useCallback(async () => {
|
||||||
|
|||||||
@@ -140,3 +140,27 @@ describe("useProjects puts the status back when a refused command never ran", ()
|
|||||||
expect(statusOf()).toBe("stopped");
|
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(
|
const remove = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
await commands.removeProject(id);
|
const report = await commands.removeProject(id);
|
||||||
removeProjectFromList(id);
|
removeProjectFromList(id);
|
||||||
|
return report;
|
||||||
},
|
},
|
||||||
[removeProjectFromList],
|
[removeProjectFromList],
|
||||||
);
|
);
|
||||||
@@ -135,9 +136,9 @@ export function useProjects() {
|
|||||||
const rebuild = useCallback(
|
const rebuild = useCallback(
|
||||||
(id: string) =>
|
(id: string) =>
|
||||||
withOptimisticStatus(id, "starting", async () => {
|
withOptimisticStatus(id, "starting", async () => {
|
||||||
const updated = await commands.rebuildProjectContainer(id);
|
const outcome = await commands.rebuildProjectContainer(id);
|
||||||
updateProjectInList(updated);
|
updateProjectInList(outcome.project);
|
||||||
return updated;
|
return outcome;
|
||||||
}),
|
}),
|
||||||
[updateProjectInList, withOptimisticStatus],
|
[updateProjectInList, withOptimisticStatus],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,5 +36,9 @@ export function useSettings() {
|
|||||||
appSettings,
|
appSettings,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
/** For a command that already returns the new `AppSettings` itself
|
||||||
|
* (settings import) — updates the store without a redundant
|
||||||
|
* `updateSettings` round trip through the backend. */
|
||||||
|
setAppSettings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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, UploadOutcome } from "./types";
|
import type { Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ContainerInfo, AppSettings, SettingsImportPreview, SettingsImportOutcome, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, UploadOutcome } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -13,7 +13,7 @@ export const listProjects = () => invoke<Project[]>("list_projects");
|
|||||||
export const addProject = (name: string, paths: ProjectPath[]) =>
|
export const addProject = (name: string, paths: ProjectPath[]) =>
|
||||||
invoke<Project>("add_project", { name, paths });
|
invoke<Project>("add_project", { name, paths });
|
||||||
export const removeProject = (projectId: string) =>
|
export const removeProject = (projectId: string) =>
|
||||||
invoke<void>("remove_project", { projectId });
|
invoke<ProjectRemovalReport>("remove_project", { projectId });
|
||||||
export const updateProject = (project: Project) =>
|
export const updateProject = (project: Project) =>
|
||||||
invoke<Project>("update_project", { project });
|
invoke<Project>("update_project", { project });
|
||||||
export const startProjectContainer = (projectId: string) =>
|
export const startProjectContainer = (projectId: string) =>
|
||||||
@@ -21,7 +21,7 @@ export const startProjectContainer = (projectId: string) =>
|
|||||||
export const stopProjectContainer = (projectId: string) =>
|
export const stopProjectContainer = (projectId: string) =>
|
||||||
invoke<void>("stop_project_container", { projectId });
|
invoke<void>("stop_project_container", { projectId });
|
||||||
export const rebuildProjectContainer = (projectId: string) =>
|
export const rebuildProjectContainer = (projectId: string) =>
|
||||||
invoke<Project>("rebuild_project_container", { projectId });
|
invoke<ProjectResetOutcome>("rebuild_project_container", { projectId });
|
||||||
export const reconcileProjectStatuses = () =>
|
export const reconcileProjectStatuses = () =>
|
||||||
invoke<Project[]>("reconcile_project_statuses");
|
invoke<Project[]>("reconcile_project_statuses");
|
||||||
|
|
||||||
@@ -42,6 +42,15 @@ export const inspectCaCertPath = (path: string) =>
|
|||||||
export const detectHostTimezone = () =>
|
export const detectHostTimezone = () =>
|
||||||
invoke<string>("detect_host_timezone");
|
invoke<string>("detect_host_timezone");
|
||||||
|
|
||||||
|
// Settings export/import — `false`/`null` mean the save/open dialog was
|
||||||
|
// dismissed, not an error.
|
||||||
|
export const exportSettings = (password: string) =>
|
||||||
|
invoke<boolean>("export_settings", { password });
|
||||||
|
export const previewSettingsImport = (password: string) =>
|
||||||
|
invoke<SettingsImportPreview | null>("preview_settings_import", { password });
|
||||||
|
export const applySettingsImport = (password: string) =>
|
||||||
|
invoke<SettingsImportOutcome>("apply_settings_import", { password });
|
||||||
|
|
||||||
// AWS
|
// AWS
|
||||||
export const awsSsoRefresh = (projectId: string) =>
|
export const awsSsoRefresh = (projectId: string) =>
|
||||||
invoke<void>("aws_sso_refresh", { projectId });
|
invoke<void>("aws_sso_refresh", { projectId });
|
||||||
|
|||||||
@@ -77,6 +77,37 @@ export type ProjectStatus =
|
|||||||
| "stopping"
|
| "stopping"
|
||||||
| "error";
|
| "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 =
|
export type Backend =
|
||||||
| "anthropic"
|
| "anthropic"
|
||||||
| "bedrock"
|
| "bedrock"
|
||||||
@@ -261,6 +292,46 @@ export interface AppSettings {
|
|||||||
global_claude_code_settings: ClaudeCodeSettings | null;
|
global_claude_code_settings: ClaudeCodeSettings | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What `preview_settings_import` returns before anything is applied —
|
||||||
|
* counts and presence flags only, never a secret value itself. Built from
|
||||||
|
* this, not from the raw import file, which the frontend never sees. */
|
||||||
|
export interface SettingsImportPreview {
|
||||||
|
exported_at: string;
|
||||||
|
app_version: string;
|
||||||
|
custom_env_var_count: number;
|
||||||
|
gateway_model_count: number;
|
||||||
|
has_claude_code_settings: boolean;
|
||||||
|
has_claude_oauth_token: boolean;
|
||||||
|
has_gateway_api_key: boolean;
|
||||||
|
has_gateway_master_key: boolean;
|
||||||
|
has_web_terminal_access_token: boolean;
|
||||||
|
/** Whether the import turns the web terminal on — surfaced separately
|
||||||
|
* from the token above since either can be true without the other, and
|
||||||
|
* "this enables a service that listens on your network" must not hide
|
||||||
|
* inside a generic "settings replaced" summary. */
|
||||||
|
enables_web_terminal: boolean;
|
||||||
|
/** Non-blank custom base URLs the import would set — endpoints, not
|
||||||
|
* secrets, so shown verbatim to disclose a redirect of model traffic. */
|
||||||
|
ollama_base_url: string | null;
|
||||||
|
llamacpp_base_url: string | null;
|
||||||
|
openai_compatible_base_url: string | null;
|
||||||
|
gateway_api_base: string | null;
|
||||||
|
/** Whether the import sets a custom Docker image, and its name if so —
|
||||||
|
* this is the image every project container is created from, so worth
|
||||||
|
* more attention than an ordinary setting. */
|
||||||
|
image_source: ImageSource;
|
||||||
|
custom_image_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What `apply_settings_import` returns: the settings that were actually
|
||||||
|
* saved, plus a note for each keychain secret the import carried but could
|
||||||
|
* not be restored (a partial keychain failure must not read as unqualified
|
||||||
|
* success just because the settings half went through). */
|
||||||
|
export interface SettingsImportOutcome {
|
||||||
|
settings: AppSettings;
|
||||||
|
secret_restore_warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride
|
/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride
|
||||||
* in the payload rather than rejecting, so the field can render them inline
|
* in the payload rather than rejecting, so the field can render them inline
|
||||||
* while the user is still typing. */
|
* while the user is still typing. */
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Maintainer: Triple-C Contributors
|
||||||
|
#
|
||||||
|
# This file is regenerated by .gitea/workflows/publish-arch-package.yml on every
|
||||||
|
# publish — pkgver, the source URL and sha256sums are rewritten from the real,
|
||||||
|
# already-uploaded release asset, never guessed. Editing pkgver/source/
|
||||||
|
# sha256sums by hand here only matters until the next automated run overwrites
|
||||||
|
# them; everything else (depends, pkgdesc, package()) is meant to be hand-
|
||||||
|
# maintained normally.
|
||||||
|
#
|
||||||
|
# "-bin" rather than building from source: this repackages the same .deb
|
||||||
|
# build-app.yml already produces and publishes, so a user gets exactly the
|
||||||
|
# binary the project ships and tests, and `makepkg` never needs a Rust
|
||||||
|
# toolchain, Node, or the dozen -dev packages CLAUDE.md lists for building
|
||||||
|
# Triple-C itself. The trade-off is the one every "-bin" package makes: it
|
||||||
|
# assumes the glibc the CI runner (Ubuntu 24.04) linked against is compatible
|
||||||
|
# with the installing system's — true for essentially every currently
|
||||||
|
# supported Arch install, since Arch tracks glibc newer than Ubuntu 24.04
|
||||||
|
# ships, and forward compatibility is the direction that holds.
|
||||||
|
pkgname=triple-c-bin
|
||||||
|
pkgver=0.4.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Sandbox Claude Code inside Docker containers"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/shadowdao/triple-c"
|
||||||
|
license=('MIT')
|
||||||
|
# Verified against a real release asset (v0.4.14), not Tauri's generic docs:
|
||||||
|
# downloaded Triple-C_0.4.14_amd64.deb, installed each of these into a real
|
||||||
|
# Arch container, and re-ran `ldd` on the actual binary until nothing came
|
||||||
|
# back "not found". `pango` and `libayatana-appindicator` were both in an
|
||||||
|
# earlier draft — pango isn't directly linked (gtk3 already pulls it in
|
||||||
|
# transitively, and namcap correctly flags declaring it as redundant), and
|
||||||
|
# libayatana-appindicator is in Tauri's own linux dependency list but this
|
||||||
|
# binary never links it at all: there is no tray icon or menu in this app
|
||||||
|
# (see CLAUDE.md's note that `core:menu`/`core:tray` are dropped for the
|
||||||
|
# same reason), so it was never a real dependency to begin with.
|
||||||
|
depends=('cairo' 'desktop-file-utils' 'gdk-pixbuf2' 'glib2' 'gtk3'
|
||||||
|
'hicolor-icon-theme' 'libsoup3' 'webkit2gtk-4.1')
|
||||||
|
optdepends=('docker: to actually run the sandboxed containers'
|
||||||
|
'xdg-utils: opening links from the app in your default browser')
|
||||||
|
provides=('triple-c')
|
||||||
|
conflicts=('triple-c')
|
||||||
|
# !strip: the upstream .deb's binary is already the release build Tauri
|
||||||
|
# produced and tested; re-stripping a prebuilt binary is unnecessary risk for
|
||||||
|
# no benefit. It's also what actually suppresses makepkg's debug-package
|
||||||
|
# machinery here (debug-package extraction requires strip; verified in a
|
||||||
|
# real build — with !strip alone, no debug package is produced at all).
|
||||||
|
# !debug is kept anyway, explicit about intent rather than relying on that
|
||||||
|
# side effect. Without either, makepkg built a usr/src/debug/triple-c-bin
|
||||||
|
# tree containing a dangling .build-id symlink, which is a real namcap
|
||||||
|
# error (not just the empty-directory warning it looks like) — there is no
|
||||||
|
# debug info in this release binary for the machinery to have extracted in
|
||||||
|
# the first place.
|
||||||
|
options=('!strip' '!debug')
|
||||||
|
# Tauri names the asset after `productName` verbatim ("Triple-C"), not the
|
||||||
|
# lowercase Cargo binary name — verified against the real release, not
|
||||||
|
# assumed; a lowercase guess here would 404. The LICENSE fetch is separate
|
||||||
|
# because the .deb itself carries no license file — namcap flags an MIT
|
||||||
|
# package with nothing under /usr/share/licenses/ as an error, correctly.
|
||||||
|
source=("Triple-C_${pkgver}_amd64.deb::https://github.com/shadowdao/triple-c/releases/download/v${pkgver}/Triple-C_${pkgver}_amd64.deb"
|
||||||
|
"LICENSE::https://raw.githubusercontent.com/shadowdao/triple-c/v${pkgver}/LICENSE")
|
||||||
|
sha256sums=('SKIP'
|
||||||
|
'SKIP')
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$srcdir"
|
||||||
|
# A .deb is an ar archive of debian-binary, control.tar.*, data.tar.* — `ar`
|
||||||
|
# (part of base-devel's binutils) pulls just the payload out. Extracting
|
||||||
|
# that tar directly into $pkgdir works here with no path rewriting at all:
|
||||||
|
# verified against the real archive, whose entire payload is
|
||||||
|
# usr/bin/triple-c, usr/share/applications/Triple-C.desktop and
|
||||||
|
# usr/share/icons/hicolor/*/apps/triple-c.png — Tauri's Linux bundle for
|
||||||
|
# this app carries no separate resource directory under usr/lib/, so there
|
||||||
|
# is nothing that could disagree between Debian's and Arch's package trees
|
||||||
|
# for it to land in the wrong place.
|
||||||
|
#
|
||||||
|
# Globbed rather than named literally: the publish workflow discovers the
|
||||||
|
# real asset name from the release itself specifically so a Tauri bundler
|
||||||
|
# naming change can't silently break this — naming the file again here
|
||||||
|
# would throw that away and fail this one line with an opaque "No such
|
||||||
|
# file or directory" instead. `source=()` above guarantees exactly one
|
||||||
|
# `*_amd64.deb` entry, so the glob can only ever match that one file.
|
||||||
|
ar x ./*_amd64.deb
|
||||||
|
tar xf data.tar.* -C "$pkgdir"
|
||||||
|
|
||||||
|
install -Dm644 "$srcdir/LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Arch / CachyOS package
|
||||||
|
|
||||||
|
`PKGBUILD` here is the `triple-c-bin` package's template — see triple-c#34
|
||||||
|
(the "I would like to also have an Arch/CachyOS native version" part of it).
|
||||||
|
It's written to AUR conventions (and may go there eventually — see
|
||||||
|
"Publishing" below) but isn't published to the AUR yet.
|
||||||
|
|
||||||
|
## Why "-bin"
|
||||||
|
|
||||||
|
It repackages the same `.deb` `build-app.yml` already produces, rather than
|
||||||
|
building from source. That means `makepkg` never needs a Rust toolchain,
|
||||||
|
Node, or the dozen `-dev` packages CLAUDE.md lists for building Triple-C
|
||||||
|
itself — and a user gets exactly the binary the project ships and tests,
|
||||||
|
built on Ubuntu 24.04 in CI. Verified end to end against a real release
|
||||||
|
(v0.4.14): downloaded the actual `.deb`, confirmed every `depends` entry
|
||||||
|
against a real `ldd` of the actual binary (two packages that looked right
|
||||||
|
from Tauri's own docs — `pango`, `libayatana-appindicator` — turned out not
|
||||||
|
to be real dependencies of *this* binary and were dropped), and ran a real
|
||||||
|
`makepkg`/`namcap`/`pacman -U` cycle rather than guessing at the shape.
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
`.gitea/workflows/publish-arch-package.yml` does the actual work: given a
|
||||||
|
version (or "latest" if none is given), it finds that release's real Linux
|
||||||
|
asset on GitHub, downloads it, computes real checksums, renders this
|
||||||
|
template into a version-specific PKGBUILD, validates it with `makepkg` and
|
||||||
|
`namcap` inside a real Arch container, and attaches the resulting
|
||||||
|
`.pkg.tar.zst` to that same GitHub release as a downloadable asset —
|
||||||
|
installable by hand with `sudo pacman -U`.
|
||||||
|
|
||||||
|
It is `workflow_dispatch`-only, deliberately — see the workflow file's own
|
||||||
|
header comment for why an automatic trigger isn't safe here (the same reason
|
||||||
|
`sync-release.yml` didn't work and was removed in triple-c#32).
|
||||||
|
|
||||||
|
**Not on the AUR yet.** Publishing there would need a maintainer AUR account
|
||||||
|
and its SSH key added as a secret on this repo — both manual, one-time steps
|
||||||
|
on https://aur.archlinux.org that only a maintainer can do. The workflow's
|
||||||
|
git history still has the AUR-push step from before this was descoped, if
|
||||||
|
that setup happens later and it's worth reinstating.
|
||||||
|
|
||||||
|
## What's hand-maintained vs. generated
|
||||||
|
|
||||||
|
`pkgver`/`pkgrel`/`source`/`sha256sums` in this file are placeholders —
|
||||||
|
the workflow rewrites them for every real publish and never commits the
|
||||||
|
result back here, so don't read this file's `pkgver` as "the last published
|
||||||
|
version." Everything else (`depends`, `pkgdesc`, `package()`) is meant to be
|
||||||
|
edited by hand normally, the same as any other PKGBUILD.
|
||||||
|
|
||||||
|
**A hand-edit made to the rendered PKGBUILD attached to a GitHub release is
|
||||||
|
not this file.** Every run renders fresh from *this* repo's template, so a
|
||||||
|
packaging fix belongs here, not in a downloaded copy — the next dispatch for
|
||||||
|
that version would just overwrite it anyway.
|
||||||
Reference in New Issue
Block a user