Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88d6bed6db | ||
|
|
6cc48b3266 | ||
|
|
0fad306c25 | ||
|
|
8beb62b12c | ||
|
|
f2cfc0be8f | ||
|
|
99c9dd3cc2 | ||
|
|
dd48baac8a | ||
|
|
e63318e04a | ||
|
|
adf9e7d603 | ||
|
|
3c8296843f | ||
|
|
7489516df3 | ||
|
|
6dcdeb89cb | ||
|
|
97e58db3c1 | ||
|
|
a606e3ab20 | ||
|
|
925e51e435 | ||
|
|
722d9aeff1 | ||
|
|
81b1cfba09 | ||
|
|
ca6028bbb3 | ||
|
|
b3d07bda09 | ||
|
|
e025a7441a | ||
|
|
8f62949902 | ||
|
|
6354cb42b2 | ||
|
|
9b55a12b32 | ||
|
|
049232099b | ||
|
|
945883bb9d | ||
|
|
b71e15c2c0 | ||
|
|
06254db3d4 | ||
|
|
61bdbc4a5b | ||
|
|
439ef16f07 | ||
|
|
d8bb5ab262 | ||
|
|
4827170715 | ||
|
|
1a79852f65 | ||
|
|
68b73a9102 | ||
|
|
d09e2a2743 | ||
|
|
4371c9f03e | ||
|
|
eead748222 | ||
|
|
2c9482a67d | ||
|
|
88ffb4744a | ||
|
|
016de8f641 | ||
|
|
4d1a5a2417 | ||
|
|
a323047964 | ||
|
|
11216c45e3 | ||
|
|
913aa85805 | ||
|
|
e9902f0564 | ||
|
|
7488fc5b70 | ||
|
|
06ccb4d818 | ||
|
|
9472cb3c4c | ||
|
|
dd23a52b41 | ||
|
|
168b61d632 | ||
|
|
47960e46df | ||
|
|
73dfaf5785 | ||
|
|
01fd38bc4b | ||
|
|
00128f9b1a | ||
|
|
39934299f9 | ||
|
|
c6086b0ab3 | ||
|
|
f7db4323be | ||
|
|
ed91423666 | ||
|
|
6a8972980d | ||
|
|
7bbb699e4e | ||
|
|
5df3e7996d | ||
|
|
5d4d5d37df | ||
|
|
bb1c7696f9 | ||
|
|
f2a84c18f9 | ||
|
|
b49dddab45 | ||
|
|
dcd2dfe5a3 | ||
|
|
6d27f924ff | ||
|
|
e70a40507c | ||
|
|
5926a52ff6 | ||
|
|
42ef1865cc | ||
|
|
4f6c012071 | ||
|
|
6b8d43414d | ||
|
|
1768240861 | ||
|
|
7e1f8df1ff | ||
|
|
a76f2c0a17 | ||
|
|
17f031a5d7 | ||
|
|
5fba7d6d35 | ||
|
|
433afa5a49 | ||
|
|
fcea506dce | ||
|
|
6abc7f27a4 | ||
|
|
2b6501d8e5 | ||
|
|
3329e07d3d | ||
|
|
092972fe92 | ||
|
|
ae3ca8cda4 | ||
|
|
d6f065a2b6 | ||
|
|
0003793abb | ||
|
|
2b9bf56f25 | ||
|
|
611f67cca7 | ||
|
|
77ef2291d7 | ||
|
|
1c834a0b08 | ||
|
|
0a022dfcf0 | ||
|
|
bb41275cea | ||
|
|
2ca86bb5d8 | ||
|
|
df6d2f1ca4 | ||
|
|
dacc1157ec | ||
|
|
dd2894cc60 | ||
|
|
22d142c70d | ||
|
|
15e05e2197 | ||
|
|
75cace7dde | ||
|
|
24590546e3 | ||
|
|
d971326e4e | ||
|
|
48d0c3249a | ||
|
|
5b96ad4823 | ||
|
|
dcb13d23ea | ||
|
|
7a8bbcbef7 | ||
|
|
3bd3caa101 | ||
|
|
5dd1ab5217 | ||
|
|
92d64cf252 | ||
|
|
ab2c75d0b2 | ||
|
|
00937745f7 | ||
|
|
01e72e4785 | ||
|
|
2b35aa8c16 | ||
|
|
65a3d4eb29 | ||
|
|
2b2d9da606 | ||
|
|
3741e0fef5 | ||
|
|
0e6566d903 | ||
|
|
84a67fcd0d | ||
|
|
f3cc1c4c17 | ||
|
|
7265f55f27 | ||
|
|
88f2e73474 | ||
|
|
fa4940dd7d | ||
|
|
9027fa9ad4 | ||
|
|
be37723c38 | ||
|
|
5f990dd28b | ||
|
|
4df59da2d8 | ||
|
|
a72406f0d8 | ||
|
|
9b2f4fe79f | ||
|
|
e9ec2f8e26 | ||
|
|
fa82d54afa | ||
|
|
4c962ebd9c | ||
|
|
d15faa923b | ||
|
|
e379c58684 | ||
|
|
ab747ce53d | ||
|
|
85ea3956e8 | ||
|
|
5bd80a05bc | ||
|
|
f239fa1c82 | ||
|
|
5b18ce804f | ||
|
|
63f3c54b95 |
@@ -1,23 +1,78 @@
|
||||
name: Build App (Preview)
|
||||
|
||||
# Builds the Tauri app for branches other than main and exposes the bundles as
|
||||
# workflow artifacts. No Gitea release, no GitHub sync — intended for local
|
||||
# smoke-testing of feature branches before they merge.
|
||||
# Builds the Tauri app for branches other than main and publishes the bundles as
|
||||
# a **prerelease**, so they are downloadable from the Releases page. No GitHub
|
||||
# sync.
|
||||
#
|
||||
# The uploads pin actions/upload-artifact@v3 and must not be "upgraded". v4
|
||||
# bundles @actions/artifact v2, which refuses to run before making a single
|
||||
# request whenever GITHUB_SERVER_URL is not github.com:
|
||||
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
|
||||
# a push that breaks any of them fails here. build-app.yml used to do that job
|
||||
# in parallel and publish nothing, which meant six OS builds per push and one
|
||||
# unreachable set of bundles; it is now releases-only.
|
||||
#
|
||||
# isGhes() -> hostname !== 'GITHUB.COM' && !endsWith('.GHE.COM') && !endsWith('.LOCALHOST')
|
||||
# The cost of the swap, stated plainly: one prerelease per PR commit that
|
||||
# touches `app/**` — so the workflow prunes its own, keeping the newest
|
||||
# KEEP_PREVIEWS (see Lifecycle).
|
||||
#
|
||||
# act_runner sets GITHUB_SERVER_URL to this Gitea instance, so v4 fails on every
|
||||
# runner and every OS with "GHESNotSupportedError" — after the whole Tauri build
|
||||
# has been paid for. v3 uses the v1 artifact API, which Gitea implements. (Run
|
||||
# #265 was this workflow's first ever run and lost all three platforms this way.)
|
||||
# The other workflows here never hit it because they publish by curling the
|
||||
# Gitea releases API instead — see build-app.yml.
|
||||
# ## Why not workflow artifacts
|
||||
#
|
||||
# Two attempts failed before this one, and both failure modes are worth knowing:
|
||||
#
|
||||
# * `actions/upload-artifact@v4` cannot run here at all. It bundles
|
||||
# `@actions/artifact` v2, whose `isGhes()` treats any GITHUB_SERVER_URL that
|
||||
# is not github.com / *.ghe.com / *.localhost as GitHub Enterprise Server and
|
||||
# throws before making a single request. act_runner sets that variable to this
|
||||
# Gitea instance, so every platform died with "GHESNotSupportedError" — after
|
||||
# the whole Tauri build had been paid for (run #265).
|
||||
# * `@v3` uploads *succeed*, and the files are downloadable by direct URL — but
|
||||
# Gitea does not **list** them: `/api/v1/…/runs/<id>/artifacts` reports
|
||||
# `total_count: 0` and the run page shows nothing (verified on run #267).
|
||||
# A build nobody can find is not a build.
|
||||
#
|
||||
# So previews publish the same way every other workflow here does: curl to the
|
||||
# Gitea releases API. One release per preview, tagged `preview-<sha>`.
|
||||
#
|
||||
# ## Lifecycle
|
||||
#
|
||||
# The `preview-` tag prefix is deliberate. `cleanup-releases.yml` keeps the most
|
||||
# recent `v<major>.<minor>.<patch>` releases and separately deletes every release
|
||||
# whose tag does *not* start with `v[0-9]` — so previews never crowd the real
|
||||
# release list, and a manual cleanup sweeps any this workflow missed.
|
||||
#
|
||||
# But that cleanup is a manual, dry-run-by-default action, and one prerelease per
|
||||
# pushed commit accumulates faster than anyone runs it. So the last job here
|
||||
# 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.
|
||||
#
|
||||
# A preview release is not meant to reach GitHub. `build-app.yml`'s inline
|
||||
# mirror never sees one (it only runs for its own `push`-triggered release),
|
||||
# but `backfill-releases.yml` pulls every Gitea release unfiltered and would
|
||||
# faithfully forward a preview's `prerelease: true` if it were ever dispatched
|
||||
# while one existed — so `GitHubRelease::prerelease` in `update_commands.rs`
|
||||
# is real defence, not a no-op, even though the `preview-<sha>` tag shape
|
||||
# (never valid semver) already blocks it independently. (The previous
|
||||
# mechanism here, `sync-release.yml`, was `workflow_dispatch`-only and read
|
||||
# `gitea.event.release.*` fields that are only ever populated by a `release`
|
||||
# trigger, so it could never have actually run; deleted rather than fixed,
|
||||
# since build-app.yml's inline mirror already does what it was meant to do
|
||||
# for real releases. See triple-c#32.)
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
# How many preview releases survive a run, newest first — including the one
|
||||
# just published.
|
||||
KEEP_PREVIEWS: "2"
|
||||
|
||||
on:
|
||||
# Every push to an open PR: this *is* the branch's build check — it compiles
|
||||
# Linux, macOS and Windows — and publishing the result costs nothing extra
|
||||
# once they are built. build-app.yml deliberately no longer runs on PRs.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app-preview.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -25,24 +80,150 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.VERSION }}
|
||||
sha: ${{ steps.version.outputs.SHA }}
|
||||
# Everything after the first `-` in VERSION (e.g. `preview.a1b2c3d`).
|
||||
# The bundle version fields never see this — see "Set app version" in
|
||||
# each build job — but it is baked into the binary as
|
||||
# `TRIPLE_C_BUILD_SUFFIX` so `get_app_version()` can still report it.
|
||||
# An installed preview otherwise reports the same bare number a
|
||||
# production build would, indistinguishable in the About panel and to
|
||||
# `check_for_updates`. See triple-c#32.
|
||||
suffix: ${{ steps.version.outputs.SUFFIX }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch all tags
|
||||
run: git fetch --tags
|
||||
|
||||
- name: Compute preview version
|
||||
id: version
|
||||
run: |
|
||||
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
VERSION="${MAJOR_MINOR}.0-preview.${SHORT_SHA}"
|
||||
# From the checkout, not from `gitea.sha`: on a pull_request event
|
||||
# that variable can be the merge ref, which is not the commit anyone
|
||||
# is testing and not something to hang a tag on.
|
||||
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
# The patch number must be the same "one past the highest patch
|
||||
# already used" build-app.yml computes for a real release — not a
|
||||
# distance from the latest tag. It used to be
|
||||
# `git rev-list --count <latest tag>..HEAD`, which build-app.yml's
|
||||
# own history section documents as broken for exactly this reason:
|
||||
# it resets to zero on every tag cut, so previews went *backwards*
|
||||
# (0.4.62 -> 0.4.0) the moment a release landed, and nothing stopped
|
||||
# a preview number from later colliding with a real release's.
|
||||
#
|
||||
# Reading the same `v${MAJOR_MINOR}.*` tags (including the `-mac`
|
||||
# / `-win` suffixed ones a partially-published release can leave
|
||||
# behind) means a preview built right before a release computes the
|
||||
# exact number that release is about to take — e.g. `0.4.13` for
|
||||
# both. That makes the two numerically *equal*, not "preview less
|
||||
# than release" — plain semver ordering does not make a
|
||||
# `-preview.<sha>` suffix sort lower on its own here, because
|
||||
# `check_for_updates` compares against the bare, stripped
|
||||
# `CARGO_PKG_VERSION`, never the suffixed display string. What
|
||||
# closes the loop is `update_commands.rs`'s `is_preview_build`
|
||||
# check, which relaxes that one comparison to `>=` specifically so
|
||||
# "a release exists at my own number" reads as an update. See
|
||||
# triple-c#32.
|
||||
HIGHEST=$(git tag -l "v${MAJOR_MINOR}.*" \
|
||||
| grep -E "^v${MAJOR_MINOR}\.[0-9]+(-mac|-win)?$" \
|
||||
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+).*/\1/" \
|
||||
| sort -n | tail -1 || true)
|
||||
|
||||
# Mirrors build-app.yml's own `EXISTING` guard: this workflow is
|
||||
# also `workflow_dispatch`-able on `main`, not just PR-triggered, so
|
||||
# HEAD can be a commit a release was already cut from. Without this,
|
||||
# dispatching a preview there would compute `HIGHEST + 1` — one past
|
||||
# that release — and produce exactly the "preview outranks
|
||||
# production" failure triple-c#32 was filed over, just reintroduced
|
||||
# through the manual-dispatch door instead of the automatic one.
|
||||
EXISTING=$(git tag --points-at HEAD \
|
||||
| grep -E "^v${MAJOR_MINOR}\.[0-9]+$" \
|
||||
| sed -E "s/^v${MAJOR_MINOR}\.([0-9]+)$/\1/" \
|
||||
| sort -n | tail -1 || true)
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "HEAD is already tagged v${MAJOR_MINOR}.${EXISTING} — matching it"
|
||||
PATCH="${EXISTING}"
|
||||
elif [ -n "$HIGHEST" ]; then
|
||||
echo "Highest patch already used on this line: ${HIGHEST}"
|
||||
PATCH=$((HIGHEST + 1))
|
||||
else
|
||||
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||
PATCH=0
|
||||
fi
|
||||
|
||||
SUFFIX="preview.${SHORT_SHA}"
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}-${SUFFIX}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT
|
||||
echo "Computed preview version: ${VERSION}"
|
||||
|
||||
# One release, created once. The three build jobs run concurrently, so
|
||||
# get-or-create in each of them would race on the same tag: whoever loses gets
|
||||
# a 409 and (the way the old build-app.yml parsed it) an empty release id that
|
||||
# still reported success. Creating it in a job they all depend on removes the
|
||||
# race rather than handling it.
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version]
|
||||
outputs:
|
||||
release_id: ${{ steps.release.outputs.RELEASE_ID }}
|
||||
tag: ${{ steps.release.outputs.TAG }}
|
||||
steps:
|
||||
- name: Create the preview release
|
||||
id: release
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||
SHA: ${{ needs.compute-version.outputs.sha }}
|
||||
BRANCH: ${{ gitea.head_ref || gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="preview-${VERSION##*.}"
|
||||
echo "TAG=${TAG}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Idempotent: re-dispatching the same commit must update the existing
|
||||
# release rather than fail on the duplicate tag.
|
||||
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||
case "${HTTP_CODE}" in
|
||||
200) echo "Release ${TAG} already exists, reusing" ;;
|
||||
404)
|
||||
echo "Creating release ${TAG}"
|
||||
# prerelease: true keeps it off "latest" — this is a branch build,
|
||||
# not something anyone should install by accident.
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"target_commitish\": \"${SHA}\", \"name\": \"Preview ${VERSION}\", \"prerelease\": true, \"body\": \"Unreleased build of \`${BRANCH}\` at ${SHA}. Not a release — pruned by Cleanup Old Releases.\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
|
||||
cat release.json >&2 || true
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
echo "Failed to parse release id; response was:" >&2
|
||||
cat release.json >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "RELEASE_ID=${RELEASE_ID}" >> $GITHUB_OUTPUT
|
||||
echo "Release ${TAG} is id ${RELEASE_ID}"
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version]
|
||||
needs: [compute-version, create-release]
|
||||
steps:
|
||||
- name: Install Node.js 22
|
||||
run: |
|
||||
@@ -129,6 +310,13 @@ jobs:
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: ./app
|
||||
env:
|
||||
# Baked into the binary via `option_env!` in `get_app_version()` —
|
||||
# the bundle version above stays bare (WiX/MSI's ProductVersion has
|
||||
# no room for a suffix), so this is the only place a preview build
|
||||
# can still tell itself apart from a production one. See
|
||||
# triple-c#32.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build
|
||||
@@ -141,18 +329,47 @@ jobs:
|
||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||
ls -la artifacts/
|
||||
|
||||
# v3, not v4, and it must stay v3 — see the note at the top of this file.
|
||||
- name: Upload Linux artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: triple-c-${{ needs.compute-version.outputs.version }}-linux
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||
- name: Upload Linux bundles to the preview release
|
||||
shell: bash
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
files=(artifacts/*)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No Linux bundles were produced" >&2
|
||||
exit 1
|
||||
fi
|
||||
for file in "${files[@]}"; do
|
||||
filename=$(basename "$file")
|
||||
EXISTING_ID=$(curl -sS \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||
if [ -n "${EXISTING_ID}" ]; then
|
||||
echo "Replacing existing asset ${filename}"
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||
fi
|
||||
echo "Uploading ${filename}..."
|
||||
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${file}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
done
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
needs: [compute-version]
|
||||
needs: [compute-version, create-release]
|
||||
steps:
|
||||
- name: Install Node.js 22
|
||||
run: |
|
||||
@@ -212,6 +429,9 @@ jobs:
|
||||
|
||||
- name: Build Tauri app (universal)
|
||||
working-directory: ./app
|
||||
env:
|
||||
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
npx tauri build --target universal-apple-darwin
|
||||
@@ -223,17 +443,47 @@ jobs:
|
||||
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
||||
ls -la artifacts/
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
uses: actions/upload-artifact@v3 # v3 deliberately — see the top of this file
|
||||
with:
|
||||
name: triple-c-${{ needs.compute-version.outputs.version }}-macos
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||
- name: Upload macOS bundles to the preview release
|
||||
shell: bash
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
files=(artifacts/*)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No macOS bundles were produced" >&2
|
||||
exit 1
|
||||
fi
|
||||
for file in "${files[@]}"; do
|
||||
filename=$(basename "$file")
|
||||
EXISTING_ID=$(curl -sS \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||
if [ -n "${EXISTING_ID}" ]; then
|
||||
echo "Replacing existing asset ${filename}"
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||
fi
|
||||
echo "Uploading ${filename}..."
|
||||
curl -fsS --http1.1 --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${file}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
|
||||
done
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
needs: [compute-version]
|
||||
needs: [compute-version, create-release]
|
||||
defaults:
|
||||
run:
|
||||
shell: cmd
|
||||
@@ -310,6 +560,8 @@ jobs:
|
||||
working-directory: ./app
|
||||
env:
|
||||
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||
run: |
|
||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||
cargo tauri build
|
||||
@@ -322,10 +574,78 @@ jobs:
|
||||
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
||||
dir artifacts\
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
uses: actions/upload-artifact@v3 # v3 deliberately — see the top of this file
|
||||
with:
|
||||
name: triple-c-${{ needs.compute-version.outputs.version }}-windows
|
||||
path: artifacts/
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
# PowerShell, because this job's default shell is cmd. Same
|
||||
# delete-then-upload shape as the other two.
|
||||
- name: Upload Windows bundles to the preview release
|
||||
shell: powershell
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$headers = @{ Authorization = "token $env:TOKEN" }
|
||||
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
|
||||
$files = @(Get-ChildItem -File -Path artifacts\*)
|
||||
if ($files.Count -eq 0) { throw "No Windows bundles were produced" }
|
||||
|
||||
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets"
|
||||
foreach ($file in $files) {
|
||||
$name = $file.Name
|
||||
$dupe = $existing | Where-Object { $_.name -eq $name }
|
||||
if ($dupe) {
|
||||
Write-Host "Replacing existing asset $name"
|
||||
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$env:RELEASE_ID/assets/$($dupe.id)" | Out-Null
|
||||
}
|
||||
Write-Host "Uploading $name..."
|
||||
$uploadUri = "$api/releases/$env:RELEASE_ID/assets?name=$([uri]::EscapeDataString($name))"
|
||||
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
|
||||
-X POST -H "Authorization: token $env:TOKEN" `
|
||||
-H "Content-Type: application/octet-stream" `
|
||||
--data-binary "@$($file.FullName)" $uploadUri
|
||||
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
# Keep the preview list short. Runs after the builds and only if all three
|
||||
# succeeded: a half-published run must not be what evicts a good older build.
|
||||
prune-previews:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [create-release, build-linux, build-macos, build-windows]
|
||||
steps:
|
||||
- name: Delete all but the newest preview releases
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
KEEP_TAG: ${{ needs.create-release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsS -H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50" > releases.json
|
||||
|
||||
# Newest first by creation time, `preview-` only, and never the one
|
||||
# this run just published — a clock skew must not delete it.
|
||||
DOOMED=$(python3 - "${KEEP_PREVIEWS}" "${KEEP_TAG}" <<'PY'
|
||||
import json, sys
|
||||
keep, keep_tag = int(sys.argv[1]), sys.argv[2]
|
||||
previews = [r for r in json.load(open("releases.json"))
|
||||
if r["tag_name"].startswith("preview-")]
|
||||
previews.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
for r in previews[keep:]:
|
||||
if r["tag_name"] != keep_tag:
|
||||
print(r["id"], r["tag_name"])
|
||||
PY
|
||||
)
|
||||
|
||||
if [ -z "${DOOMED}" ]; then
|
||||
echo "Nothing to prune (keeping ${KEEP_PREVIEWS})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "${DOOMED}" | while read -r ID TAG; do
|
||||
[ -z "${ID}" ] && continue
|
||||
echo "Deleting ${TAG} (id ${ID})"
|
||||
# Best effort: a preview someone deleted by hand mid-run is not a
|
||||
# reason to fail a build that otherwise succeeded.
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || true
|
||||
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" || true
|
||||
done
|
||||
|
||||
@@ -7,14 +7,14 @@ on:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
# Deliberately **not** on pull_request. Every publishing step here is gated on
|
||||
# `gitea.event_name == 'push'`, so a PR run compiled all three platforms and
|
||||
# produced nothing — and it ran alongside build-app-preview.yml, which compiles
|
||||
# the same three and publishes them. Six OS builds per push, one set of which
|
||||
# was unreachable. Previews now carry the PR check; this workflow is releases.
|
||||
|
||||
env:
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
@@ -39,16 +39,55 @@ jobs:
|
||||
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||
echo "Major.Minor: ${MAJOR_MINOR}"
|
||||
|
||||
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
|
||||
# `|| true` so an empty grep result doesn't fail the step under pipefail.
|
||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
||||
# The patch number is **one past the highest patch already used**, and
|
||||
# never a distance.
|
||||
#
|
||||
# It used to be `git rev-list --count <highest tag>..HEAD`, which is
|
||||
# not a counter at all: it measures how far HEAD has drifted from
|
||||
# whichever tag sorts highest, and that resets to zero every time a
|
||||
# tag is cut. The published history is the proof — each of these is
|
||||
# exactly what the old formula returned at the time:
|
||||
#
|
||||
# v0.4.0 -> 3 commits -> v0.4.3 looked fine
|
||||
# v0.4.3 -> 4 commits -> v0.4.4 fine by luck, 4 > 3
|
||||
# v0.4.4 -> 2 commits -> v0.4.2 went backwards
|
||||
# v0.4.4 -> 6 commits -> v0.4.6 jumped, skipping .5
|
||||
# v0.4.6 -> 3 commits -> v0.4.3 already taken; the upload failed
|
||||
#
|
||||
# Reusing a version is worse than failing to publish one: the macOS
|
||||
# and Windows steps replace assets in place, so a duplicate silently
|
||||
# rewrote a release that had been public for three days. Monotonic
|
||||
# numbering is what stops that at the source.
|
||||
#
|
||||
# Suffixed tags count too. `create-tag` is skipped when any platform
|
||||
# job fails, so a run can publish v0.4.7-mac and never create the
|
||||
# plain v0.4.7 — reading only unsuffixed tags would then hand the
|
||||
# same number out twice.
|
||||
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)
|
||||
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
echo "Latest matching tag: ${LATEST_TAG}"
|
||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||
# A re-run of a commit that already released must not mint a new
|
||||
# version just because its own tag now exists.
|
||||
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} — reusing it"
|
||||
PATCH="${EXISTING}"
|
||||
elif [ -n "$HIGHEST" ]; then
|
||||
echo "Highest patch already used on this line: ${HIGHEST}"
|
||||
PATCH=$((HIGHEST + 1))
|
||||
else
|
||||
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
|
||||
PATCH=$(git rev-list --count HEAD)
|
||||
# A minor line nobody has tagged yet is a *new* line, and a new line
|
||||
# starts at .0 — that is what "we are moving to 0.4.x" means. The
|
||||
# old fallback here counted every commit in the repository, which
|
||||
# would have made the first 0.4 build 0.4.234.
|
||||
echo "No v${MAJOR_MINOR}.* tag yet — starting this line at .0"
|
||||
PATCH=0
|
||||
fi
|
||||
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}"
|
||||
@@ -161,21 +200,70 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="v${{ needs.compute-version.outputs.version }}"
|
||||
# Create release
|
||||
curl -s -X POST \
|
||||
|
||||
# Idempotent get-or-create, matching build-macos. This step used to
|
||||
# POST /releases unconditionally: against a tag that already existed
|
||||
# Gitea answered 409, the grep below found no id, and the run died
|
||||
# with a bare "exitcode '1'" and not one line of output explaining
|
||||
# it — `curl -s` with no `-f` swallows the HTTP error, so nothing
|
||||
# ever said "409" or "duplicate tag". Hence -fsS throughout, and
|
||||
# pipefail so a failure cannot be stepped over.
|
||||
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||
case "${HTTP_CODE}" in
|
||||
200)
|
||||
echo "Release ${TAG} already exists, reusing"
|
||||
;;
|
||||
404)
|
||||
echo "Creating release ${TAG}"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected ${HTTP_CODE} looking up release ${TAG}:" >&2
|
||||
cat release.json >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
RELEASE_ID=$(python3 -c "import json,sys; print(json.load(open('release.json')).get('id',''))")
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
echo "No release id for ${TAG}; refusing to upload into nothing:" >&2
|
||||
cat release.json >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
# Upload each artifact
|
||||
|
||||
# Replace-not-conflict, so a retry after a partial upload succeeds.
|
||||
# Versions are monotonic now (see compute-version), so this can only
|
||||
# ever be replacing an asset from a failed run of this same commit —
|
||||
# never one belonging to an already-published version.
|
||||
for file in artifacts/*; do
|
||||
[ -f "$file" ] || continue
|
||||
filename=$(basename "$file")
|
||||
|
||||
EXISTING_ID=$(curl -sS \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
|
||||
if [ -n "${EXISTING_ID}" ]; then
|
||||
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||
fi
|
||||
|
||||
echo "Uploading ${filename}..."
|
||||
curl -s -X POST \
|
||||
curl -fsS --http1.1 \
|
||||
--retry 5 --retry-all-errors --retry-delay 5 \
|
||||
--max-time 600 \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${file}" \
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
name: Publish Arch Package
|
||||
|
||||
# Builds the `triple-c-bin` Arch package (packaging/arch/PKGBUILD) for a
|
||||
# given release, or the latest one if none is given, and attaches the built
|
||||
# .pkg.tar.zst to that release on GitHub as a downloadable asset. Manual
|
||||
# dispatch only — deliberately not triggered by `release` or `push`, for the
|
||||
# same reason sync-release.yml (removed in triple-c#32) never worked safely
|
||||
# as an automatic trigger: this repo's releases are assembled by
|
||||
# build-app.yml across three separate platform jobs, and there is no single
|
||||
# automatic event that fires only once everything (including the Linux .deb
|
||||
# this workflow needs) is actually uploaded. A human deciding "this release
|
||||
# is ready, go package it" is the correct trigger, the same reasoning
|
||||
# backfill-releases.yml already uses for its own manual-only GitHub sync.
|
||||
#
|
||||
# ## What this does and does not do
|
||||
#
|
||||
# It renders `packaging/arch/PKGBUILD` for one specific version (real
|
||||
# download URL, real sha256sums — never guessed; see the resolve-asset step),
|
||||
# validates it with `makepkg`/`namcap` in a real Arch container, and attaches
|
||||
# the resulting `.pkg.tar.zst` — installable by hand with `pacman -U` — to
|
||||
# *both* the GitHub release it was built from and the corresponding Gitea
|
||||
# release (the plain, unsuffixed `vX.Y.Z` tag build-app.yml's Linux job
|
||||
# creates; the `-win`/`-mac` suffixed Gitea releases are a different tag and
|
||||
# don't get this asset). It does NOT commit anything back to this repo —
|
||||
# `packaging/arch/PKGBUILD` stays a hand-maintained template with a
|
||||
# placeholder version, and the workflow never starts from or writes to it.
|
||||
#
|
||||
# ## Not published to the AUR (yet)
|
||||
#
|
||||
# This originally also pushed the rendered PKGBUILD to an AUR git repo, which
|
||||
# needs a maintainer AUR account and its SSH key registered as a secret here
|
||||
# — both manual, one-time steps neither this workflow nor anyone but a
|
||||
# maintainer can do. Until that setup happens, a downloadable release asset
|
||||
# gets the same package to users without it. The AUR push step is still in
|
||||
# this file's git history (see the commit that added this comment) if that
|
||||
# setup is ever done and it's worth reinstating.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: >-
|
||||
Release version to package, without a leading "v" (e.g. "0.4.14").
|
||||
Leave empty to use the latest published GitHub release.
|
||||
required: false
|
||||
|
||||
env:
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve version and find the Linux asset
|
||||
id: resolve
|
||||
env:
|
||||
VERSION_INPUT: ${{ inputs.version }}
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Authenticated when the secret is available (it is, everywhere
|
||||
# else in this repo's workflows) to avoid the unauthenticated
|
||||
# 60-requests/hour-per-IP cap; still works without it, just at that
|
||||
# lower limit, since this hits nothing but a public repo's public
|
||||
# releases.
|
||||
AUTH=()
|
||||
[ -n "${GH_PAT}" ] && AUTH=(-H "Authorization: Bearer ${GH_PAT}")
|
||||
|
||||
if [ -z "${VERSION_INPUT}" ]; then
|
||||
echo "No version given — resolving the latest GitHub release"
|
||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/latest")
|
||||
else
|
||||
echo "Using requested version ${VERSION_INPUT}"
|
||||
RELEASE_JSON=$(curl -fsS "${AUTH[@]}" "https://api.github.com/repos/${GITHUB_REPO}/releases/tags/v${VERSION_INPUT}")
|
||||
fi
|
||||
|
||||
TAG=$(echo "$RELEASE_JSON" | jq -r '.tag_name')
|
||||
VERSION="${TAG#v}"
|
||||
echo "Resolved to ${TAG}"
|
||||
|
||||
# Discovered from the real release, not assumed: Tauri names the
|
||||
# asset after `productName` verbatim ("Triple-C"), not the
|
||||
# lowercase Cargo binary name, and asset naming is exactly the kind
|
||||
# of thing that silently drifts if a future Tauri upgrade changes
|
||||
# bundler defaults — a hardcoded pattern here would then 404
|
||||
# forever until someone noticed. `head -1` guards against a release
|
||||
# somehow carrying more than one matching asset, which would
|
||||
# otherwise pass the emptiness check below and then break the
|
||||
# download step with two URLs on one line.
|
||||
DEB_URL=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .browser_download_url' | head -1)
|
||||
DEB_NAME=$(echo "$RELEASE_JSON" | jq -r '.assets[] | select(.name | endswith("_amd64.deb")) | .name' | head -1)
|
||||
if [ -z "$DEB_URL" ] || [ "$DEB_URL" = "null" ]; then
|
||||
echo "No *_amd64.deb asset found on release ${TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Found asset: ${DEB_NAME}"
|
||||
|
||||
# For attaching the built package to this same release later —
|
||||
# every release object carries its own `upload_url` regardless of
|
||||
# whether it was just created or (as here) already existed, and
|
||||
# the `{?name,label}` URI-template suffix has to come off before
|
||||
# this is usable as a plain URL to POST to.
|
||||
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
|
||||
UPLOAD_URL=$(echo "$RELEASE_JSON" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_url=${DEB_URL}" >> "$GITHUB_OUTPUT"
|
||||
echo "deb_name=${DEB_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_id=${RELEASE_ID}" >> "$GITHUB_OUTPUT"
|
||||
echo "upload_url=${UPLOAD_URL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download the release asset and compute real checksums
|
||||
id: checksums
|
||||
env:
|
||||
DEB_URL: ${{ steps.resolve.outputs.deb_url }}
|
||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "${DEB_NAME}" "${DEB_URL}"
|
||||
curl -fsSL -o LICENSE "https://raw.githubusercontent.com/${GITHUB_REPO}/${TAG}/LICENSE"
|
||||
|
||||
echo "deb_sha256=$(sha256sum "${DEB_NAME}" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
echo "license_sha256=$(sha256sum LICENSE | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Render PKGBUILD
|
||||
id: render
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
DEB_NAME: ${{ steps.resolve.outputs.deb_name }}
|
||||
DEB_SHA256: ${{ steps.checksums.outputs.deb_sha256 }}
|
||||
LICENSE_SHA256: ${{ steps.checksums.outputs.license_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p rendered
|
||||
cp packaging/arch/PKGBUILD rendered/PKGBUILD
|
||||
cd rendered
|
||||
|
||||
# Plain string replacement throughout, not sed — the source URL
|
||||
# contains slashes and the repo name does too, and getting a sed
|
||||
# delimiter choice AND its escaping right for that is exactly the
|
||||
# kind of thing that looks correct, passes review, and breaks the
|
||||
# next time someone touches it. `re.sub` with `count=1` and an
|
||||
# exact `.format`-free literal match is boring and that's the
|
||||
# point: every substitution below fails loudly (an assertion /
|
||||
# the checks after) rather than silently no-op'ing if the
|
||||
# template's shape ever drifts from what this expects.
|
||||
#
|
||||
# pkgrel resets to 1 for a new pkgver — a packaging-only fix to the
|
||||
# same upstream version (a dependency bump, say) is what pkgrel is
|
||||
# for, and this workflow always republishes the current PKGBUILD
|
||||
# verbatim rather than incrementing anything, so 1 is always
|
||||
# correct for what this workflow does. It is NOT correct for a
|
||||
# dependency-only fix republished at the *same* pkgver: pkgrel
|
||||
# would be forced back to 1, and no existing installation sees an
|
||||
# upgrade. That case needs a manual pkgrel bump in the template
|
||||
# before dispatching, which this workflow has no input for.
|
||||
python3 - "$VERSION" "$DEB_NAME" "$DEB_SHA256" "$LICENSE_SHA256" "$GITHUB_REPO" <<'PY'
|
||||
import re, sys
|
||||
version, deb_name, deb_sha, license_sha, github_repo = sys.argv[1:6]
|
||||
|
||||
with open("PKGBUILD") as f:
|
||||
text = f.read()
|
||||
|
||||
text, n = re.subn(r"(?m)^pkgver=.*$", f"pkgver={version}", text, count=1)
|
||||
assert n == 1, "pkgver=... line not found"
|
||||
text, n = re.subn(r"(?m)^pkgrel=.*$", "pkgrel=1", text, count=1)
|
||||
assert n == 1, "pkgrel=... line not found"
|
||||
|
||||
# Built with a "$" variable and plain "+" concatenation rather than
|
||||
# an f-string's double-brace escape for a literal brace: writing
|
||||
# this as an f-string put a dollar sign directly against two open
|
||||
# braces, right here in this workflow's own YAML text — and this
|
||||
# runner's own expression templating scans a run: block for that
|
||||
# exact two-character opening sequence and tries to evaluate
|
||||
# whatever sits inside as one of ITS OWN expressions (a step
|
||||
# output, a secret, ...) before the shell ever sees this script.
|
||||
# "pkgver" isn't one of those, so that lookup failed and silently
|
||||
# emptied this whole step rather than raising anything here.
|
||||
# Spelling the dollar sign out of a variable instead means this
|
||||
# file's own text never contains that trigger sequence.
|
||||
DOLLAR = "$"
|
||||
old_source = (
|
||||
"source=(\"Triple-C_" + DOLLAR + "{pkgver}_amd64.deb::"
|
||||
+ "https://github.com/" + github_repo + "/releases/download/v" + DOLLAR + "{pkgver}/"
|
||||
+ "Triple-C_" + DOLLAR + "{pkgver}_amd64.deb\""
|
||||
)
|
||||
new_source = (
|
||||
f'source=("{deb_name}::'
|
||||
f'https://github.com/{github_repo}/releases/download/v{version}/{deb_name}"'
|
||||
)
|
||||
assert old_source in text, "source=() line does not match the expected template shape"
|
||||
text = text.replace(old_source, new_source, 1)
|
||||
|
||||
old_sums = "sha256sums=('SKIP'\n 'SKIP')"
|
||||
assert old_sums in text, "sha256sums=() placeholders not found"
|
||||
text = text.replace(old_sums, f"sha256sums=('{deb_sha}'\n '{license_sha}')", 1)
|
||||
|
||||
with open("PKGBUILD", "w") as f:
|
||||
f.write(text)
|
||||
PY
|
||||
|
||||
grep -q "pkgver=${VERSION}$" PKGBUILD
|
||||
! grep -q "SKIP" PKGBUILD
|
||||
|
||||
- name: Validate with makepkg and namcap
|
||||
id: build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# A bind mount (`docker run -v "$PWD/...":/work`) is the more
|
||||
# obvious way to write this, and was the first draft — but on a
|
||||
# containerized Gitea act_runner job, `$PWD` is a path inside this
|
||||
# job's own container, which the daemon's host cannot resolve; the
|
||||
# mount would silently attach an empty directory instead of failing
|
||||
# loudly. `docker cp` moves real bytes across that boundary
|
||||
# regardless of where the daemon actually lives, which is what
|
||||
# makes this work under both a bind-mount-capable runner and a
|
||||
# containerized one.
|
||||
docker pull archlinux:latest
|
||||
CID=$(docker create -w /work archlinux:latest bash -c '
|
||||
set -euo pipefail
|
||||
pacman -Syu --noconfirm --needed base-devel namcap sudo git openssh >/dev/null
|
||||
useradd -m builder
|
||||
chown -R builder:builder /work
|
||||
echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder
|
||||
sudo -u builder bash -c "cd /work && makepkg --printsrcinfo > .SRCINFO"
|
||||
sudo -u builder bash -c "cd /work && makepkg -s --noconfirm"
|
||||
# Named once here, inside the container, rather than guessed
|
||||
# from options=(!strip !debug) plus pkgver/pkgrel/arch on the
|
||||
# host after the fact — makepkg is the one place that actually
|
||||
# knows its own output name, and `!debug` already guarantees
|
||||
# this glob can only ever match the one real package (no
|
||||
# -debug split package gets produced).
|
||||
basename /work/*.pkg.tar.* > /work/.pkgfile
|
||||
echo "--- namcap ---"
|
||||
NAMCAP_OUT=$(sudo -u builder bash -c "cd /work && namcap PKGBUILD *.pkg.tar.*" || true)
|
||||
echo "$NAMCAP_OUT"
|
||||
# Matches "triple-c-bin E:", "PKGBUILD (triple-c-bin) E:" and any
|
||||
# split-package variant ("triple-c-bin-debug E:") alike — namcap
|
||||
# uses more than one line shape for its two rule families, and
|
||||
# namcap itself exits 0 regardless of what it reports, so this
|
||||
# grep is the only thing standing between an E: and a green job.
|
||||
if echo "$NAMCAP_OUT" | grep -q " E: "; then
|
||||
echo "namcap reported an error — see above" >&2
|
||||
exit 1
|
||||
fi
|
||||
')
|
||||
mkdir -p rendered
|
||||
docker cp rendered/. "${CID}:/work"
|
||||
# `docker start -a` streams output and its exit code is the
|
||||
# container's own — the same failure this would have hit with a
|
||||
# bind mount still fails the job the same way.
|
||||
docker start -a "${CID}"
|
||||
docker cp "${CID}:/work/.SRCINFO" rendered/.SRCINFO
|
||||
docker cp "${CID}:/work/.pkgfile" rendered/.pkgfile
|
||||
PKG_FILE=$(cat rendered/.pkgfile)
|
||||
docker cp "${CID}:/work/${PKG_FILE}" "rendered/${PKG_FILE}"
|
||||
docker rm -f "${CID}" >/dev/null
|
||||
|
||||
echo "pkg_file=${PKG_FILE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Attach the package to the GitHub release
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
RELEASE_ID: ${{ steps.resolve.outputs.release_id }}
|
||||
UPLOAD_URL: ${{ steps.resolve.outputs.upload_url }}
|
||||
PKG_FILE: ${{ steps.build.outputs.pkg_file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_PAT}" ]; then
|
||||
echo "GH_PAT is not set — this step needs it to attach a release asset." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A manual re-dispatch for a version that's already been packaged
|
||||
# would otherwise hit GitHub's 422 "already_exists" here instead
|
||||
# of just replacing the stale build with this one.
|
||||
EXISTING_ID=$(curl -fsS -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/${RELEASE_ID}/assets" \
|
||||
| jq -r --arg name "$PKG_FILE" '.[] | select(.name == $name) | .id')
|
||||
if [ -n "$EXISTING_ID" ]; then
|
||||
echo "Replacing the existing ${PKG_FILE} (asset id ${EXISTING_ID}) already on ${TAG}"
|
||||
curl -fsS -X DELETE -H "Authorization: Bearer ${GH_PAT}" -H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/${GITHUB_REPO}/releases/assets/${EXISTING_ID}"
|
||||
fi
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer ${GH_PAT}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@rendered/${PKG_FILE}" \
|
||||
"${UPLOAD_URL}?name=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "${PKG_FILE}")" \
|
||||
> /dev/null
|
||||
|
||||
echo "Attached ${PKG_FILE} to ${TAG} on GitHub"
|
||||
|
||||
- name: Attach the package to the Gitea release
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
PKG_FILE: ${{ steps.build.outputs.pkg_file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Same get-or-create-by-tag, delete-existing-asset,
|
||||
# upload-as-octet-stream shape build-app.yml's own Gitea upload
|
||||
# step already uses — this is expected to always hit the "reuse"
|
||||
# branch, since build-app.yml's Linux job already created this
|
||||
# exact release for this exact tag; the create fallback is here
|
||||
# only so this doesn't hard-depend on that ordering.
|
||||
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
|
||||
case "${HTTP_CODE}" in
|
||||
200)
|
||||
echo "Release ${TAG} already exists on Gitea, reusing"
|
||||
;;
|
||||
404)
|
||||
echo "Creating release ${TAG} on Gitea"
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected ${HTTP_CODE} looking up release ${TAG} on Gitea:" >&2
|
||||
cat release.json >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
RELEASE_ID=$(python3 -c "import json; print(json.load(open('release.json')).get('id',''))")
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
echo "No Gitea release id for ${TAG}; refusing to upload into nothing:" >&2
|
||||
cat release.json >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXISTING_ID=$(curl -sS \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${PKG_FILE}")
|
||||
if [ -n "${EXISTING_ID}" ]; then
|
||||
echo "Replacing the existing ${PKG_FILE} (asset id ${EXISTING_ID}) already on ${TAG}"
|
||||
curl -fsS -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||
fi
|
||||
|
||||
curl -fsS --http1.1 \
|
||||
--retry 5 --retry-all-errors --retry-delay 5 \
|
||||
--max-time 600 \
|
||||
-X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@rendered/${PKG_FILE}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${PKG_FILE}"
|
||||
|
||||
echo "Attached ${PKG_FILE} to ${TAG} on Gitea"
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Secret Scan
|
||||
|
||||
# **No `paths:` filter, deliberately.** The credential this exists for lived in
|
||||
# `app/src-tauri/src/docker/container.rs`, which `build.yml` would have skipped —
|
||||
# that workflow only runs for `container/**`. A scan that can be avoided by
|
||||
# touching the wrong directory is not a scan.
|
||||
#
|
||||
# This is the half of the check that nobody can bypass. The pre-commit hook in
|
||||
# `.githooks/` is faster and friendlier, but it is opt-in per clone and
|
||||
# `--no-verify` skips it; both are true of every git hook and neither is fixable
|
||||
# from inside a repository.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The whole tracked tree, not just the diff. Scanning a range is cheaper
|
||||
# but depends on getting the range right across pushes, force-pushes,
|
||||
# merges and PR events — and a wrong range fails *open*. The full scan
|
||||
# takes under half a second on this repository and cannot be evaded by
|
||||
# arranging for the interesting commit to sit outside the window.
|
||||
- name: Scan tracked files for credentials
|
||||
run: sh scripts/scan-secrets.sh --tracked
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Sync Release to GitHub
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mirror release to GitHub
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
GITHUB_REPO: shadowdao/triple-c
|
||||
RELEASE_TAG: ${{ gitea.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ gitea.event.release.name }}
|
||||
RELEASE_BODY: ${{ gitea.event.release.body }}
|
||||
IS_PRERELEASE: ${{ gitea.event.release.prerelease }}
|
||||
IS_DRAFT: ${{ gitea.event.release.draft }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
echo "==> Creating release $RELEASE_TAG on GitHub..."
|
||||
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/$GITHUB_REPO/releases \
|
||||
-d "{
|
||||
\"tag_name\": \"$RELEASE_TAG\",
|
||||
\"name\": \"$RELEASE_NAME\",
|
||||
\"body\": $(echo "$RELEASE_BODY" | jq -Rs .),
|
||||
\"draft\": $IS_DRAFT,
|
||||
\"prerelease\": $IS_PRERELEASE
|
||||
}")
|
||||
|
||||
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
|
||||
echo "Release created. Upload URL: $UPLOAD_URL"
|
||||
|
||||
echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do
|
||||
ASSET_NAME=$(echo "$asset" | jq -r '.name')
|
||||
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
|
||||
|
||||
echo "==> Downloading asset: $ASSET_NAME"
|
||||
curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL"
|
||||
|
||||
echo "==> Uploading $ASSET_NAME to GitHub..."
|
||||
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GH_PAT" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@/tmp/$ASSET_NAME" \
|
||||
"$UPLOAD_URL?name=$ENCODED_NAME"
|
||||
|
||||
echo " Uploaded: $ASSET_NAME"
|
||||
done
|
||||
|
||||
echo "==> Release sync complete."
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Refuse a commit that adds something shaped like a live credential.
|
||||
#
|
||||
# Installed by pointing git at this directory:
|
||||
#
|
||||
# git config core.hooksPath .githooks
|
||||
#
|
||||
# which `npm run hooks` in app/ does for you. It is per-clone — git will not let
|
||||
# a repository configure its own hooks path, for the obvious reason that cloning
|
||||
# a repo would then be enough to run its code. So this is opt-in on every
|
||||
# machine, `--no-verify` skips it, and neither of those is a flaw to fix here:
|
||||
# the CI job in `.gitea/workflows/build.yml` is the half nobody can bypass. The
|
||||
# hook exists to tell you in one second rather than in five minutes.
|
||||
exec "$(git rev-parse --show-toplevel)/scripts/scan-secrets.sh" --staged
|
||||
@@ -3,3 +3,13 @@ app/dist/
|
||||
app/src-tauri/target/
|
||||
Screenshot*.png
|
||||
code-review.md
|
||||
|
||||
# Windows NTFS alternate-data-stream artifacts, created when files arrive
|
||||
# through the WSL/host bind mount.
|
||||
*:Zone.Identifier
|
||||
|
||||
# Local bug-report screenshots, same spirit as Screenshot*.png above.
|
||||
screenshot_for_fix/
|
||||
|
||||
# Package files pulled in by ad-hoc verification runs.
|
||||
*.deb
|
||||
|
||||
@@ -79,7 +79,62 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
||||
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||
modals — see "UI conventions" below.
|
||||
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth
|
||||
- **The Files pane's host transfers open their dialog from Rust, and that is the whole
|
||||
design — do not move it back into the webview.** The tab browses, views (text and image),
|
||||
renames and creates folders inside the container (`list_container_files`,
|
||||
`read_container_file`, `rename_container_path`, `create_container_directory`), and it
|
||||
copies single files in and out (`upload_files_to_container`, `download_container_file`).
|
||||
The second pair call `pick_files_to_upload` / `pick_save_path`, which drive
|
||||
`tauri-plugin-dialog` from the *backend*: the webview can ask for a picker and that is the
|
||||
entirety of its influence — it cannot name a host path as an *input*. The claim stops
|
||||
there and should not be widened: host paths still travel outward in error text, canonical
|
||||
ones included. What is closed is the direction that produced the criticals.
|
||||
That shape is not decoration. Four successive audits found that host filesystem paths
|
||||
crossing IPC were where the criticals lived — a caller-named host destination for
|
||||
container-controlled bytes, an arbitrary host source read into the container, a `link(2)`
|
||||
upload reservation that succeeded against a directory and failed forever on any filesystem
|
||||
without hard links. The feature was removed rather than fixed a fifth time, and it came
|
||||
back only in the shape that removes the class: a frontend-driven dialog handing Rust a
|
||||
string is the exact thing that failed, so re-introducing `open()`/`save()` in `FilesTab`
|
||||
would undo the whole point while looking like a simplification.
|
||||
None of the reservation machinery came back with it. There is no destination reservation,
|
||||
no placeholder rollback and no collision marker — the OS save dialog already asks about
|
||||
overwriting, and Docker's archive extractor overwrites on upload the way `cp` does.
|
||||
- **Drag-and-drop is still not it.** There is no drop-into-the-Files-pane and no OS
|
||||
drag-out; the buttons are the gesture. A file also gets *in* by being dropped on the
|
||||
Terminal, and a whole tree comes *out* through "Back up container" — those two predate the
|
||||
Files work and their hardening is not to be weakened. `TerminalView`'s `onDragDropEvent`
|
||||
is Tauri's native drop event (window-wide, so routed by `lib/dropTarget.ts` — geometry for
|
||||
*whose* drop it is, a document-wide `dropIsBlocked` for whether the app should accept one
|
||||
at all; keep both halves and keep `PaneVisibility`). Backup is
|
||||
`file_commands::download_container_backup`.
|
||||
- **`resolve_host_path` applies the full lexical predicate twice — as written, and again
|
||||
after canonicalisation.** That includes the general hidden-component rule, which
|
||||
deliberately over-catches: a path resolving through `node_modules/.pnpm`, `~/.cache` or
|
||||
`~/.local/share` is refused. Do not narrow it back to a list of "credential" directories.
|
||||
That was tried, and allow-by-omission let `~/.local/bin` (write there and you own the
|
||||
user's next shell command), `~/.password-store`, browser profiles and `~/.pki/nssdb`
|
||||
through a planted symlink with a perfectly visible name. Over-refusing is the cheaper
|
||||
mistake. Note the cost is real and has grown: of the four callers, the Files pane's two
|
||||
are routine, and their path comes from a dialog — so an over-catch refuses a destination a
|
||||
person actually chose (`~/.config` is the common one). Accepted, and not a reason to
|
||||
narrow the rule, because the terminal drop and `download_container_backup` still take
|
||||
their host path over IPC and this predicate is their only boundary.
|
||||
- **OS drag-out is not here.** `tauri-plugin-drag`, `stage_container_file_for_drag` and its
|
||||
host staging directory were held back for separate hardening and live on
|
||||
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command
|
||||
without taking that work back whole: the plugin has no scope mechanism, so the grant lets
|
||||
a compromised webview start a drag on *any* host path the user can read, and the staging
|
||||
directory is a host-temp disk leak with a gesture attached unless its exit-clear and
|
||||
startup-reap come back with it.
|
||||
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth.
|
||||
There is deliberately **no Disk panel** here. The disk survey and its reclaim / destroy /
|
||||
compaction surface were held back for separate hardening and live on `hold/disk-and-dragout`;
|
||||
one of their IPC commands was a verified arbitrary-DELETE primitive, so if that work returns it
|
||||
returns whole, `generate_handler!` entries and typed confirmations included. The *prevention*
|
||||
half stayed and is not disk-panel code: the pre-commit scrub in `docker/container.rs`, capped
|
||||
container logs, the `triple-c.base` / `triple-c.managed` labels, `sweep_orphaned_snapshots` and
|
||||
the startup housekeeping in `lib.rs`, the migration reapers, and `project_lock.rs`.
|
||||
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
|
||||
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
|
||||
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
|
||||
@@ -203,7 +258,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
### Container (`container/`)
|
||||
|
||||
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed, plus the shared
|
||||
libraries a browser links against (see below)
|
||||
libraries a browser links against (see below) and the VPN tooling the `vpn_support_enabled`
|
||||
toggle grants capability for (`iproute2`, `wireguard-tools`, `iptables`)
|
||||
- **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs
|
||||
`npx --yes playwright@latest install-deps chromium` as root, so Playwright names its own
|
||||
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
|
||||
@@ -273,6 +329,90 @@ migration and Reset. Four things here are not obvious:
|
||||
actively **removes** `triple-c-*.crt` when the setting is cleared — `/usr/local/share` rides the
|
||||
project's snapshot image, so turning the feature off has to undo, not merely stop.
|
||||
|
||||
### VPN support (`vpn_support_enabled`, `docker/container.rs`)
|
||||
|
||||
An opt-in per-project switch granting the container what a VPN client needs to build a tunnel.
|
||||
`vpn_host_config()` is the single definition of what that means, and it is unit-tested because a
|
||||
container is created once by a very long function where a dropped capability is invisible.
|
||||
|
||||
- **All three pieces or none.** `CAP_NET_ADMIN` (Docker's default set has `net_raw` but *not*
|
||||
`net_admin`, so a client can ping but never connect), the `/dev/net/tun` device (absent
|
||||
entirely from a default container — nothing to open even with the capability), and
|
||||
`net.ipv4.conf.all.src_valid_mark=1` (WireGuard's `wg-quick` sets it and cannot from inside a
|
||||
container, since `/proc/sys` is read-only, so handshake packets die to reverse-path filtering).
|
||||
Any two without the third still presents as a connection that hangs to a timeout, which is why
|
||||
the tests assert the whole set.
|
||||
- **The device is passed through from the host, never `mknod`-ed inside.** The kernel's `tun`
|
||||
module has to back it.
|
||||
- **A missing device fails at `start`, not `create` — verified against Docker 29.7.** `docker
|
||||
create --device /dev/does-not-exist` succeeds and prints an id; runc resolves the device (and
|
||||
validates sysctls) only when it builds the container. So the guard belongs on the start path:
|
||||
`explain_container_failure()` covers both and is called from `start_container`, where it has a
|
||||
container id and no project — which is why it keys off the error naming `/dev/net/tun` rather
|
||||
than off `vpn_support_enabled`. Nothing else in Triple-C requests a device, so that is
|
||||
unambiguous. A version of this check wired to `create` alone is dead code that looks correct.
|
||||
- **`NET_ADMIN` here is not user-namespaced.** Docker does not enable userns remapping by default,
|
||||
so only the *network* namespace confines it: no reach onto host interfaces, but promiscuous
|
||||
mode, arbitrary addresses/routes/NAT on the shared `docker0` segment (sibling containers, the
|
||||
LiteLLM gateway among them, are ARP-spoofable), netlink-triggered host module auto-load, and
|
||||
enough authority to flush in-container netfilter rules that sandbox mode may rely on. Keep the
|
||||
code comments honest about this — an earlier draft claimed it "confers no authority" outside the
|
||||
container, which is too strong.
|
||||
- **`triple-c.vpn-support` is written unconditionally, including `false`.** The usual
|
||||
`docker commit` reason: a `true` stamped once would ride the snapshot image into every future
|
||||
container and make the switch impossible to turn off.
|
||||
- Off is byte-identical to a container created before the feature existed, and a missing label
|
||||
reads as `false`, so no existing project is churned.
|
||||
- **The toggle grants capability and stops there — it routes nothing.** `vpn_host_config()` returns
|
||||
a cap, a device and a sysctl; no client is installed, no route is touched, no tunnel is started
|
||||
or restored. Users read the name as "turn the VPN on" and report the default network not routing
|
||||
through it as a bug. It isn't, and the docs say so explicitly; keep it that way.
|
||||
- **The tooling is baked, not installed at runtime.** `iproute2` and `wireguard-tools` are in
|
||||
`container/Dockerfile` because a runtime install lands in the writable layer and is lost on
|
||||
base-image migration — leaving a project holding the capability with nothing able to exercise it,
|
||||
and no error that points at why. `iptables` is included and `nftables` deliberately is not; see
|
||||
the Dockerfile comment for why that way round.
|
||||
- **Anything built on this fails open.** The network namespace is rebuilt on every start and no
|
||||
service manager runs inside, so a tunnel never survives stop/start or recreation — while leftover
|
||||
`/run` state makes it look as though it did. Note the two different mechanisms: `/run` is in the
|
||||
writable layer, so on a stop/start it is simply the same container's files, and on a recreation
|
||||
`docker commit` has carried it into the snapshot. Traffic silently reverts to the real address.
|
||||
Any future autostart or killswitch work starts here.
|
||||
- **`/run` riding the snapshot means a VPN client's key material can end up in an image.** Verified:
|
||||
a fresh container off the whp snapshot already contained the `wg.priv` a previous tunnel left in
|
||||
`/run`. Anything writing key material there inherits the problem — the same `docker commit`
|
||||
hazard as `triple-c.git-token-hash` and the custom-env fingerprint, in a directory that looks
|
||||
ephemeral and is not. A VPN client that does this should delete its key on teardown.
|
||||
- **`iptables` is baked, and picking `nftables` instead would have been wrong.** `Recommends:
|
||||
nftables | iptables` is stripped by `--no-install-recommends`, and `wg-quick` needs a backend for
|
||||
any `AllowedIPs = 0.0.0.0/0`. `nftables` is the tempting choice — preferred by `wg-quick`, half
|
||||
the size — but `wg-quick` picks nft *unconditionally* when present, and its nft ruleset needs
|
||||
`nft_fib_ipv4`, which LinuxKit (Docker Desktop for Mac) does not build while it *does* build
|
||||
`xt_CONNMARK`. Shipping nftables would therefore have forfeited Mac. See the Dockerfile comment;
|
||||
the kernel-config evidence is quoted there.
|
||||
- **Two `wg-quick` failures remain, and only one is ours to fix.** Full tunnels still need
|
||||
`xt_CONNMARK`, which WSL2 before 6.6 lacks — nothing installable changes that. And every
|
||||
provider's stock config carries a `DNS =` line that fails in `set_dns()` before any routing, so it
|
||||
breaks split tunnels too; `openresolv` has no candidate on noble and `resolvconf` drags in
|
||||
systemd-resolved, so that one is documented rather than fixed. Driving `wg` and `ip route`
|
||||
directly avoids both, which is what the skill does.
|
||||
- **The `pia-vpn` skill is installed *and removed* from `VPN_SUPPORT_ENABLED`.** `container/skills/`
|
||||
is baked to `/opt/triple-c-skills` and `install_feature_skill()` in `entrypoint.sh` copies it into
|
||||
`~/.claude/skills/` on every start — refreshed each time, so a fix reaches any project whose base
|
||||
image has the source, and `rm -rf`'d first, so files dropped from a later version do not linger.
|
||||
The removal branch matters as much as the install: `~/.claude` is a persisted volume, so a skill
|
||||
left behind after the toggle goes off would keep instructing an agent to use a capability the
|
||||
container no longer has. Which is also why the variable is sent as `0` rather than omitted (see
|
||||
`vpn_env_var`, tested), and why it is in `RESERVED_ENV_EXACT` — a custom env var of that name
|
||||
could otherwise claim the skill without the capability behind it.
|
||||
- **Both halves of that live in the base image, so neither reaches an existing project.** A
|
||||
recreation builds from the project's *own snapshot*, which has no `/opt/triple-c-skills` and no
|
||||
updated `entrypoint.sh`; only a migration or a Reset delivers them. The install path says so out
|
||||
loud rather than returning silently, and `/opt/triple-c-skills` is in `FEATURE_PROBES` so the
|
||||
migration pre-flight lists it as missing. Worth knowing before adding anything else behind an
|
||||
existing toggle: the label fingerprints *the setting*, not the set of things the setting drives,
|
||||
so a project already at `true` gets no recreation at all on upgrade.
|
||||
|
||||
### Container Lifecycle
|
||||
|
||||
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
|
||||
@@ -385,6 +525,158 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
|
||||
`models/project.rs` for anything that should default to true.
|
||||
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
||||
|
||||
## Secrets
|
||||
|
||||
**`scripts/scan-secrets.sh` refuses a commit that adds something shaped like a live
|
||||
credential.** Enable the hook once per clone with `npm run hooks` (from `app/`), which sets
|
||||
`core.hooksPath` to `.githooks`. A repository cannot configure its own hooks path — cloning it
|
||||
would then be enough to run its code — so this is opt-in everywhere, and `--no-verify` skips it.
|
||||
The `Secret Scan` workflow is the half nobody can bypass; it carries **no `paths:` filter**, on
|
||||
purpose, because the incident that prompted all this lived in `app/**` and `build.yml` only runs
|
||||
for `container/**`.
|
||||
|
||||
Three rules, and the second half of the third is what keeps it usable: vendor-prefixed tokens
|
||||
(`ghp_`, `sk-`, `AKIA`, `xox`, …), `BEGIN … PRIVATE KEY` blocks, and an opaque literal assigned to
|
||||
a secret-shaped name. That last one needs **both** halves — the identifier must read as a
|
||||
credential *and* the whole literal must be hex or base64 with no word structure. Name-proximity
|
||||
alone flags `secure::get_project_secret(&id, "aws-secret-access-key")`, which is a keychain key
|
||||
name; the literal test is what excludes it. Measured against the tree: 0 false positives, and it
|
||||
catches the real incident (`9b2f4fe`) when replayed.
|
||||
|
||||
A line ending `pragma: allowlist secret` is skipped. Make a fixture obviously fake before reaching
|
||||
for it.
|
||||
|
||||
**Why this exists:** `the_custom_env_fingerprint_never_carries_the_value` used the maintainer's
|
||||
real Gitea **site-admin** token as its fixture — a test about secrets not escaping, leaking one. It
|
||||
survived 92 commits and fourteen days in the public GitHub mirror, past five audit rounds and two
|
||||
independent reviews, because every one of them read the code under change and this sat in a test
|
||||
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
|
||||
|
||||
## Settings export/import
|
||||
|
||||
`commands::settings_export_commands`, `storage::settings_crypto`, `models::settings_export`
|
||||
(triple-c#35). Exports the *host* environment — global `AppSettings` plus the global secrets that
|
||||
live in the OS keychain instead: the shared Claude Code OAuth login and the model gateway's two
|
||||
keys. Per-project settings, per-project secrets, and anything in a project's Docker volumes are
|
||||
deliberately out of scope — this is not a project backup.
|
||||
|
||||
- **`AppSettings` is not entirely the non-secret shape it looks like, and a review of this feature
|
||||
caught the one place that isn't.** `WebTerminalSettings::access_token` is a live bearer
|
||||
credential for a server that binds every interface — exporting `AppSettings` wholesale would
|
||||
have carried it along as if it were as inert as a port number, and importing it would have
|
||||
applied `web_terminal.enabled` and the token together with no more warning than any other
|
||||
setting, letting a crafted export silently stand up a LAN-listening terminal on the next launch.
|
||||
`export_settings`/`apply_settings_import` carve this one field out into `ExportedSecrets`
|
||||
instead, with the same "only overwrite what the import actually has" treatment as the other
|
||||
three secrets — except "leave it alone" has to be done by hand in `apply_settings_import`, since
|
||||
unlike the keychain secrets this one lives inside the `AppSettings` blob that gets replaced
|
||||
wholesale. `SettingsImportPreview::enables_web_terminal` also exists because of this: `enabled`
|
||||
and the token are independent fields, and "this turns on a listening service" must not hide
|
||||
inside a generic "settings replaced" summary. Read this as the standing example of the class of
|
||||
thing to keep checking for in this feature, not a one-off fixed bug — any other field that looks
|
||||
like config but is actually a live credential would have the same problem.
|
||||
- **Encrypted because it can carry live credentials, not for appearance's sake.** Argon2id derives
|
||||
a 256-bit key from the user's password (memory-hard — meaningfully resistant to GPU/ASIC
|
||||
brute-forcing, unlike PBKDF2 at any reasonable iteration count), AES-256-GCM does the actual
|
||||
encryption. A wrong password fails GCM's authentication tag rather than producing silent
|
||||
garbage. The salt and nonce are not secret and are written in the clear in the file's own
|
||||
header — the salt's job is only to make two exports of the same password derive different keys,
|
||||
and the nonce's only requirement is per-encryption uniqueness, which a fresh random draw on
|
||||
every export already gives it.
|
||||
- **The save/open dialogs are opened from Rust**, the same boundary `file_commands.rs`'s
|
||||
`pick_save_path`/`pick_files_to_upload` draw and document at length: a frontend-driven dialog
|
||||
handing Rust a host path string is the exact shape of bug that produced this app's past
|
||||
criticals. `preview_settings_import` resolves the chosen path itself and remembers it
|
||||
(`AppState::pending_settings_import`) so `apply_settings_import` re-reads the same file without
|
||||
a path ever crossing back over IPC. It also pins a hash of the file's ciphertext next to that
|
||||
path, and `apply_settings_import` refuses to proceed if the file on disk no longer matches it —
|
||||
otherwise confirming a preview would not actually be binding on what gets applied, which matters
|
||||
given this feature's own threat model: a file shared between people may sit in a synced or
|
||||
otherwise shared directory that changes between the two calls.
|
||||
- **The decrypted payload is not cached between preview and apply — only the password is reused.**
|
||||
The frontend holds the password in React state and passes it to both calls; nothing in Rust
|
||||
holds decrypted plaintext — secrets included — in memory for longer than one command's
|
||||
execution, so `apply_settings_import` always re-decrypts rather than reusing anything
|
||||
`preview_settings_import` computed. `preview_settings_import` returns counts and presence flags
|
||||
only (`SettingsImportPreview`), never a secret value, so it's safe to hand to the frontend and
|
||||
render directly.
|
||||
- **Import replaces settings wholesale, but only writes secrets actually present in the file.**
|
||||
An import is "restore this environment," so the settings half is a full replace, not a
|
||||
field-by-field merge. Secrets are different on purpose: an absent secret in the export means
|
||||
"the source machine never had this configured," not "delete this on import" — a user who wants
|
||||
to clear a secret already has dedicated UI for that (signing out of shared auth, clearing the
|
||||
gateway key). Secrets are restored *before* the settings replace runs, not after — replacing
|
||||
settings is what triggers `reconcile_gateway`, and restoring the other way round leaves a real
|
||||
window where a gateway recreation happens against the destination's old keys.
|
||||
- **A restored gateway secret nudges a running gateway container to recreate itself, even when
|
||||
nothing about the gateway's *shape* changed.** `reconcile_gateway`'s `gateway_shape_changed` only
|
||||
compares port/provider/base URL/models — deliberately, since that's what's rendered into the
|
||||
container's config — so a secret-only change (same shape, new key) is invisible to it. Left
|
||||
alone, a running container would keep serving the old key material indefinitely after an import
|
||||
that restored a new one. `apply_settings_import` tracks whether either gateway secret was
|
||||
actually written and, if the gateway is enabled and its container both exists and is running,
|
||||
calls `docker::gateway::ensure_gateway_running` directly afterward — its own fingerprint already
|
||||
includes the secret rotation id (`storage::secure::get_gateway_secret_version`), so it recreates
|
||||
exactly when it should and no more.
|
||||
- **A keychain write failing during import is reported back, not only logged.** Each of the three
|
||||
`secure::store_*` calls collects its error into `SettingsImportOutcome::secret_restore_warnings`
|
||||
in addition to logging it — an import that silently restores two of three secrets but not the
|
||||
third must not read as unqualified success just because the settings half of the import (which
|
||||
runs after, and is validated before any of this) went through. `apply_settings_import` returns
|
||||
`SettingsImportOutcome { settings, secret_restore_warnings }` rather than bare `AppSettings` for
|
||||
this reason; `ImportSettingsModal` shows any warnings alongside the "Settings imported" message.
|
||||
- **The imported settings are validated *before* any secret is written, not just before the
|
||||
settings replace.** `apply_settings_import` calls
|
||||
`settings_commands::validate_settings_update(¤t, &settings)` — the same checks
|
||||
`update_settings` runs internally, pulled out into its own function specifically so this caller
|
||||
can run them first — and only proceeds to the three keychain writes if that passes. A review
|
||||
caught the earlier ordering: writing secrets first meant a rejected import (a bad env var name, a
|
||||
disallowed host path) still left the keychain overwritten with the file's secrets while the
|
||||
settings themselves stayed unchanged, a silently half-applied state the error message gave no
|
||||
hint of.
|
||||
- **`read_and_decrypt` checks `format_version` before attempting to parse the full payload, not
|
||||
after.** A version bump that isn't deserialize-compatible is exactly the case that check exists
|
||||
for, and parsing the full struct first would fail on the shape mismatch before the version check
|
||||
ever ran. Neither error path interpolates what `serde_json` actually says into the message
|
||||
shown to the user — its type-mismatch errors quote the offending value inline, and the plaintext
|
||||
here can hold a live credential.
|
||||
- **The 8-character password minimum is enforced in `export_settings` itself, not only in the
|
||||
export modal.** The frontend minimum is a UX nudge; the Rust command is the actual boundary a
|
||||
weak password has to cross, and Argon2id's memory-hardness buys little against an attacker who
|
||||
can just try a short password directly. Measured with `.chars().count()` (Unicode scalar values)
|
||||
rather than `.len()` (bytes), to stay as close as this pair of languages allows to the frontend's
|
||||
`.length` check (UTF-16 code units) — the two only diverge on astral-plane characters. The
|
||||
derived key and both plaintext buffers — the payload built for export, and whatever `decrypt`
|
||||
recovers on import — are wrapped in `zeroize::Zeroizing` for the same reason every other secret
|
||||
in this codebase gets handled carefully — cheap insurance (`zeroize` is already pulled in
|
||||
transitively via `aes-gcm`) for material that exists only to hold or produce live credentials.
|
||||
- **The preview also discloses non-blank custom base URLs** (`global_ollama`, `global_llamacpp`,
|
||||
`global_openai_compatible`, `gateway.api_base`) so an import that would redirect model traffic to
|
||||
a different server is visible in the confirmation dialog rather than discovered later — these are
|
||||
endpoints, not secrets, so `SettingsImportPreview` carries and `describeImport` renders the actual
|
||||
URL rather than just a presence flag. `describeImportWarnings` additionally calls out a web
|
||||
terminal token that arrives with the terminal left *off*: `start_web_terminal` only mints a fresh
|
||||
token when none is already set, so a planted token would otherwise activate silently the next
|
||||
time someone turns the terminal on, with no import-time signal that it wasn't freshly generated.
|
||||
- **The preview also discloses a custom Docker image, and warns on one every time — not just on
|
||||
change.** `custom_image_name`/`image_source` weren't in scope for the base-URL disclosure above,
|
||||
but a review pointed out they're a sharper version of the same problem: this is the image *every*
|
||||
project container is created from (`models::container_config::resolve_image_name`), so a crafted
|
||||
export pointing it at an attacker-controlled image is a path to running arbitrary code with
|
||||
whatever a project's containers are allowed to reach, not merely a redirected API endpoint.
|
||||
`describeImportWarnings` fires on `image_source == Custom` unconditionally rather than only when
|
||||
it differs from the destination's current value, since re-importing the same risky configuration
|
||||
is still worth surfacing every time a user confirms an import.
|
||||
- **Every free-form string a preview surfaces is sanitized and length-capped before it's built.**
|
||||
`SettingsImportPreview::from_payload`'s `sanitize_for_preview` strips control characters and caps
|
||||
at 100 characters (`MAX_PREVIEW_STRING_LEN`) for every base URL and the custom image name — a
|
||||
review noted that, unlike the count- and boolean-derived fields the preview started with, these
|
||||
are verbatim strings from a not-yet-trusted decrypted payload rendered directly into the
|
||||
confirmation dialog. Unbounded, a single pathological value (very long, or holding embedded
|
||||
newlines) could push the security warnings above the scroll fold in the dialog that exists
|
||||
specifically to make them unmissable — the frontend's `<li>`/warning boxes also get `break-all`
|
||||
as a second layer against the same failure mode.
|
||||
|
||||
## Testing
|
||||
|
||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||
|
||||
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Installation](#installation)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [First Launch](#first-launch)
|
||||
- [The Interface](#the-interface)
|
||||
@@ -32,6 +33,23 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Download the build for your platform from [GitHub Releases](https://github.com/shadowdao/triple-c/releases/latest).
|
||||
|
||||
| Platform | File | Install |
|
||||
|----------|------|---------|
|
||||
| **Windows** | `Triple-C_<version>_x64-setup.exe` or `.msi` | Run the installer. |
|
||||
| **macOS** | `Triple-C_<version>_universal.dmg` | Open the `.dmg` and drag Triple-C to Applications. |
|
||||
| **Debian / Ubuntu** | `Triple-C_<version>_amd64.deb` | `sudo apt install ./Triple-C_<version>_amd64.deb` |
|
||||
| **Fedora / RHEL** | `Triple-C-<version>-1.x86_64.rpm` | `sudo dnf install ./Triple-C-<version>-1.x86_64.rpm` |
|
||||
| **Arch / CachyOS** | `triple-c-bin-<version>-1-x86_64.pkg.tar.zst` | `sudo pacman -U ./triple-c-bin-<version>-1-x86_64.pkg.tar.zst` |
|
||||
| **Other Linux** | `Triple-C_<version>_amd64.AppImage` | `chmod +x` it, then run it directly. |
|
||||
|
||||
> **macOS note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it — right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`.
|
||||
|
||||
> **Arch / CachyOS note:** This package is not on the AUR — it's a `pacman`-installable file built and attached to each GitHub release by a maintainer-triggered step (`.gitea/workflows/publish-arch-package.yml`), so it can lag behind the very latest release by a bit. See [`packaging/arch/README.md`](packaging/arch/README.md) for details, including why "-bin" and what's verified about it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Docker
|
||||
@@ -128,8 +146,11 @@ Anthropic-backend project uses that token without its own login. See
|
||||
2. Claude prints an OAuth URL. Triple-C detects long URLs and shows a clickable toast at the top of the terminal — click **Open** to open it in your browser.
|
||||
3. Complete the login in your browser. The token is saved and persists across container stops, starts and recreations. A **Reset** deletes it — see below.
|
||||
|
||||
> If the login hangs after the browser step, the callback could not reach the container. Enable the
|
||||
> [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project.
|
||||
> If the login hangs after the browser step, the callback could not reach the container. Either
|
||||
> click **In container** on the toast instead of **Open** — the callback then never has to leave the
|
||||
> container at all — or turn on the
|
||||
> [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) in the project's
|
||||
> **Config → Runtime** section.
|
||||
|
||||
**AWS Bedrock:**
|
||||
|
||||
@@ -225,7 +246,7 @@ buttons. Below that are six tabs:
|
||||
| **Sessions** | Past Claude Code conversations stored on this project's config volume, each with a **Resume** button |
|
||||
| **Automation** | The scheduled tasks running inside this container — see [Automation & Scheduled Tasks](#automation--scheduled-tasks) |
|
||||
| **Config** | All per-project configuration — see [Project Configuration](#project-configuration) |
|
||||
| **Files** | Browse, download and upload files inside the container |
|
||||
| **Files** | Browse, view and rename files inside the container, and move files between it and your own machine — see [Files](#files) |
|
||||
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
|
||||
|
||||
### Sessions
|
||||
@@ -348,7 +369,7 @@ it. The sidebar row carries only the two hover controls.
|
||||
| **Force stop** | Project Home header | Starting / Stopping | Interrupts a transition that is stuck |
|
||||
| **Open Claude Terminal** | Project Home header; sidebar hover control; `Ctrl+T` | Running | Opens a new Claude Code terminal tab |
|
||||
| **Shell** | Project Home header | Running | Opens a bash login shell tab in the container (no Claude Code) |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, download and upload files |
|
||||
| **Files** | Project Home header, and the **Files** tab | Running | Switches to the Files tab to browse, view and rename files inside the container, upload files into it and save one back out |
|
||||
| **Config** | The **Config** tab | Always | Per-project configuration (most fields need the container stopped) |
|
||||
| **Back up container** | **⋯** overflow menu | A container exists | Saves a `.tar.gz` archive of the container to a location you choose |
|
||||
| **Reset container…** | **⋯** overflow menu | Stopped or Error | Destroys the container, snapshot image and both volumes, then recreates from the base image (wipes `~/.claude`) — asks first |
|
||||
@@ -471,6 +492,93 @@ When enabled, the host Docker socket is mounted into the container so Claude Cod
|
||||
|
||||
> Toggling this requires stopping and restarting the container to take effect.
|
||||
|
||||
### VPN Support
|
||||
|
||||
When enabled, the container is given the three things a VPN client needs to build a tunnel:
|
||||
the `NET_ADMIN` capability, the `/dev/net/tun` device, and the `net.ipv4.conf.all.src_valid_mark`
|
||||
sysctl that WireGuard requires. This is **off by default**.
|
||||
|
||||
The `ip`, `wg` and `iptables` commands ship in the container image so there is something able to use
|
||||
them. If your project's container was created from an older base image it will not have them, and
|
||||
`wg` will simply not be found — **migrating the project onto the current base image** is what picks
|
||||
them up. `sudo apt install iproute2 wireguard-tools iptables` works in the meantime, but lives in
|
||||
the writable layer, so it is undone by a **Reset** and by a migration.
|
||||
|
||||
**This setting makes a tunnel possible; it does not make one.** Nothing is connected, no traffic is
|
||||
redirected, and no tunnel is configured or started on your behalf. Enabling it and expecting the
|
||||
container's traffic to start leaving through a VPN is the most common misreading of what it does —
|
||||
configuring a tunnel and routing traffic into it remains yours to do.
|
||||
|
||||
To make that second half easier, enabling this also installs a **`pia-vpn` skill** into the
|
||||
container's `~/.claude/skills/`, so Claude Code can bring up a Private Internet Access tunnel over
|
||||
WireGuard for you — ask it to connect the VPN and it will. The skill carries the parts that are
|
||||
easy to get wrong (see the DNS note below), and it is removed again when you turn the setting off.
|
||||
It needs your PIA credentials in `~/pia-creds`, two lines, username then password. If you use a
|
||||
different provider, ignore it and set up your own client; nothing else depends on it.
|
||||
|
||||
Like the VPN tooling above, the skill ships in the container image, so a project whose container
|
||||
predates it will not get one by toggling the setting — **migrate the project** and it appears; the
|
||||
migration pre-flight lists it among what you would gain.
|
||||
|
||||
With the setting **off**, a client such as PIA or OpenVPN installs and its daemon starts normally,
|
||||
but the connection attempt **hangs until it times out** — a default container has no tun device to open
|
||||
and no permission to add an interface or a route, and most clients report that as a generic timeout
|
||||
rather than a permissions error.
|
||||
|
||||
Things worth knowing:
|
||||
|
||||
- Tailscale is the exception: in its `--tun=userspace-networking` mode it needs neither the
|
||||
capability nor the device, so leave this off if that is all you want.
|
||||
|
||||
- `NET_ADMIN` applies to the container's **own** network namespace — it cannot touch the host's
|
||||
interfaces. It is not nothing, though: within that namespace anything in the container can set
|
||||
promiscuous mode and add arbitrary addresses, routes and firewall rules on the Docker bridge it
|
||||
shares with your other containers, and it can flush firewall rules that sandbox mode relies on.
|
||||
Grant it per project, to projects that need it.
|
||||
- The **Docker host's** kernel must have the `tun` module available. With Docker Desktop that is
|
||||
the Linux VM, not your own machine. If it is missing, the container is created but fails to
|
||||
**start**, with an error naming `/dev/net/tun` and pointing back at this setting.
|
||||
- A VPN client's kill switch applies to everything in the container, Claude Code included. If the
|
||||
tunnel drops, expect API calls to fail until it reconnects or the kill switch is turned off.
|
||||
- **No tunnel survives a restart.** The network namespace is built fresh every time the container
|
||||
starts, and there is no service manager inside to reconnect anything. Leftover state under `/run`
|
||||
makes it *look* like the tunnel is still configured — that directory is in the container's
|
||||
writable layer, so it is simply still there after a stop/start, and `docker commit` carries it
|
||||
into the snapshot that a recreation is built from. Either way the interface and its routes are
|
||||
gone and traffic goes out your real address again, with no error and nothing visibly different.
|
||||
Re-establish it after every start, and check rather than assume.
|
||||
- **A full tunnel breaks DNS unless the client is told to leave private ranges alone.** Your
|
||||
resolver is whatever `/etc/resolv.conf` says, and if that address is outside the container's own
|
||||
subnet then a default route of `0.0.0.0/0` — or a `0.0.0.0/1` plus `128.0.0.0/1` pair — captures
|
||||
it and sends every lookup into a tunnel that cannot carry it. Under Docker Desktop it is
|
||||
`192.168.65.7`, which is exactly that case; on a user-defined Docker network it is `127.0.0.11`,
|
||||
which is loopback and unaffected. Check yours rather than assuming. The symptom when it bites is
|
||||
total: Claude Code reports it cannot connect, because it cannot resolve `api.anthropic.com`.
|
||||
Route `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` and `169.254.0.0/16` via the original
|
||||
gateway — and give the tunnel a resolver it can actually reach, normally the VPN provider's own,
|
||||
or you have a tunnel that leaks every DNS query outside itself. Also pin the VPN endpoint's own
|
||||
address via the original gateway, or the tunnel's encrypted packets try to route through the
|
||||
tunnel. Note that a health check which fetches an IP literal such as `1.1.1.1` passes cleanly
|
||||
while DNS is broken — resolve a name instead.
|
||||
- **Delete a client's key material when you tear a tunnel down.** Anything written under `/run` is
|
||||
in the container's writable layer, and recreating or migrating the project runs `docker commit`
|
||||
over it — so a WireGuard private key left there gets baked into the project's snapshot image and
|
||||
copied forward from then on. This is not hypothetical; it has already happened here.
|
||||
- **Strip the `DNS =` line from a provider's `.conf` before `wg-quick up`.** Every commercial
|
||||
provider ships one, and `wg-quick` hands it to `resolvconf`, which is not installed — so it fails
|
||||
at `resolvconf: command not found` and deletes the interface again. This happens before any
|
||||
routing, so it takes **split tunnels down too**. Set the resolver another way instead, or drive
|
||||
`wg` and `ip route` directly rather than going through `wg-quick`.
|
||||
- **`wg-quick` full tunnels additionally need `xt_CONNMARK` from the host kernel.** WSL2 kernels
|
||||
before 6.6 do not have it and a container cannot load one — on Windows, `wsl --update` moves you
|
||||
to a current kernel, which does. Failing that, add the routes yourself with `ip route`, which
|
||||
needs no firewall backend on any platform. Note this is the *second* hurdle: clear the `DNS =`
|
||||
one above first, or you will not reach this.
|
||||
|
||||
> This setting can only be changed when the container is stopped. Capabilities and devices are
|
||||
> fixed when a container is created, so toggling it recreates the container on the next start.
|
||||
> Recreation preserves the home and `.claude` volumes — it is not a Reset.
|
||||
|
||||
### Mission Control
|
||||
|
||||
Toggle **Mission Control** to integrate Flight Control — an AI-first development methodology bundled with Triple-C — into the project. When enabled:
|
||||
@@ -525,18 +633,27 @@ The **Claude Code settings** editor, also at the bottom of the Config tab, confi
|
||||
|
||||
| Setting | What It Does |
|
||||
|---------|-------------|
|
||||
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
|
||||
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
|
||||
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
|
||||
| **Thinking Summaries** | Shows Claude's thinking process as summaries during responses |
|
||||
| **Session Recap** | Provides context when returning to a session after being away |
|
||||
| **Auto-Scroll Disabled** | Disables auto-scroll when in fullscreen TUI mode |
|
||||
| **TUI Mode** | **Automatic** lets Claude Code choose; **Classic** pins the main-screen renderer; **Fullscreen** pins the flicker-free alt-screen one |
|
||||
| **Effort Level** | Reasoning depth: **Low**, **Medium**, **High**, **Extra high** |
|
||||
| **Focus Mode** | Summarises tool *calls* to one line each, showing the last prompt and the final response. **Needs the fullscreen renderer** — set TUI Mode to Fullscreen or this does nothing |
|
||||
| **Thinking Summaries** | Shows Claude's thinking as summaries rather than a collapsed stub |
|
||||
| **Session Recap** | A one-line recap when you return to the terminal after a few minutes away. **On by default** — the switch is how you turn it off |
|
||||
| **Auto-Scroll** | Follows new output to the bottom in fullscreen rendering. On by default |
|
||||
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
||||
| **Prompt Caching (1h)** | Enables 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||
| **Prompt Caching (1h)** | Requests a 1-hour prompt cache TTL instead of the default 5 minutes |
|
||||
|
||||
Per-project settings override global defaults set in Settings. If all settings are at their defaults, no configuration is injected.
|
||||
Each switch has three states on a project: **Global** (follow Settings), **On**, and **Off**. Off is a
|
||||
real choice — it overrides a global On, which a project could not previously do.
|
||||
|
||||
> These settings map to Claude Code environment variables and `~/.claude/settings.json` entries. Changes require stopping and restarting the container to take effect.
|
||||
> These map to Claude Code environment variables and `~/.claude/settings.json` keys, and are applied
|
||||
> when the container starts. Changing one stops and recreates the container.
|
||||
>
|
||||
> **Two caveats on an existing project.** Changing any of these recreates the container, and a
|
||||
> recreation commits a new image layer — so flipping switches repeatedly costs disk. And
|
||||
> **TUI Mode, Effort Level, Focus Mode and Session Recap cannot be returned to Global** until the
|
||||
> project's base image is updated: those four are cleared by *removing* a key, and an older image's
|
||||
> startup script ignores the instruction to remove it. Update the base image from the project's
|
||||
> Overview tab first. The other switches work on any image.
|
||||
|
||||
### MCP Servers
|
||||
|
||||
@@ -702,6 +819,19 @@ web server they started on `localhost`. `claude login`, `aws sso login` and Conc
|
||||
|
||||
The **Auth Bridge** fixes this. It is **opt-in per project** and **off by default**.
|
||||
|
||||
### Where the switch is
|
||||
|
||||
Project Home → **Config** → **Runtime** → **Auth bridge**.
|
||||
|
||||
Unlike the rest of that tab, it is **not** greyed out while the container is running — it is a
|
||||
host-side feature that recreates nothing, and the moment you want it is usually the moment a login
|
||||
is already hanging in a running container. Switch it on, then retry the login.
|
||||
|
||||
Beside the switch is its live state: **Off**, **Watching** (on, nothing to bridge yet — normal,
|
||||
there is only something to bridge while a login is waiting), **Bridging *n* ports**, **IPv4 only**,
|
||||
or **Port conflict** with the port and the reason. A conflict means the host port was already taken
|
||||
and the callback will not arrive; free the port, or use **In container** instead.
|
||||
|
||||
### What it does
|
||||
|
||||
- Every couple of seconds it looks inside the container for programs listening on the container's
|
||||
@@ -1058,14 +1188,61 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
|
||||
|
||||
### Files
|
||||
|
||||
The **Files** tab of Project Home browses inside a running container. You can:
|
||||
The **Files** tab of Project Home browses inside a running container, and moves files between it
|
||||
and your own machine. You can:
|
||||
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation
|
||||
- **Download** any file to your host machine via the **Download** button on each file entry
|
||||
- **Upload file** from your host into the current container directory
|
||||
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation.
|
||||
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
|
||||
between rows and Enter opens the selected one
|
||||
- **View** a file — double-click it, or press Enter. Text files and images render in a read-only
|
||||
viewer
|
||||
- **Rename** an entry, from the row's Rename button or by pressing `F2`. A rename never moves a
|
||||
file between folders
|
||||
- **New folder** in the directory on screen
|
||||
- **Upload…**, from the toolbar, to copy files from your machine into the directory on screen
|
||||
- **Save to host…**, from a file's own row, to write that one file out to your machine
|
||||
- **Refresh** the directory listing at any time
|
||||
|
||||
The listing shows file names, sizes, and modification dates.
|
||||
The listing shows file names, sizes, and modification dates, and marks symbolic links.
|
||||
|
||||
#### Getting files in and out
|
||||
|
||||
**Upload…** opens a file dialog on your machine, and whatever you choose is copied into the
|
||||
directory currently on screen. Uploaded files arrive owned by you inside the container, not by
|
||||
root. You can pick several files in one dialog; each is handled on its own, so if a folder or an
|
||||
over-sized file is among them, it is named in the message and the rest still arrive. Uploads are
|
||||
capped at **256 MB per file** — for anything larger, mount the folder into the project instead and
|
||||
skip the copying altogether.
|
||||
|
||||
**Save to host…** does the reverse, for one file: a save dialog opens, you choose where the file
|
||||
goes, and it is written there. The button sits on the file's own row, and only on files. For a
|
||||
whole directory, use **Back up container** in Project Home's **⋯** overflow menu, which writes a
|
||||
`.tar.gz` of the workspace and the container's `~/.claude` config to a location you choose — that
|
||||
is still the right tool for a tree.
|
||||
|
||||
Dragging a file from your desktop and **dropping it onto the Terminal tab** works too, and is often
|
||||
the quickest way in when you are already typing: the file is copied into the container and its path
|
||||
is typed into the terminal for you, ready to hand to Claude Code. (The whole terminal pane is a
|
||||
drop target, including its *Following* toggle.) The Files pane itself is not a drop target.
|
||||
|
||||
Both dialogs are opened by Triple-C itself rather than by the page you are looking at. The page
|
||||
cannot name a place on your machine — it can only ask for a dialog — and nothing is read or written
|
||||
until you pick somewhere in it. Closing a dialog without choosing is not an error: nothing happens,
|
||||
and nothing is said about it.
|
||||
|
||||
Every one of these routes refuses a location whose path passes through a hidden *folder* — anything
|
||||
with a component beginning with `.`, such as `~/.ssh`, `~/.cache` or `~/.local/share` — or a system
|
||||
location, and it checks both the path as written and where it points after any symbolic links. That
|
||||
rule catches more than it strictly needs to, so now and then it will refuse a place you genuinely
|
||||
meant, `~/.config` among them. The refusal is a plain sentence saying so; choose a visible location
|
||||
such as `~/Documents` or `~/Downloads`.
|
||||
|
||||
The *file's own name* is a different matter, and dotfiles are fine: `.env`, `.gitignore` and the
|
||||
rest save normally, since you chose the name in the save dialog yourself. Only the folders on the
|
||||
way are judged.
|
||||
|
||||
If you already keep the project in a folder mounted into the container, the simplest answer is
|
||||
usually none of the above: edit the file on your host and it is already inside.
|
||||
|
||||
### Terminal Rendering
|
||||
|
||||
@@ -1111,10 +1288,14 @@ change. Remember that a headless run cannot answer a permission prompt, so in an
|
||||
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
|
||||
that was used.
|
||||
|
||||
### Creating Tasks (In the Container)
|
||||
### Creating Tasks
|
||||
|
||||
There is no "add task" form in the app. Create tasks from a terminal in the container — either type
|
||||
the commands yourself in a **Shell** session, or just ask Claude to do it.
|
||||
The quickest route is the **New task** button on a project's **Automation** tab, which gives you a
|
||||
form for the name, the schedule and the prompt.
|
||||
|
||||
You can also create tasks from a terminal in the container — type the commands yourself in a
|
||||
**Shell** session, or just ask Claude to do it. That is the better route when you want Claude to
|
||||
work out the schedule or the prompt for you, and it is what the rest of this section covers.
|
||||
|
||||
### Create a Recurring Task
|
||||
|
||||
@@ -1139,13 +1320,23 @@ triple-c-scheduler list # List all tasks
|
||||
triple-c-scheduler enable --id abc123 # Enable a task
|
||||
triple-c-scheduler disable --id abc123 # Disable a task
|
||||
triple-c-scheduler remove --id abc123 # Delete a task
|
||||
triple-c-scheduler run --id abc123 # Trigger a task immediately
|
||||
triple-c-scheduler run --id abc123 # Trigger a task now, streaming its log
|
||||
triple-c-scheduler status # What is running right now, and for how long
|
||||
triple-c-scheduler status --id abc123 -w # Watch one task until its run finishes
|
||||
triple-c-scheduler logs --id abc123 # View logs for a task
|
||||
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
|
||||
triple-c-scheduler notifications # View completion notifications
|
||||
triple-c-scheduler notifications --clear # Clear notifications
|
||||
```
|
||||
|
||||
`list` carries a status column, and the Automation tab marks a task **Running** with
|
||||
its elapsed time, so a triggered run is visible rather than silent.
|
||||
|
||||
Note that a log which has stopped growing is not evidence of a stall: `claude -p`
|
||||
writes its answer in one go when it finishes, so a healthy run shows nothing but its
|
||||
header for as long as it is thinking. `status` is what distinguishes a slow run from
|
||||
a dead one — it reports the run only while the runner's process is genuinely alive.
|
||||
|
||||
### Cron Schedule Format
|
||||
|
||||
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
||||
@@ -1196,9 +1387,19 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test
|
||||
| **Ctrl+Shift+V** | Paste |
|
||||
| **Ctrl+V** | Paste an image from the clipboard into the container |
|
||||
| **Ctrl+Shift+M** | Toggle speech-to-text recording (when enabled) |
|
||||
| **Shift+Enter** | Insert a newline in Claude Code's prompt instead of submitting it |
|
||||
| **Alt+Enter** | The same thing, and it has always worked — it was simply never written down |
|
||||
|
||||
Everything else goes straight through to the program running in the container.
|
||||
|
||||
> **Shift+Enter** sends `ESC` + `CR`, the same bytes Claude Code's own `/terminal-setup` installs
|
||||
> for VS Code, Cursor, Alacritty and Zed — so there is nothing to run and no tip to follow. It is
|
||||
> bound in **Claude** tabs only: in a **bash** tab that sequence means nothing to readline, and
|
||||
> Shift+Enter there submits the line as it always has.
|
||||
>
|
||||
> In the [Web Terminal](#web-terminal-remote-access) the same chord works, and there is an **↵+**
|
||||
> key beside **Enter** on the mobile key row for devices with no Shift.
|
||||
|
||||
---
|
||||
|
||||
## What's Inside the Container
|
||||
@@ -1215,6 +1416,7 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
||||
| ruff | Latest | Python linter/formatter |
|
||||
| Rust | Stable | Rust development (via rustup) |
|
||||
| Docker CLI | Latest | Container management (when spawning is enabled) |
|
||||
| iproute2, WireGuard tools, iptables | Latest | Building a tunnel (when VPN Support is enabled) |
|
||||
| git | Latest | Version control |
|
||||
| GitHub CLI (gh) | Latest | GitHub integration |
|
||||
| AWS CLI | v2 | AWS services and Bedrock |
|
||||
@@ -1304,8 +1506,18 @@ your machine (anything that isn't `http`/`https`).
|
||||
|
||||
You opened the URL, signed in successfully, and the CLI in the terminal is still waiting. The
|
||||
callback from your browser is landing on your host's `localhost` while the CLI is listening on the
|
||||
*container's*. Enable the
|
||||
[Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project and try again.
|
||||
*container's*.
|
||||
|
||||
Two ways out, in order of least effort:
|
||||
|
||||
1. Dismiss and re-trigger the login, then click **In container** on the toast rather than **Open**.
|
||||
The page opens in a browser *inside* the container, so the callback never has to cross to the
|
||||
host. This needs no auth bridge — only a running container with Playwright installed (Project
|
||||
Home → **Browser**). For a recognised Anthropic sign-in link this is already the default button.
|
||||
2. Turn on the [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) — Project Home →
|
||||
**Config** → **Runtime** → **Auth bridge** — and try again. It can be switched on while the
|
||||
container is running. Check the indicator beside it: **Port conflict** means the host port was
|
||||
already taken and the callback still will not arrive.
|
||||
|
||||
For Claude specifically, the simpler answer is usually
|
||||
[Shared Claude Authentication](#shared-claude-authentication), which finishes on an Anthropic-hosted
|
||||
@@ -1342,3 +1554,9 @@ cp ~/.claude.json ~/.claude.json.bak && jq 'with_entries(select(.key | startswit
|
||||
```
|
||||
|
||||
This backs up your config and removes the corrupted marketplace entries. Claude Code will re-download them cleanly on the next startup.
|
||||
|
||||
### App Icon Missing After Installing (Linux)
|
||||
|
||||
If Triple-C's icon shows as generic or blank right after installing — in the app menu, taskbar, and window titlebar alike — **log out and back in.**
|
||||
|
||||
Desktop shells (GNOME Shell, KDE Plasma) cache the list of installed apps and their resolved icons in memory when the shell starts, for performance. A freshly installed package's icon files land on disk correctly and its install hooks do rebuild the on-disk icon cache, but an already-running shell doesn't always notice — on X11 there used to be a way to soft-restart just the shell (GNOME's Alt+F2 → `r`) to force a reload, but under Wayland the shell *is* the compositor, so restarting it means ending the session. Logging out and back in starts a fresh shell that reads the current on-disk state, which picks the icon up.
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="branding/triple-c-lockup-dark.svg">
|
||||
<img src="branding/triple-c-lockup-light.svg" alt="Triple-C — Coding Container" width="429" height="112">
|
||||
</picture>
|
||||
|
||||
# Triple-C (Claude-Code-Container)
|
||||
|
||||
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project chooses its own **permission mode** — from Plan (read-only) through to Bypass (`--dangerously-skip-permissions`), which gives Claude unrestricted access within the sandbox.
|
||||
|
||||
This file is the architectural tour: what each subsystem is and why it works the way it does.
|
||||
|
||||
| Document | For |
|
||||
|---|---|
|
||||
| [HOW-TO-USE.md](HOW-TO-USE.md) | Using the app — first launch, projects, settings, troubleshooting |
|
||||
| [BUILDING.md](BUILDING.md) | Building from source on Linux, macOS and Windows |
|
||||
| [TECHNICAL.md](TECHNICAL.md) | Technology choices and the dependency inventory |
|
||||
| [ROADMAP.md](ROADMAP.md) | Claude Code feature parity, gaps and sequencing |
|
||||
| [CLAUDE.md](CLAUDE.md) | Working *on* this repo, for Claude Code |
|
||||
| [branding/](branding/README.md) | The mark, the palette, and how the icons are generated |
|
||||
|
||||
## Contents
|
||||
|
||||
- [Architecture](#architecture) — layout, tabs, shortcuts, Project Home
|
||||
- [Permission Modes](#permission-modes)
|
||||
- [Containers](#containers) — lifecycle, base-image migration, mounts, CA certificates, sibling containers
|
||||
- [Models and Authentication](#models-and-authentication) — backends, model aliases, gateway, shared token
|
||||
- [Bridges to the Host](#bridges-to-the-host) — URL relay, auth bridge, browser view, host file transfers
|
||||
- [Inside a Project](#inside-a-project) — capability tiles, Mission Control, web terminal, speech-to-text
|
||||
- [Key Files](#key-files) · [CSS / Styling Notes](#css--styling-notes) · [Container Image](#container-image)
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
|
||||
@@ -29,6 +55,13 @@ two tab kinds: `home:<projectId>` (Project Home) and `term:<sessionId>` (a termi
|
||||
separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one
|
||||
thing is current at a time.
|
||||
|
||||
Tabs are user-reorderable — drag one, or move the active tab with `Ctrl+Shift+←/→`. A tab's
|
||||
position is therefore never its identity: tabs are addressed by key, and indexed only through
|
||||
`tabOrder`. The drag is built on pointer events rather than HTML5 drag-and-drop, deliberately:
|
||||
Tauri's `dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot simply be
|
||||
switched off because `TerminalView` needs Tauri's native drag-drop event — the only one that
|
||||
carries dropped *file paths*.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
||||
@@ -39,31 +72,47 @@ Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
||||
| `Ctrl+Shift+W` | Close the active tab |
|
||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
|
||||
| `Ctrl+1` … `Ctrl+9` | Jump to the nth tab |
|
||||
| `Ctrl+Shift+←` / `Ctrl+Shift+→` | Move the active tab left / right |
|
||||
|
||||
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
|
||||
terminal this app is built around. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`,
|
||||
`Ctrl+Shift+M`) are handled in `TerminalView.tsx`.
|
||||
terminal this app is built around. Plain `Ctrl+←/→` is readline's word-wise cursor motion, which is
|
||||
why moving a tab takes Shift as well.
|
||||
|
||||
Terminal-scoped keys are handled in `TerminalView.tsx`:
|
||||
|
||||
| Shortcut | Action |
|
||||
|---|---|
|
||||
| `Ctrl+Shift+C` / `Ctrl+Shift+Alt+C` | Copy the selection, trimmed / exactly as-is |
|
||||
| `Ctrl+Shift+M` | Toggle speech-to-text recording |
|
||||
| `Shift+Enter` | Insert a newline in Claude Code's prompt instead of submitting |
|
||||
| `Alt+Enter` | The same thing — xterm.js already ESC-prefixes on Alt, so this has always worked |
|
||||
|
||||
`Shift+Enter` sends `ESC` + `CR`, which is what Claude Code's own `/terminal-setup` installs for
|
||||
VS Code, Cursor, Alacritty and Zed. It is bound in Claude sessions only: in a bash tab those bytes
|
||||
are unbound in readline. The web terminal does the same, and adds an `↵+` key beside Enter for
|
||||
devices with no Shift.
|
||||
|
||||
### Project Home
|
||||
|
||||
Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project
|
||||
view, with tabs **Overview · Sessions · Automation · Config · Files**. The sidebar row itself is
|
||||
select-only (plus hover controls for start/stop and opening a terminal); it holds no configuration.
|
||||
Per-project configuration lives in the Config tab rather than in modals.
|
||||
view, with tabs **Overview · Sessions · Automation · Config · Files · Browser**. The sidebar row
|
||||
itself is select-only (plus hover controls for start/stop and opening a terminal); it holds no
|
||||
configuration. Per-project configuration lives in the Config tab rather than in modals.
|
||||
|
||||
| Tab | Contents |
|
||||
|---|---|
|
||||
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks |
|
||||
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks, base-image staleness banner |
|
||||
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
|
||||
| **Automation** | The container's `triple-c-scheduler` tasks — enable/disable, run now, read logs, remove, and completion notifications |
|
||||
| **Automation** | The container's `triple-c-scheduler` tasks — create, edit, enable/disable, run now, read logs, remove, and completion notifications |
|
||||
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
|
||||
| **Files** | Browse, download and upload files inside the container |
|
||||
| **Files** | Browse, view, rename and create folders inside the container, upload host files into the directory on screen, and save one file back out to the host — see [Host File Transfers](#host-file-transfers). A whole tree still comes out through **Back up container** |
|
||||
| **Browser** | Watch and take over the Playwright browser inside the container — see [Browser View](#browser-view) |
|
||||
|
||||
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
|
||||
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
|
||||
progress modal.
|
||||
|
||||
### Permission Modes
|
||||
## Permission Modes
|
||||
|
||||
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
|
||||
mapped to CLI flags by `PermissionMode::cli_args()`:
|
||||
@@ -87,15 +136,196 @@ back into flags for its headless `claude -p` run. Because it travels as containe
|
||||
only reaches the scheduler after the container is recreated on its next start (the label mismatch
|
||||
forces that).
|
||||
|
||||
### Container Introspection (Capability Tiles)
|
||||
## Containers
|
||||
|
||||
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
||||
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
||||
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
||||
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
||||
### Container Lifecycle
|
||||
|
||||
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
||||
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
||||
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, installs any CA certificates, injects Claude Code settings, rebuilds the scheduler crontab
|
||||
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
||||
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
||||
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
|
||||
6. **Migrate**: The project is moved onto a newer base image without losing its volumes — see below
|
||||
|
||||
Each recreation moves the `triple-c-snapshot-{projectId}:latest` tag, leaving the image it pointed
|
||||
at before untagged but still on disk — multiple gigabytes per recreation. `sweep_orphaned_snapshots`
|
||||
clears those after a recreation and after a migration is accepted. It only ever removes images that
|
||||
are **both** untagged *and* labelled `triple-c.managed=true`, so a live snapshot tag and a
|
||||
migration's `pre-migration-*` rollback pin are structurally out of reach, and removal is unforced so
|
||||
Docker itself refuses while any container — including a stopped project's — is still built from the
|
||||
image.
|
||||
7. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
|
||||
|
||||
### Base-Image Migration
|
||||
|
||||
A container is created from `triple-c-snapshot-{projectId}:latest` whenever that image exists, and
|
||||
every recreation re-commits it. So without an explicit act, a project stays on the base image it was
|
||||
first built from **forever** — it never picks up a new `/usr/local/bin` shim, a new `socat`, or a
|
||||
security update. **Update container base…** (Project Home → overflow menu) is the non-destructive
|
||||
way out; Reset is the destructive one. `docker/migration.rs` owns it.
|
||||
|
||||
- **Staleness is surfaced, not acted on.** `triple-c.base-image-id` records the lineage and
|
||||
`get_container_staleness` reports it as a banner, but it is deliberately *not* compared in
|
||||
`container_needs_recreation`. Comparing it there would recreate every project *from its own
|
||||
snapshot* on the next base bump: churn on the old base, and the "you should migrate" signal
|
||||
consumed without migrating. A missing lineage label means "unknown, probe instead" — never
|
||||
"stale".
|
||||
- **What comes across**: the apt package delta and user-authored files, computed by diffing two
|
||||
filesystem manifests through dpkg ownership and presence-in-the-new-base. (`docker diff` is
|
||||
useless here — on a snapshot-derived container it only reports changes since the last commit.
|
||||
Measured on a real project, manifest diffing turned 8,677 raw path differences into 2 genuinely
|
||||
user-authored ones.) Both named volumes are untouched at every step, so `$HOME`, the OAuth login,
|
||||
skills, transcripts and scheduler tasks simply re-attach.
|
||||
- **What does not**: `/etc` is reported but never copied — the old lineage has
|
||||
`/etc/apt/sources.list.d/nodesource.sources` where the current base has `nodesource.list`, and
|
||||
having both breaks every `apt-get update`. `/var` is not copied either, and that is the one way
|
||||
migration is *more* destructive than an ordinary recreate: a database under `/var/lib` rides along
|
||||
on a recreate, but a migration builds from the base and the apt replay hands back an empty
|
||||
cluster. `unpreserved_data()` names those directories in the pre-flight, the banner and the final
|
||||
report.
|
||||
- **Crash-safety**: `:latest` keeps pointing at the old lineage until the final commit, so any
|
||||
failure before that self-heals — the next start just recreates from the old snapshot. After the
|
||||
container swap, a `triple-c.migration-state=in-progress` label plus a persisted state file let the
|
||||
app offer **resume** or **rollback**. Rollback restores the system layer only; work done in
|
||||
`$HOME` during a migrated session survives it.
|
||||
|
||||
### Mounts
|
||||
|
||||
| Target in Container | Source | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
||||
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
|
||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
|
||||
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||
| `/tmp/.host-aws` | AWS config directory | Bind | Read-only; entrypoint copies to `~/.aws`; for Bedrock auth |
|
||||
| `/tmp/.host-ca` | CA certificate file or directory | Bind | Read-only; entrypoint installs into the system and NSS stores |
|
||||
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
|
||||
|
||||
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
||||
removal, and by nothing else.
|
||||
|
||||
### Corporate CA Certificates
|
||||
|
||||
A global **Certificates** setting (`AppSettings::ca_cert_path`) with a per-project override
|
||||
(`Project::ca_cert_path`), accepting a single certificate file **or** a directory. It follows the
|
||||
SSH/AWS host-mount pattern — read-only bind mount at `/tmp/.host-ca`, applied by the entrypoint on
|
||||
every start — so it survives recreation, migration and Reset.
|
||||
|
||||
- **Certificates are renamed to `.crt`.** `update-ca-certificates` globs `*.crt`, case-sensitively;
|
||||
a `.pem` merely copied into `/usr/local/share/ca-certificates/` is ignored in total silence.
|
||||
`container_cert_name()` in Rust does the renaming, mirrored in a few lines of shell in the
|
||||
entrypoint. A single-file mount lands at `/tmp/.host-ca/<name>.crt`, so the entrypoint only ever
|
||||
sees a directory.
|
||||
- **The system store is not enough.** Only curl, git and apt read it. Node — and therefore Claude
|
||||
Code itself — needs `NODE_EXTRA_CA_CERTS`; Python and requests need
|
||||
`REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`; Chromium reads neither and wants its own NSS database at
|
||||
`~/.pki/nssdb`, seeded with `certutil` (from `libnss3-tools`). The NSS step warns and continues
|
||||
rather than failing the start.
|
||||
- **Those env vars are set from Rust at creation, never exported by the entrypoint.** A terminal
|
||||
session is a `docker exec`, which inherits the container's configured env and sees nothing the
|
||||
entrypoint exported — the same lesson that made `$BROWSER` an image-level `ENV`. They are emitted
|
||||
**empty** when no CA is configured, because `docker commit` bakes env into the snapshot image.
|
||||
- **`triple-c.ca-fingerprint` covers the certificate bytes, not the path.** Replacing a rotated CA
|
||||
at the same location still forces the recreation that copies it in. Clearing the setting actively
|
||||
**removes** `triple-c-*.crt` from the container — `/usr/local/share` rides the project's snapshot,
|
||||
so turning the feature off has to undo, not merely stop.
|
||||
|
||||
### Container Spawning (Sibling Containers)
|
||||
|
||||
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
||||
|
||||
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||
|
||||
### Docker Socket Path
|
||||
|
||||
The socket path is OS-aware:
|
||||
- **Linux/macOS**: `/var/run/docker.sock`
|
||||
- **Windows**: `//./pipe/docker_engine`
|
||||
|
||||
Users can override this in Settings via the global `docker_socket_path` option.
|
||||
|
||||
## Models and Authentication
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
Each project can independently use one of:
|
||||
|
||||
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
|
||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
|
||||
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain. Triple-C can run that gateway for you — see [Model Gateway](#model-gateway-litellm-sibling-container).
|
||||
|
||||
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
|
||||
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
|
||||
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
|
||||
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
|
||||
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
|
||||
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
|
||||
> they get first-class backends of their own rather than going through a translation layer.
|
||||
|
||||
#### Model alias variables
|
||||
|
||||
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
|
||||
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
|
||||
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
|
||||
|
||||
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
|
||||
Triple-C therefore sets all four:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
|
||||
|
||||
A local server usually serves exactly one model, so pointing every alias at it is the right
|
||||
default. If you run a second, smaller model for cheap background work, set **Background model**
|
||||
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
|
||||
|
||||
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
|
||||
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
|
||||
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
|
||||
|
||||
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||
|
||||
### Model Gateway (LiteLLM sibling container)
|
||||
|
||||
For providers that only speak OpenAI's API, Triple-C can run **LiteLLM** as a sibling container
|
||||
(`docker/gateway.rs`, `gateway-container/`) that gives Claude Code the Anthropic-format front end it
|
||||
requires. Settings → Gateway configures the provider prefix (`openai`, `azure`, `gemini`, `groq`,
|
||||
…), an optional API base override, the models to serve, and the host port (default `4000`). A
|
||||
project then consumes it with the OpenAI Compatible backend. It mirrors the STT container's
|
||||
lifecycle, including auto-start with the app.
|
||||
|
||||
Its bind address is **detected, never `0.0.0.0`**. Unlike STT, the consumers are *project
|
||||
containers*, so loopback alone is not always enough: Docker Desktop binds `127.0.0.1` and advertises
|
||||
`host.docker.internal`; native Linux binds the default bridge gateway (`172.17.0.1`) and advertises
|
||||
the same literal. `GatewayBinding` derives the bind address and the advertised `base_url` together
|
||||
so the two cannot drift. A wildcard bind would be LAN-reachable — Docker's rules precede host
|
||||
firewalls — in front of a config file holding a billed provider key. A LiteLLM `master_key` is
|
||||
**always** set, because LiteLLM without one accepts any key.
|
||||
|
||||
### Shared Claude Authentication Token
|
||||
|
||||
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
||||
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
||||
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
||||
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
||||
anything resembling an `sk-ant-` secret.
|
||||
|
||||
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
||||
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
||||
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
||||
|
||||
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
||||
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
||||
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
||||
forces a container recreation on the next start; that is when a container picks the token up or has
|
||||
it cleared.
|
||||
|
||||
## Bridges to the Host
|
||||
|
||||
### URL Relay (host browser)
|
||||
|
||||
@@ -175,96 +405,87 @@ recreates the container. The poller stops on its own when the container stops.
|
||||
unauthenticated service inside the container, so widening those addresses would publish container
|
||||
internals to the LAN. Nothing else on the network can reach a bridged port.
|
||||
|
||||
### Shared Claude Authentication Token
|
||||
### Browser View
|
||||
|
||||
Rather than running `claude login` in every container, `claude setup-token` can be run once
|
||||
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
||||
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
||||
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
||||
anything resembling an `sk-ant-` secret.
|
||||
Watch — and take over — the browser Claude is driving with Playwright inside the container. The
|
||||
**Browser** tab runs Playwright's own dashboard (`browser.bind()` plus `playwright-cli show`) in the
|
||||
container and fronts it with a **token-gated** loopback proxy on the host (`browser_view/`). Opt-in
|
||||
per project.
|
||||
|
||||
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
||||
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
||||
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
||||
- **It deliberately does not reuse the auth bridge's `PortForward`**, which binds an
|
||||
unauthenticated port — fine for a throwaway OAuth listener, wrong for remote control of a browser.
|
||||
Host ports are confined to `47820..=47827` because CSP `frame-src` cannot express a port range and
|
||||
has to enumerate them; a unit test asserts the Rust range matches `tauri.conf.json`.
|
||||
- **Pop out** puts the same URL in a second OS window (`popout.rs`), so the view can be watched on
|
||||
another monitor or pinned on top while the main window is used for work. No capability lists that
|
||||
window, so it has **no IPC surface**; the app CSP does not apply to it either, because it is a
|
||||
top-level document rather than a frame — the token gate is what protects the port in both cases.
|
||||
The window is owned by the *session*, so the supervisor's teardown closes it. The pane drops its
|
||||
iframe while popped out, and both viewers can drive the browser.
|
||||
- **Open page…** launches a browser in the container at a URL and viewport you choose and binds it,
|
||||
so the pane shows it (`page.rs`). This is what serves container-side auth — the OAuth callback
|
||||
listener is *in* the container, so a container-side browser closes the loop with no host round
|
||||
trip and no auth bridge — and dev servers on container loopback. Re-opening with a helper already
|
||||
up *navigates* rather than relaunching, so a session signed in on one page survives to the next.
|
||||
- **Resizing the window does not resize the page.** The viewer is a CDP screencast: a bigger window
|
||||
is the same pixels drawn larger. `page.setViewportSize()` is what reflows, and match-window mode
|
||||
pushes the pop-out's settled size into it, debounced by generation counter because a drag emits
|
||||
continuously and each event costs a container exec.
|
||||
- **Setup is two clicks, and nothing installs itself.** Detection has to look past `node_modules` —
|
||||
`claude mcp add … npx @playwright/mcp@latest` installs into `~/.npm/_npx/<hash>/node_modules` — and
|
||||
hops from a wrapper `playwright` to its **nested** `playwright-core`, because npm does not hoist
|
||||
for global installs and the wrapper ships no type definitions to read a version from. Installing
|
||||
puts Playwright in `/workspace` with `--no-save` (not a bind mount, so it touches nothing of
|
||||
yours) and browsers in `~/.cache/ms-playwright`, which is inside the home volume and so survives
|
||||
recreation *and* migration.
|
||||
- **`@playwright/mcp` can never satisfy this pane** on its own: it bundles a `playwright-core` that
|
||||
binds, but never `@playwright/cli`, which is the viewer. It is what binds sessions automatically
|
||||
once Playwright is present — not a setup route.
|
||||
|
||||
Rotation is tracked with a random id (not a hash of the token) mirrored into the
|
||||
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
|
||||
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
|
||||
forces a container recreation on the next start; that is when a container picks the token up or has
|
||||
it cleared.
|
||||
### Host File Transfers
|
||||
|
||||
### Container Lifecycle
|
||||
Four routes move files across the boundary: **Upload…** and the per-row **Save to host…** in the
|
||||
Files tab, a file dropped onto the Terminal tab, and **Back up container**. All four share one path
|
||||
policy in `commands/file_commands.rs`.
|
||||
|
||||
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, injects Claude Code settings, rebuilds the scheduler crontab
|
||||
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
||||
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
||||
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
|
||||
6. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
|
||||
- **The OS dialogs are opened by Rust, not by the webview.** `upload_files_to_container` and
|
||||
`download_container_file` drive `tauri-plugin-dialog` themselves and take nothing but a project
|
||||
id and a container-side path; `FilesTab.tsx` imports no dialog plugin and `useFileManager`'s
|
||||
`uploadFiles` takes no argument at all. The web UI can ask for a dialog, and that is the whole of
|
||||
its influence over where a file comes from or goes — it cannot name a host path as an *input*.
|
||||
This is a boundary rather than a convention: a dialog the page itself opens is only as trustworthy
|
||||
as the page. Be precise about the limit, though — host paths still travel *outward* in error text,
|
||||
canonical ones included, so this closes the inbound direction and not both.
|
||||
- **The dialog's pre-filled name is sanitized, because a container authored it.** On Windows the
|
||||
save dialog parses its name box as a path, and a container can name a file
|
||||
`..\..\Users\you\…\Word\STARTUP\x.dotm` — one POSIX segment, so nothing upstream objects.
|
||||
`suggested_save_name` replaces every separator and every character NTFS refuses, so the string
|
||||
cannot be a path on any platform this ships to.
|
||||
- **One policy for every host path.** A source or destination whose path passes through a hidden
|
||||
folder (`~/.ssh`, `~/.cache`, `~/.local/share`, anything dot-prefixed) or a system location is
|
||||
refused, and the check is applied both to the path as written and to what it resolves to after
|
||||
symlinks. It over-catches deliberately, so it will occasionally refuse somewhere a person
|
||||
genuinely meant — `~/.config`, say — and the refusal is a sentence naming the folder that tripped
|
||||
it, not an errno.
|
||||
- **Uploads are capped at 256 MB per file**; past that the answer is a mount, not a copy. One
|
||||
dialog's selection is handled file by file, so a folder or an oversized file among the selection
|
||||
is reported by name and does not stop the others. Uploaded files land owned by the container user,
|
||||
not root. A cancelled dialog is silent — `Ok(None)`, not an error.
|
||||
- **`download_container_file` is one file and files only** — no button on a folder row. A directory
|
||||
is what `download_container_backup` is for. There is no drop target on the Files pane; the
|
||||
Terminal tab keeps the one it has.
|
||||
|
||||
### Mounts
|
||||
## Inside a Project
|
||||
|
||||
| Target in Container | Source | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
|
||||
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
|
||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
|
||||
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
||||
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
|
||||
### Container Introspection (Capability Tiles)
|
||||
|
||||
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
||||
removal, and by nothing else.
|
||||
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
||||
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
|
||||
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
|
||||
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
|
||||
|
||||
### Authentication Modes
|
||||
|
||||
Each project can independently use one of:
|
||||
|
||||
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
|
||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
|
||||
- **llama.cpp**: Connect to a local or remote `llama-server` via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:8080` — 8080 is `llama-server`'s default port). `ANTHROPIC_AUTH_TOKEN` is set to a placeholder; `llama-server` ignores it unless it was started with `--api-key`.
|
||||
- **OpenAI Compatible**: Connect through a gateway that implements the **Anthropic Messages API**, via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain.
|
||||
|
||||
> **The endpoint must speak the Anthropic Messages API.** Claude Code only ever sends
|
||||
> `POST /v1/messages?beta=true` in Anthropic Messages format to `ANTHROPIC_BASE_URL` — it never
|
||||
> speaks OpenAI's `/v1/chat/completions`. So a server that exposes *only* an OpenAI-compatible API
|
||||
> (plain vLLM, text-generation-inference, LocalAI, OpenRouter, …) will **not** work behind any of
|
||||
> these backends. What does work: **LiteLLM**, which exposes an Anthropic-shaped route, and
|
||||
> **Ollama** and **llama.cpp**, both of which implement `POST /v1/messages` natively — which is why
|
||||
> they get first-class backends of their own rather than going through a translation layer.
|
||||
|
||||
#### Model alias variables
|
||||
|
||||
The `opus` / `sonnet` / `haiku` / `fable` aliases in Claude Code resolve to Anthropic model IDs by
|
||||
default. Against a local server those IDs do not exist, so anything that uses an alias fails —
|
||||
most visibly the **background** calls (conversation titles, summaries), which use `haiku`.
|
||||
|
||||
For every backend that points at a custom endpoint (Ollama, llama.cpp, OpenAI Compatible),
|
||||
Triple-C therefore sets all four:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `ANTHROPIC_DEFAULT_OPUS_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_SONNET_MODEL` | the backend's configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | the **Background model** override, else the configured model ID |
|
||||
| `ANTHROPIC_DEFAULT_FABLE_MODEL` | the backend's configured model ID |
|
||||
|
||||
A local server usually serves exactly one model, so pointing every alias at it is the right
|
||||
default. If you run a second, smaller model for cheap background work, set **Background model**
|
||||
(Config → Model, and in global Backend settings) and only the Haiku alias moves.
|
||||
|
||||
These are *not* set for the Anthropic or Bedrock backends, which reach servers that really do host
|
||||
the Anthropic model IDs. Triple-C manages all four names, so they cannot be set as custom
|
||||
environment variables. (`ANTHROPIC_SMALL_FAST_MODEL` is deprecated and is not used.)
|
||||
|
||||
> **Note:** Ollama, llama.cpp and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||
|
||||
### Container Spawning (Sibling Containers)
|
||||
|
||||
When "Allow container spawning" is enabled per-project, the host Docker socket is bind-mounted into the container. This allows Claude Code to create **sibling containers** (not nested Docker-in-Docker) that are visible to the host. The entrypoint detects the socket's GID and adds the `claude` user to the matching group.
|
||||
|
||||
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
||||
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
||||
|
||||
### Mission Control Integration
|
||||
|
||||
@@ -289,87 +510,117 @@ Triple-C includes optional speech-to-text powered by [Faster Whisper](https://gi
|
||||
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
|
||||
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
|
||||
- **Port**: Default `9876` (configurable)
|
||||
- **Input device**: Selectable in Settings when the host exposes more than one microphone
|
||||
- **Language**: Optional language hint for transcription
|
||||
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
|
||||
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
|
||||
|
||||
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
|
||||
|
||||
### Docker Socket Path
|
||||
|
||||
The socket path is OS-aware:
|
||||
- **Linux/macOS**: `/var/run/docker.sock`
|
||||
- **Windows**: `//./pipe/docker_engine`
|
||||
|
||||
Users can override this in Settings via the global `docker_socket_path` option.
|
||||
|
||||
## Key Files
|
||||
|
||||
### Frontend — layout and projects
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
|
||||
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring |
|
||||
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
|
||||
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs) |
|
||||
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs), pointer-event drag reordering |
|
||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
|
||||
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
|
||||
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
|
||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
||||
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
|
||||
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9`, `Ctrl+Shift+←/→` |
|
||||
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
|
||||
|
||||
### Frontend — Project Home
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip |
|
||||
| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks |
|
||||
| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume |
|
||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: toggle, run now, logs, remove, notifications |
|
||||
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: create, toggle, run now, logs, remove, notifications |
|
||||
| `app/src/components/projects/home/TaskEditorModal.tsx` | Create/edit a scheduled task; `taskValidation.ts` holds the cron and schedule rules |
|
||||
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
|
||||
| `app/src/components/projects/home/FilesTab.tsx` | Container-side file browser (navigate, view, rename, new folder) plus **Upload…** and per-row **Save to host…**; imports no dialog plugin — the dialogs are Rust's |
|
||||
| `app/src/components/projects/home/BrowserTab.tsx` | Browser view pane: detect, install, watch, take over, pop out |
|
||||
| `app/src/components/projects/home/OpenPageDialog.tsx` | Open a URL in the container's browser at a chosen viewport |
|
||||
| `app/src/components/projects/home/ContainerMigrationBanner.tsx` | Base-image staleness banner, migration progress, resume/rollback |
|
||||
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
|
||||
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) |
|
||||
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
|
||||
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9` |
|
||||
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
|
||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, shared auth, and global settings |
|
||||
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings → `tui`, `effortLevel`, `viewMode`, `autoScrollEnabled`, `showThinkingSummaries`, `awaySummaryEnabled`, plus the env-var flags (scrub, 1h caching). Every managed key is re-emitted on each start, `null` meaning "delete". |
|
||||
|
||||
### Frontend — settings, terminal and hooks
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, certificates, gateway, web terminal, STT, shared auth and global settings |
|
||||
| `app/src/components/settings/CertificateSettings.tsx` | Corporate CA certificate path (global), with `CaCertPathInput` |
|
||||
| `app/src/components/settings/GatewaySettings.tsx` | LiteLLM gateway: provider, API base, models, port, container controls |
|
||||
| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token |
|
||||
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
|
||||
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) |
|
||||
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, device, container controls) |
|
||||
| `app/src/components/settings/UpdateDialog.tsx` | New-release notice with download links (`update_commands.rs`) |
|
||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, OSC 7777 URL relay, image paste |
|
||||
| `app/src/components/terminal/SttButton.tsx` | Mic button with on-demand STT container start |
|
||||
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
|
||||
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
||||
| `app/src/hooks/useContainerMigration.ts` | Staleness polling, migration run, resume and rollback |
|
||||
| `app/src/hooks/useFileManager.ts` | File browser operations (list, navigate, rename, mkdir) and the host transfers (upload, save one file out); never handles a host path |
|
||||
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
||||
|
||||
### Backend (Rust)
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
|
||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
|
||||
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; one-shot execs and single-file tar building |
|
||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||
| `app/src-tauri/src/docker/migration.rs` | Base-image migration: manifest capture, delta computation, crash-recovery state machine |
|
||||
| `app/src-tauri/src/docker/ca_certs.rs` | CA certificate discovery, `.crt` renaming, fingerprinting |
|
||||
| `app/src-tauri/src/docker/gateway.rs` | LiteLLM sibling container: binding detection, config rendering, lifecycle |
|
||||
| `app/src-tauri/src/docker/stt.rs` | Speech-to-text container lifecycle |
|
||||
| `app/src-tauri/src/docker/legacy_cleanup.rs` | One-release migration shim removing leftovers from the deleted MCP feature |
|
||||
| `app/src-tauri/src/auth_bridge/` | Loopback callback bridge (`mod.rs`, `proc_net.rs`, `tunnel.rs`) |
|
||||
| `app/src-tauri/src/browser_view/` | Browser view: `detect.rs`, `install.rs`, `page.rs`, `popout.rs`, `proxy.rs`, `commands.rs` |
|
||||
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
||||
| `app/src-tauri/src/commands/migration_commands.rs` | Staleness, migrate, confirm, rollback, reconcile, `is_migrating` |
|
||||
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
|
||||
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
|
||||
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, shared-token opt-out) |
|
||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | Container-side file commands (list, read, rename, mkdir), the host transfers `upload_files_to_container` and `download_container_file` — each opening its own OS dialog here in Rust — plus `download_container_backup`, and the hidden-folder path policy all of them share |
|
||||
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, browser view, CA path, shared-token opt-out) |
|
||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, CA path, Claude Code settings, web terminal, STT, gateway) |
|
||||
| `app/src-tauri/src/models/gateway_settings.rs` | Gateway provider, models, port and API base |
|
||||
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
|
||||
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
|
||||
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
|
||||
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
|
||||
| `app/src-tauri/src/docker/stt.rs` | STT Docker container lifecycle (create, start, stop, build, pull) |
|
||||
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
||||
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
|
||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, Claude Code settings injection, Mission Control setup |
|
||||
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, gateway keys, rotation id) |
|
||||
|
||||
### Container and packaging
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims + browser runtime libraries |
|
||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, CA installation, Docker group config, Claude Code settings injection, Mission Control setup |
|
||||
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||
| `container/triple-c-open` | URL relay shim (xdg-open/`$BROWSER`/sensible-browser via OSC 7777); prints the URL when no terminal is attached |
|
||||
| `app/src/lib/urlRelay.ts` | Host-side relay validation: OSC 7777 parsing, http/https allowlist, rate limiting |
|
||||
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
|
||||
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
|
||||
| `container/triple-c-sso-refresh` | AWS SSO session refresh helper |
|
||||
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, rotation id) |
|
||||
| `gateway-container/` | LiteLLM image and rendered `config.yaml` for the model gateway |
|
||||
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
||||
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
|
||||
| `branding/` | Logo sources, palette, and `build-icons.py`, which generates every packaged icon |
|
||||
|
||||
## CSS / Styling Notes
|
||||
|
||||
@@ -382,7 +633,7 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
|
||||
**Base**: Ubuntu 24.04
|
||||
|
||||
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
|
||||
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential, `libnss3-tools` (for `certutil`, used to seed Chromium's CA store)
|
||||
|
||||
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `xdg-open`/`sensible-browser`/`www-browser`/`x-www-browser`/`$BROWSER` (OSC 7777 URL relay to the host browser), `rec`/`arecord` (audio FIFO for voice mode)
|
||||
|
||||
@@ -406,4 +657,10 @@ The libraries are the opposite — a runtime `apt-get install` lands in the cont
|
||||
layer, is re-paid after every Reset, and is lost on migration (which replays apt from a manifest
|
||||
against the new base). Baking one and not the other puts each half where it already persists.
|
||||
|
||||
**`/home/claude` in the image is seed-only.** It is the mount point of the `triple-c-home-{projectId}`
|
||||
volume, so after a project's *first* start the image's copy of that directory is masked permanently.
|
||||
A change made under `/home/claude` in the Dockerfile reaches **new projects only** — with or without
|
||||
a base-image migration. Anything that must stay upgradable belongs in `/usr/local/bin` or `/opt`, or
|
||||
must be seeded by `entrypoint.sh` on every start.
|
||||
|
||||
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||
|
||||
@@ -26,20 +26,35 @@ scheduler, and the fleet view across many projects.
|
||||
|
||||
## Current coverage (v0.3.0)
|
||||
|
||||
Triple-C sets exactly five `settings.json` keys, plus a sandbox block:
|
||||
Triple-C sets exactly six `settings.json` keys, plus a sandbox block:
|
||||
|
||||
| Key | Surfaced as |
|
||||
|---|---|
|
||||
| `tui` | TUI Mode select (`fullscreen`) |
|
||||
| `effort` | Effort Level select (`low`/`medium`/`high`) |
|
||||
| `autoScrollEnabled` | Auto-Scroll Disabled toggle |
|
||||
| `focusMode` | Focus Mode toggle |
|
||||
| `showThinkingSummaries` | Thinking Summaries toggle |
|
||||
| `tui` | TUI mode select — unset (Claude Code chooses), `default` (classic renderer), `fullscreen` (flicker-free alt-screen). Three distinct states, not two. |
|
||||
| `effortLevel` | Effort level select (`low`/`medium`/`high`/`xhigh`) |
|
||||
| `viewMode` | Focus mode toggle, written as `"focus"`. Unset means the user's own `verbose` setting and sticky `/focus` choice still apply. |
|
||||
| `autoScrollEnabled` | Auto-scroll toggle. Claude Code's default is `true`, so it is the *off* state that writes `false`. |
|
||||
| `showThinkingSummaries` | Thinking summaries toggle (Claude Code default `false`) |
|
||||
| `awaySummaryEnabled` | Session recap toggle. Claude Code's recap is **on** by default, so again it is the off state that writes `false`. |
|
||||
| `sandbox.*` | Sandbox toggle (`enabled`, `enableWeakerNestedSandbox`, `allowUnsandboxedCommands`) |
|
||||
|
||||
Every one of those keys is emitted on **every** start, with a JSON `null` standing for
|
||||
"delete this key". `~/.claude/settings.json` sits on the config volume and the entrypoint
|
||||
merges into it, so a key merely omitted when its control goes off left the previous
|
||||
on-value in place forever.
|
||||
|
||||
Plus four env feature flags — `CLAUDE_CODE_NO_FLICKER`, `CLAUDE_CODE_ENABLE_AWAY_SUMMARY`,
|
||||
`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, `ENABLE_PROMPT_CACHING_1H` — and arbitrary user-set
|
||||
`CLAUDE_CODE_*` vars via the Env Vars modal.
|
||||
`CLAUDE_CODE_*` vars via the Env Vars modal. The four are written on every container
|
||||
create *including* their off value, because `docker commit` bakes a container's env into
|
||||
the snapshot image: a value written once would otherwise ride that snapshot into every
|
||||
future container. That also makes them Triple-C's to own, so all four are reserved names
|
||||
— hand-setting one in the Env Vars modal is skipped with a warning, the same as any other
|
||||
`triple-c.*`-managed variable. `CLAUDE_CODE_ENABLE_AWAY_SUMMARY` is what actually enforces
|
||||
the recap choice — it takes precedence over `awaySummaryEnabled` *and* over the
|
||||
in-container `/config` toggle, so turning the control off sends `0` while leaving it on
|
||||
sends an empty value rather than `1`: Triple-C's default must not overrule a `/config`
|
||||
choice it never asked about.
|
||||
|
||||
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
||||
|
||||
@@ -412,13 +412,18 @@ triple-c/
|
||||
│
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
||||
│ ├── build-app-preview.yml # Preview builds
|
||||
│ ├── build.yml # Build container image (multi-arch)
|
||||
│ ├── build-stt.yml # Build the STT image
|
||||
│ ├── sync-release.yml # Mirror releases to GitHub
|
||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│ └── cleanup-releases.yml # Prune old releases
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline
|
||||
│ ├── build-app-preview.yml # Preview builds
|
||||
│ ├── build.yml # Build container image (multi-arch)
|
||||
│ ├── build-stt.yml # Build the STT image
|
||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||
│ ├── 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
|
||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||
@@ -436,7 +441,7 @@ triple-c/
|
||||
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
|
||||
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
|
||||
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||
│ │ ├── useFileManager.ts # File browser operations
|
||||
│ │ ├── useFileManager.ts # File browser operations + host transfers
|
||||
│ │ ├── useInstallHelper.ts # Guided Docker installation
|
||||
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
|
||||
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
|
||||
@@ -464,7 +469,7 @@ triple-c/
|
||||
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
|
||||
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
|
||||
│ │ │ ├── ConfigTab.tsx # Config section host
|
||||
│ │ │ ├── FilesTab.tsx # In-container file browser
|
||||
│ │ │ ├── FilesTab.tsx # In-container file browser, upload / save to host
|
||||
│ │ │ ├── CapabilityTiles.tsx # Read-only capability counts
|
||||
│ │ │ ├── format.ts # Age / size / uptime formatting
|
||||
│ │ │ └── config/ # WorkspaceSection, ModelSection,
|
||||
@@ -504,7 +509,7 @@ triple-c/
|
||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||
│ ├── docker_commands.rs # Docker status, image ops
|
||||
│ ├── file_commands.rs # File browser (list/download/upload)
|
||||
│ ├── file_commands.rs # File browser + host transfers (Rust-opened dialogs)
|
||||
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Triple-C</title>
|
||||
</head>
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
{
|
||||
"name": "triple-c",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "triple-c",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@xterm/addon-fit": "^0.10",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.18",
|
||||
@@ -2001,15 +2000,6 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-store": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz",
|
||||
"integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "triple-c",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,13 +9,13 @@
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"hooks": "git -C .. config core.hooksPath .githooks && echo \"pre-commit secret scan enabled\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@xterm/addon-fit": "^0.10",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.18",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Triple-C">
|
||||
<title>Triple-C application icon, small-size variant</title>
|
||||
<!-- Source for every raster ≤ 32 px: the small mark, drawn at 82% so the strokes
|
||||
survive being resampled down to 16 px. See build-icons.py. -->
|
||||
<rect width="128" height="128" rx="28.16" fill="#0D1117"/>
|
||||
<g transform="translate(2.9236 2.9236) scale(0.95418)">
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M112 50 L112 38 A22 22 0 0 0 90 16 L38 16 A22 22 0 0 0 16 38 L16 90 A22 22 0 0 0 38 112 L90 112 A22 22 0 0 0 112 90 L112 78"
|
||||
stroke="#58A6FF" stroke-width="14"/>
|
||||
<path d="M46 50 L62 66 L46 82" stroke="#F0821E" stroke-width="13"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 812 B |
@@ -8,6 +8,41 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -47,6 +82,18 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -280,6 +327,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -310,6 +363,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -569,6 +631,16 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -753,6 +826,15 @@ version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
@@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1135,7 +1218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1550,6 +1633,16 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
@@ -2114,6 +2207,15 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -2831,6 +2933,12 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
@@ -2913,6 +3021,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -3194,6 +3313,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -3394,7 +3525,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3743,7 +3874,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4654,22 +4785,6 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.0"
|
||||
@@ -4782,7 +4897,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5163,8 +5278,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "triple-c"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bollard",
|
||||
@@ -5187,10 +5304,10 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-store",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5304,6 +5421,16 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -5689,7 +5816,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "triple-c"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -13,7 +13,6 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["image-png", "image-ico"] }
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -37,6 +36,9 @@ tower-http = { version = "0.6", features = ["cors"] }
|
||||
base64 = "0.22"
|
||||
rand = "0.9"
|
||||
local-ip-address = "0.6"
|
||||
argon2 = "0.5"
|
||||
aes-gcm = "0.10"
|
||||
zeroize = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||
|
||||
@@ -2473,180 +2473,6 @@
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`",
|
||||
"type": "string",
|
||||
"const": "store:default",
|
||||
"markdownDescription": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the clear command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-clear",
|
||||
"markdownDescription": "Enables the clear command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-delete",
|
||||
"markdownDescription": "Enables the delete command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the entries command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-entries",
|
||||
"markdownDescription": "Enables the entries command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-get",
|
||||
"markdownDescription": "Enables the get command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-get-store",
|
||||
"markdownDescription": "Enables the get_store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the has command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-has",
|
||||
"markdownDescription": "Enables the has command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the keys command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-keys",
|
||||
"markdownDescription": "Enables the keys command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the length command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-length",
|
||||
"markdownDescription": "Enables the length command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the load command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-load",
|
||||
"markdownDescription": "Enables the load command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reload command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-reload",
|
||||
"markdownDescription": "Enables the reload command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reset command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-reset",
|
||||
"markdownDescription": "Enables the reset command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-set",
|
||||
"markdownDescription": "Enables the set command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the values command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-values",
|
||||
"markdownDescription": "Enables the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the clear command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-clear",
|
||||
"markdownDescription": "Denies the clear command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-delete",
|
||||
"markdownDescription": "Denies the delete command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the entries command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-entries",
|
||||
"markdownDescription": "Denies the entries command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-get",
|
||||
"markdownDescription": "Denies the get command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-get-store",
|
||||
"markdownDescription": "Denies the get_store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the has command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-has",
|
||||
"markdownDescription": "Denies the has command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the keys command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-keys",
|
||||
"markdownDescription": "Denies the keys command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the length command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-length",
|
||||
"markdownDescription": "Denies the length command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the load command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-load",
|
||||
"markdownDescription": "Denies the load command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reload command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-reload",
|
||||
"markdownDescription": "Denies the reload command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reset command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-reset",
|
||||
"markdownDescription": "Denies the reset command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-set",
|
||||
"markdownDescription": "Denies the set command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the values command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2473,180 +2473,6 @@
|
||||
"type": "string",
|
||||
"const": "opener:deny-reveal-item-in-dir",
|
||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`",
|
||||
"type": "string",
|
||||
"const": "store:default",
|
||||
"markdownDescription": "This permission set configures what kind of\noperations are available from the store plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-load`\n- `allow-get-store`\n- `allow-set`\n- `allow-get`\n- `allow-has`\n- `allow-delete`\n- `allow-clear`\n- `allow-reset`\n- `allow-keys`\n- `allow-values`\n- `allow-entries`\n- `allow-length`\n- `allow-reload`\n- `allow-save`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the clear command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-clear",
|
||||
"markdownDescription": "Enables the clear command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the delete command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-delete",
|
||||
"markdownDescription": "Enables the delete command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the entries command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-entries",
|
||||
"markdownDescription": "Enables the entries command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-get",
|
||||
"markdownDescription": "Enables the get command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the get_store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-get-store",
|
||||
"markdownDescription": "Enables the get_store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the has command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-has",
|
||||
"markdownDescription": "Enables the has command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the keys command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-keys",
|
||||
"markdownDescription": "Enables the keys command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the length command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-length",
|
||||
"markdownDescription": "Enables the length command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the load command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-load",
|
||||
"markdownDescription": "Enables the load command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reload command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-reload",
|
||||
"markdownDescription": "Enables the reload command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the reset command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-reset",
|
||||
"markdownDescription": "Enables the reset command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the set command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-set",
|
||||
"markdownDescription": "Enables the set command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the values command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:allow-values",
|
||||
"markdownDescription": "Enables the values command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the clear command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-clear",
|
||||
"markdownDescription": "Denies the clear command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the delete command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-delete",
|
||||
"markdownDescription": "Denies the delete command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the entries command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-entries",
|
||||
"markdownDescription": "Denies the entries command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-get",
|
||||
"markdownDescription": "Denies the get command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the get_store command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-get-store",
|
||||
"markdownDescription": "Denies the get_store command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the has command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-has",
|
||||
"markdownDescription": "Denies the has command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the keys command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-keys",
|
||||
"markdownDescription": "Denies the keys command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the length command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-length",
|
||||
"markdownDescription": "Denies the length command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the load command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-load",
|
||||
"markdownDescription": "Denies the load command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reload command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-reload",
|
||||
"markdownDescription": "Denies the reload command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the reset command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-reset",
|
||||
"markdownDescription": "Denies the reset command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the set command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-set",
|
||||
"markdownDescription": "Denies the set command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the values command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "store:deny-values",
|
||||
"markdownDescription": "Denies the values command without any pre-configured scope."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 918 B After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 16 KiB |
@@ -82,6 +82,10 @@ pub struct BridgedPort {
|
||||
pub family: PortFamily,
|
||||
/// RFC 3339 timestamp of when the host listener was bound.
|
||||
pub bridged_at: String,
|
||||
/// Set when only the IPv4 half of the host listener could be bound. The
|
||||
/// port still works, but not for a client that insists on `::1` — see
|
||||
/// [`tunnel::PortForward::ipv6_warning`].
|
||||
pub ipv6_warning: Option<String>,
|
||||
}
|
||||
|
||||
/// A loopback listener that was discovered but could not be bridged.
|
||||
@@ -132,6 +136,7 @@ impl BridgeState {
|
||||
port: f.port,
|
||||
family: f.family,
|
||||
bridged_at: f.bridged_at.clone(),
|
||||
ipv6_warning: f.ipv6_warning.clone(),
|
||||
})
|
||||
.collect(),
|
||||
conflicts: self
|
||||
@@ -329,7 +334,10 @@ async fn poll_loop(
|
||||
Ok(text) => {
|
||||
exec_failures = 0;
|
||||
let discovered = proc_net::parse_loopback_listeners(&text);
|
||||
let skip = skipped_ports(&project);
|
||||
// Re-read every tick: a project can gain a port mapping and the
|
||||
// gateway/STT/web-terminal ports can be re-pointed while the
|
||||
// bridge is running, and a stale reservation set is a hole.
|
||||
let skip = skipped_ports(&project, &store.list(), &app_settings(&app));
|
||||
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||
emit_status(&app, &project_id, &state, true).await;
|
||||
}
|
||||
@@ -372,24 +380,101 @@ async fn poll_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// Ports Docker already handles for this project. A container port that is
|
||||
/// explicitly published has a host-side path already, and the mapping's host
|
||||
/// port is a binding we must not fight over.
|
||||
/// Every port this project's bridge must not take.
|
||||
///
|
||||
/// [`RESERVED_CONTAINER_PORTS`] is folded in as well: those are container
|
||||
/// loopback listeners another feature owns and exposes on its own,
|
||||
/// authenticated terms.
|
||||
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
||||
/// The bridge's rule is "a container loopback listener on port N becomes an
|
||||
/// **unauthenticated** host listener on port N". That is only safe for ports
|
||||
/// nothing else on the host owns, so everything that *is* owned has to be
|
||||
/// enumerated here. Four sources:
|
||||
///
|
||||
/// 1. **This project's own published ports** — a container port that Docker
|
||||
/// already publishes has a host-side path, and the mapping's host port is a
|
||||
/// binding we must not fight over.
|
||||
/// 2. **Every other project's published host ports.** The container names the
|
||||
/// *host* port, so project A's container listening on 8080 would otherwise
|
||||
/// have the bridge bind host 8080 — the port project B publishes on. Only
|
||||
/// the host end of another project's mapping is reserved: its container end
|
||||
/// is a number inside a different network namespace and means nothing here.
|
||||
/// 3. **This app's own host services** — the LiteLLM gateway, the STT sidecar
|
||||
/// and the web terminal. All three are off by default and bind on demand, so
|
||||
/// first-come would win: a container that binds container-loopback 4000
|
||||
/// while the gateway is stopped gets host `127.0.0.1:4000` mirrored to it
|
||||
/// within one [`POLL_INTERVAL`], after which the gateway cannot start and
|
||||
/// anything on the host dialling 4000 — including *other project
|
||||
/// containers*, which reach the gateway by host address — is talking to the
|
||||
/// squatting container instead. The web terminal is the worst of the three,
|
||||
/// because its access token travels in the URL query. Both the *configured*
|
||||
/// port and the shipped default are reserved: the configured one is what the
|
||||
/// service will bind next, and the default is what it falls back to for a
|
||||
/// fresh profile or a settings file that failed to parse.
|
||||
/// 4. [`RESERVED_CONTAINER_PORTS`] and [`RESERVED_HOST_PORTS`] — the
|
||||
/// browser-view pane's two ends, which it exposes on its own authenticated
|
||||
/// terms.
|
||||
///
|
||||
/// Pure on purpose: everything it needs is passed in, so the whole reservation
|
||||
/// policy is unit-testable without a store, a container or an app handle.
|
||||
fn skipped_ports(
|
||||
project: &crate::models::Project,
|
||||
all_projects: &[crate::models::Project],
|
||||
settings: &crate::models::AppSettings,
|
||||
) -> HashSet<u16> {
|
||||
let mut skip: HashSet<u16> = project
|
||||
.port_mappings
|
||||
.iter()
|
||||
.flat_map(|m| [m.container_port, m.host_port])
|
||||
.collect();
|
||||
|
||||
// Other projects: host end only.
|
||||
skip.extend(
|
||||
all_projects
|
||||
.iter()
|
||||
.filter(|p| p.id != project.id)
|
||||
.flat_map(|p| p.port_mappings.iter().map(|m| m.host_port)),
|
||||
);
|
||||
|
||||
skip.extend(app_service_host_ports(settings));
|
||||
skip.extend(RESERVED_CONTAINER_PORTS.clone());
|
||||
skip.extend(RESERVED_HOST_PORTS.clone());
|
||||
skip
|
||||
}
|
||||
|
||||
/// Current app settings, or defaults if the state is not reachable.
|
||||
///
|
||||
/// Falling back rather than unwrapping matters: the reservation set is a safety
|
||||
/// rail, and a rail that panics the poller when it cannot read its input is
|
||||
/// worse than one that falls back to the shipped port numbers — which are what
|
||||
/// the services use anyway until someone changes them.
|
||||
fn app_settings(app: &AppHandle) -> crate::models::AppSettings {
|
||||
use tauri::Manager;
|
||||
app.try_state::<crate::AppState>()
|
||||
.map(|state| state.settings_store.get())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Host ports this app's own sibling services bind, configured value and
|
||||
/// shipped default alike.
|
||||
///
|
||||
/// Read off the settings models rather than restated as literals here: a
|
||||
/// duplicated port number is exactly the kind of constant that drifts silently,
|
||||
/// and the failure mode of drift is a reservation that no longer covers the
|
||||
/// service it was written for.
|
||||
fn app_service_host_ports(settings: &crate::models::AppSettings) -> Vec<u16> {
|
||||
use crate::models::{SttSettings, WebTerminalSettings};
|
||||
|
||||
vec![
|
||||
// LiteLLM gateway (`docker/gateway.rs`).
|
||||
settings.gateway.port,
|
||||
crate::models::default_gateway_port(),
|
||||
// Speech-to-text sidecar (`docker/stt.rs`).
|
||||
settings.stt.port,
|
||||
SttSettings::default().port,
|
||||
// Remote web terminal (`web_terminal/server.rs`) — binds 0.0.0.0, and
|
||||
// its access token is in the URL query.
|
||||
settings.web_terminal.port,
|
||||
WebTerminalSettings::default().port,
|
||||
]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Reservations
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -586,7 +671,7 @@ async fn emit_status(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{PortMapping, Project, ProjectPath};
|
||||
use crate::models::{AppSettings, PortMapping, Project, ProjectPath};
|
||||
|
||||
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
||||
let mut p = Project::new(
|
||||
@@ -607,9 +692,14 @@ mod tests {
|
||||
p
|
||||
}
|
||||
|
||||
/// The common case: one project, no siblings, stock settings.
|
||||
fn skip_for(project: &Project) -> HashSet<u16> {
|
||||
skipped_ports(project, std::slice::from_ref(project), &AppSettings::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_already_published_by_docker_are_skipped() {
|
||||
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||
let skip = skip_for(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||
assert!(skip.contains(&3000));
|
||||
// Both ends of an asymmetric mapping are off limits: the container port
|
||||
// is already reachable, and the host port is Docker's binding.
|
||||
@@ -619,20 +709,96 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mappings_means_nothing_but_the_reserved_ranges_are_skipped() {
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
fn no_mappings_means_nothing_but_the_reservations_are_skipped() {
|
||||
let settings = AppSettings::default();
|
||||
let project = project_with_mappings(vec![]);
|
||||
let skip = skip_for(&project);
|
||||
|
||||
let mut expected: HashSet<u16> = RESERVED_CONTAINER_PORTS.collect();
|
||||
expected.extend(RESERVED_HOST_PORTS);
|
||||
expected.extend(app_service_host_ports(&settings));
|
||||
assert_eq!(skip, expected);
|
||||
|
||||
// The ranges and the service ports are disjoint, so nothing above is
|
||||
// accidentally counting the same port twice.
|
||||
assert_eq!(
|
||||
skip.len(),
|
||||
RESERVED_CONTAINER_PORTS.clone().count() + RESERVED_HOST_PORTS.clone().count()
|
||||
RESERVED_CONTAINER_PORTS.clone().count()
|
||||
+ RESERVED_HOST_PORTS.clone().count()
|
||||
+ 3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_apps_own_host_services_are_never_taken() {
|
||||
// The bug this guards: the reserved set used to cover only the
|
||||
// browser-view ranges and this project's own mappings, so a container
|
||||
// binding container-loopback 4000 / 9876 / 7681 while the matching
|
||||
// service was stopped had that port mirrored, unauthenticated, onto the
|
||||
// host — taking the gateway's, the STT sidecar's or the web terminal's
|
||||
// door before they could bind it.
|
||||
let settings = AppSettings::default();
|
||||
let skip = skip_for(&project_with_mappings(vec![]));
|
||||
|
||||
assert!(skip.contains(&settings.gateway.port), "LiteLLM gateway port");
|
||||
assert!(skip.contains(&settings.stt.port), "STT sidecar port");
|
||||
assert!(skip.contains(&settings.web_terminal.port), "web terminal port");
|
||||
|
||||
// The shipped defaults, spelled out once so a change to any of them is
|
||||
// a change to this assertion and not a silent narrowing.
|
||||
assert!(skip.contains(&4000));
|
||||
assert!(skip.contains(&9876));
|
||||
assert!(skip.contains(&7681));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reconfigured_service_port_is_reserved_alongside_its_default() {
|
||||
let mut settings = AppSettings::default();
|
||||
settings.gateway.port = 4321;
|
||||
settings.stt.port = 9000;
|
||||
settings.web_terminal.port = 8443;
|
||||
let project = project_with_mappings(vec![]);
|
||||
let skip = skipped_ports(&project, std::slice::from_ref(&project), &settings);
|
||||
|
||||
for port in [4321, 9000, 8443] {
|
||||
assert!(skip.contains(&port), "configured port {} should be reserved", port);
|
||||
}
|
||||
// The default stays reserved too: it is what the service falls back to
|
||||
// for a fresh profile or an unparseable settings file, so leaving it
|
||||
// open is leaving the same squat available one restart later.
|
||||
for port in [4000, 9876, 7681] {
|
||||
assert!(skip.contains(&port), "default port {} should be reserved", port);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_projects_published_host_port_is_not_stolen() {
|
||||
// The container names the *host* port. Without this, project A's
|
||||
// container listening on 8080 takes the host 8080 that project B
|
||||
// publishes on — the bridge wins the race whenever B's container is not
|
||||
// running yet.
|
||||
let mine = project_with_mappings(vec![]);
|
||||
let mut theirs = project_with_mappings(vec![(8080, 3000)]);
|
||||
theirs.id = format!("{}-other", mine.id);
|
||||
|
||||
let skip = skipped_ports(
|
||||
&mine,
|
||||
&[mine.clone(), theirs.clone()],
|
||||
&AppSettings::default(),
|
||||
);
|
||||
assert!(skip.contains(&8080), "another project's host port");
|
||||
// …but not the other project's *container* port: that number lives in a
|
||||
// different network namespace and means nothing on this host, and
|
||||
// reserving it would refuse a legitimate login callback for no reason.
|
||||
assert!(!skip.contains(&3000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_views_host_ports_are_never_taken() {
|
||||
// The bridge binds *host* ports chosen by the container, so without
|
||||
// this it can take the port the browser-view proxy will want later —
|
||||
// that pane binds on demand, so first-come would win.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
let skip = skip_for(&project_with_mappings(vec![]));
|
||||
for port in RESERVED_HOST_PORTS {
|
||||
assert!(skip.contains(&port), "host port {} should be reserved", port);
|
||||
}
|
||||
@@ -688,14 +854,14 @@ mod tests {
|
||||
// Mirroring these would publish an ungated second door to the
|
||||
// Playwright dashboard, which the pane deliberately keeps behind a
|
||||
// token-checking listener.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
let skip = skip_for(&project_with_mappings(vec![]));
|
||||
for port in RESERVED_CONTAINER_PORTS {
|
||||
assert!(skip.contains(&port), "port {} should be reserved", port);
|
||||
}
|
||||
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
||||
|
||||
// Reservations coexist with Docker's own published ports.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000)]));
|
||||
let skip = skip_for(&project_with_mappings(vec![(3000, 3000)]));
|
||||
assert!(skip.contains(RESERVED_CONTAINER_PORTS.start()));
|
||||
assert!(skip.contains(&3000));
|
||||
}
|
||||
|
||||
@@ -13,8 +13,48 @@
|
||||
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||
//! interactive terminal sessions are built on.
|
||||
//!
|
||||
//! ## What the host listener is, and is not
|
||||
//!
|
||||
//! The listener is **not authenticated**, and cannot be. The port number is
|
||||
//! chosen by whatever CLI is logging in, the redirect URL is the provider's, and
|
||||
//! nothing in that chain can be taught to present a token — so there is no path
|
||||
//! token to add. Anything that can reach `127.0.0.1:<port>` on this host reaches
|
||||
//! the container-side listener. That includes **any web page the user has open**,
|
||||
//! which can port-scan loopback from script.
|
||||
//!
|
||||
//! Two things narrow that, and neither is a substitute for the other:
|
||||
//!
|
||||
//! * The whole feature is opt-in per project, off by default, and only mirrors
|
||||
//! ports while its container is running.
|
||||
//! * [`web_request_verdict`] refuses the one case that is unambiguously a web
|
||||
//! page reaching in: a request whose fetch metadata says it is a cross-site
|
||||
//! **sub-resource** (`fetch`, `XMLHttpRequest`, `<img>`, `<script src>`,
|
||||
//! `<iframe>`). Cross-site *navigations* are allowed, because that is exactly
|
||||
//! what an OAuth redirect is.
|
||||
//!
|
||||
//! The residual risk, stated plainly rather than papered over: a client that
|
||||
//! sends no `Sec-Fetch-Site` header at all is not filtered — that is every
|
||||
//! non-browser client (which is the point; `curl`, a CLI, the container's own
|
||||
//! probe must all still work) but also any browser predating fetch metadata
|
||||
//! (Chrome < 76, Firefox < 90, Safari < 16.4). A page can also still reach the
|
||||
//! port with a top-level navigation it opens itself (`window.open`), which
|
||||
//! carries `Sec-Fetch-Mode: navigate` and is indistinguishable from the redirect
|
||||
//! the bridge exists to deliver. And nothing here inspects *what* is behind the
|
||||
//! port: if the container has something more interesting than a throwaway OAuth
|
||||
//! listener on loopback, a same-machine caller reaches it.
|
||||
//!
|
||||
//! ## Bounds
|
||||
//!
|
||||
//! Every accepted connection costs a `docker exec`, and the number of
|
||||
//! connections is decided by whoever can reach the port. So each forward caps
|
||||
//! concurrent connections ([`MAX_CONNECTIONS`]), refuses a client that opens a
|
||||
//! socket and then says nothing ([`FIRST_BYTE_TIMEOUT`], enforced *before* the
|
||||
//! exec is created), and drops a connection the container has gone quiet on
|
||||
//! ([`IDLE_TIMEOUT`]).
|
||||
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::container::LogOutput;
|
||||
use futures_util::StreamExt;
|
||||
@@ -30,6 +70,38 @@ use super::proc_net::PortFamily;
|
||||
/// only needs to not be pathological.
|
||||
const PUMP_BUF: usize = 16 * 1024;
|
||||
|
||||
/// Concurrent connections one forwarded port will carry.
|
||||
///
|
||||
/// Each one is a `docker exec`, and the client side is anything on the host that
|
||||
/// can dial loopback — including a web page in a loop. A login callback is one
|
||||
/// connection, occasionally a handful; this is generous for that and still a
|
||||
/// bound the engine will not notice.
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
/// How long an accepted connection has to send its first byte before it is
|
||||
/// dropped, *without* a `docker exec` ever being created for it.
|
||||
///
|
||||
/// This is a deliberate narrowing of what the bridge carries: a client that
|
||||
/// connects and says nothing is not the HTTP OAuth callback this exists for, and
|
||||
/// forwarding it costs a container exec for a socket that may never speak. A
|
||||
/// server-speaks-first protocol behind a bridged port would be refused by this;
|
||||
/// that is the trade, and it is the only protocol shape affected.
|
||||
const FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// How long a live connection may go with nothing coming back from the container
|
||||
/// before it is torn down. Generous, because a bridged port is not always a
|
||||
/// short OAuth callback — but finite, so an abandoned connection cannot pin an
|
||||
/// exec forever.
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
/// Ceiling on the request head buffered for [`web_request_verdict`]. Real heads
|
||||
/// are well under 8 KiB; past this we stop looking and forward what we have.
|
||||
const MAX_HEAD: usize = 32 * 1024;
|
||||
|
||||
/// How long the rest of a request head has, once the first line has identified
|
||||
/// the connection as HTTP. Only a stalled or hostile client reaches it.
|
||||
const HEAD_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
||||
/// child running.
|
||||
struct AbortOnDrop(JoinHandle<()>);
|
||||
@@ -52,6 +124,15 @@ pub struct PortForward {
|
||||
pub port: u16,
|
||||
pub family: PortFamily,
|
||||
pub bridged_at: String,
|
||||
/// Why `[::1]` could not be taken alongside `127.0.0.1`, if it could not.
|
||||
///
|
||||
/// A half-bound forward is the one failure mode that looks like a success:
|
||||
/// the status says the port is bridged, and a browser that resolves
|
||||
/// `localhost` to `::1` and does not fall back still gets a refused
|
||||
/// connection. It is not a conflict — the IPv4 half really is carrying
|
||||
/// traffic — so it rides along with the port it belongs to and the UI says
|
||||
/// so, rather than being logged at debug where nobody sees it.
|
||||
pub ipv6_warning: Option<String>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -86,18 +167,33 @@ impl PortForward {
|
||||
// first, so a v4-only host listener would miss those callbacks. This is
|
||||
// best-effort: if ::1 is unavailable (no IPv6, or that half is taken)
|
||||
// the v4 listener alone still works, so it is not treated as a conflict.
|
||||
let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||
Ok(l) => Some(l),
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only",
|
||||
port,
|
||||
port,
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
let (v6, ipv6_warning) =
|
||||
match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||
Ok(l) => (Some(l), None),
|
||||
Err(e) => {
|
||||
// Warn, not debug. Best-effort is about whether to *fail*,
|
||||
// not about whether to say anything: on a host where
|
||||
// `localhost` resolves to `::1` and the client does not
|
||||
// fall back to IPv4, the callback is refused while the
|
||||
// bridge reports itself healthy — a silent failure with no
|
||||
// thread back to this line.
|
||||
log::warn!(
|
||||
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only; \
|
||||
a client that resolves localhost to ::1 without falling back will not reach it",
|
||||
port,
|
||||
port,
|
||||
e
|
||||
);
|
||||
(
|
||||
None,
|
||||
Some(format!(
|
||||
"IPv4 only — [::1]:{} could not be bound ({}). A browser that resolves \
|
||||
localhost to ::1 without falling back will not reach this port.",
|
||||
port, e
|
||||
)),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let target = family.socat_target(port);
|
||||
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
|
||||
@@ -106,6 +202,7 @@ impl PortForward {
|
||||
port,
|
||||
family,
|
||||
bridged_at: chrono::Utc::now().to_rfc3339(),
|
||||
ipv6_warning,
|
||||
task,
|
||||
})
|
||||
}
|
||||
@@ -142,6 +239,22 @@ async fn accept_loop(
|
||||
|
||||
match accepted {
|
||||
Ok((stream, peer)) => {
|
||||
// Reap first, so the cap counts *live* connections rather than
|
||||
// every one this listener has ever accepted.
|
||||
while conns.try_join_next().is_some() {}
|
||||
if conns.len() >= MAX_CONNECTIONS {
|
||||
// Dropping the stream closes it. Better than queueing: the
|
||||
// client side is whatever can dial loopback, so a queue is
|
||||
// just a slower way to run out of execs.
|
||||
log::warn!(
|
||||
"Auth bridge: refusing connection from {} to bridged port {} — \
|
||||
{} concurrent connections already open on it",
|
||||
peer,
|
||||
port,
|
||||
MAX_CONNECTIONS
|
||||
);
|
||||
continue;
|
||||
}
|
||||
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
||||
let _ = stream.set_nodelay(true);
|
||||
conns.spawn(tunnel_connection(
|
||||
@@ -170,9 +283,224 @@ async fn accept_optional(
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry one accepted host connection into the container over `socat`.
|
||||
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
|
||||
tunnel_connection_with_prelude(container_id, target, stream, port, Vec::new()).await
|
||||
/// Carry one accepted host connection into the container over `socat`, after
|
||||
/// deciding it is not a web page reaching into loopback.
|
||||
///
|
||||
/// Nothing is forwarded until that decision is made, so a refused request never
|
||||
/// reaches the container at all — not even a `docker exec`.
|
||||
async fn tunnel_connection(container_id: String, target: String, mut stream: TcpStream, port: u16) {
|
||||
let head = match read_leading_bytes(&mut stream).await {
|
||||
Ok(head) => head,
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Auth bridge: dropping connection to bridged port {} before forwarding: {}",
|
||||
port,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let LeadingBytes::HttpRequest { buffer, head_len } = &head {
|
||||
// Authorize against the head slice only. Parsing past the blank line is
|
||||
// how a request *body* gets read as headers — a cross-site `fetch` with
|
||||
// a `text/plain` body is not preflighted, so it can put any line it
|
||||
// likes in there.
|
||||
let head_text = String::from_utf8_lossy(&buffer[..*head_len]);
|
||||
if web_request_verdict(&head_text) == Verdict::RefuseCrossSite {
|
||||
log::warn!(
|
||||
"Auth bridge: refused a cross-site sub-resource request to bridged port {} — \
|
||||
a web page, not a login redirect",
|
||||
port
|
||||
);
|
||||
let _ = refuse(&mut stream).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// The bytes already off the socket go back on the wire first, byte-exact.
|
||||
tunnel_connection_with_prelude(container_id, target, stream, port, head.into_buffer()).await
|
||||
}
|
||||
|
||||
/// What the first bytes of an accepted connection turned out to be.
|
||||
enum LeadingBytes {
|
||||
/// An HTTP request whose head we have in full. `head_len` is one past the
|
||||
/// blank line; `buffer` may hold pipelined body bytes beyond it.
|
||||
HttpRequest { buffer: Vec<u8>, head_len: usize },
|
||||
/// Not HTTP, or HTTP we gave up on reading. Forwarded verbatim, ungated.
|
||||
Opaque(Vec<u8>),
|
||||
}
|
||||
|
||||
impl LeadingBytes {
|
||||
fn into_buffer(self) -> Vec<u8> {
|
||||
match self {
|
||||
LeadingBytes::HttpRequest { buffer, .. } => buffer,
|
||||
LeadingBytes::Opaque(buffer) => buffer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read just enough of the connection to classify it, without consuming
|
||||
/// anything the caller cannot replay.
|
||||
///
|
||||
/// Bails out to [`LeadingBytes::Opaque`] the moment the first line proves this
|
||||
/// is not HTTP, so a non-HTTP protocol pays one line of latency and no more.
|
||||
/// The only hard failure is silence: a client that sends nothing within
|
||||
/// [`FIRST_BYTE_TIMEOUT`] is dropped before an exec is spent on it.
|
||||
async fn read_leading_bytes(stream: &mut TcpStream) -> Result<LeadingBytes, String> {
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(1024);
|
||||
let mut chunk = [0u8; 1024];
|
||||
let mut deadline = tokio::time::Instant::now() + FIRST_BYTE_TIMEOUT;
|
||||
|
||||
loop {
|
||||
let n = match tokio::time::timeout_at(deadline, stream.read(&mut chunk)).await {
|
||||
Ok(Ok(0)) if buf.is_empty() => {
|
||||
return Err("closed before sending anything".to_string())
|
||||
}
|
||||
// A half-close after some bytes is legitimate; forward what we have.
|
||||
Ok(Ok(0)) => return Ok(LeadingBytes::Opaque(buf)),
|
||||
Ok(Ok(n)) => n,
|
||||
Ok(Err(e)) => return Err(format!("read failed: {}", e)),
|
||||
Err(_) if buf.is_empty() => {
|
||||
return Err(format!(
|
||||
"sent nothing within {}s",
|
||||
FIRST_BYTE_TIMEOUT.as_secs()
|
||||
))
|
||||
}
|
||||
// Bytes arrived but the head never finished. Fail open: this is a
|
||||
// gate on top of the bridge, not the bridge's reason to exist.
|
||||
Err(_) => return Ok(LeadingBytes::Opaque(buf)),
|
||||
};
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
|
||||
// Once the first line is complete we know whether to keep reading.
|
||||
if let Some(eol) = buf.iter().position(|b| *b == b'\n') {
|
||||
if !is_http_request_line(&buf[..eol]) {
|
||||
return Ok(LeadingBytes::Opaque(buf));
|
||||
}
|
||||
deadline = deadline.max(tokio::time::Instant::now() + HEAD_TIMEOUT);
|
||||
} else if buf.len() > MAX_HEAD {
|
||||
return Ok(LeadingBytes::Opaque(buf));
|
||||
}
|
||||
|
||||
if let Some(head_len) = find_head_end(&buf) {
|
||||
return Ok(LeadingBytes::HttpRequest {
|
||||
buffer: buf,
|
||||
head_len,
|
||||
});
|
||||
}
|
||||
if buf.len() > MAX_HEAD {
|
||||
return Ok(LeadingBytes::Opaque(buf));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a first line looks like `METHOD target HTTP/1.x`.
|
||||
fn is_http_request_line(line: &[u8]) -> bool {
|
||||
let line = String::from_utf8_lossy(line);
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
let mut parts = line.split(' ');
|
||||
let (Some(method), Some(target), Some(version), None) =
|
||||
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
!method.is_empty()
|
||||
&& method.chars().all(|c| c.is_ascii_uppercase())
|
||||
&& !target.is_empty()
|
||||
&& (version == "HTTP/1.1" || version == "HTTP/1.0")
|
||||
}
|
||||
|
||||
/// Index just past the blank line terminating an HTTP head, if it has arrived.
|
||||
/// Tolerates a bare-LF terminator, which some minimal clients still emit.
|
||||
fn find_head_end(buf: &[u8]) -> Option<usize> {
|
||||
buf.windows(4)
|
||||
.position(|w| w == b"\r\n\r\n")
|
||||
.map(|i| i + 4)
|
||||
.or_else(|| buf.windows(2).position(|w| w == b"\n\n").map(|i| i + 2))
|
||||
}
|
||||
|
||||
/// Tell a refused caller why, then close. Plain text and `Connection: close` —
|
||||
/// there is no session here to keep alive.
|
||||
async fn refuse(stream: &mut TcpStream) -> std::io::Result<()> {
|
||||
const BODY: &str = "This port is bridged from a container by Triple-C for a sign-in \
|
||||
callback. It is not an API for web pages to call.\n";
|
||||
let response = format!(
|
||||
"HTTP/1.1 403 Forbidden\r\n\
|
||||
Content-Type: text/plain; charset=utf-8\r\n\
|
||||
Content-Length: {}\r\n\
|
||||
Cache-Control: no-store\r\n\
|
||||
Connection: close\r\n\r\n{}",
|
||||
BODY.len(),
|
||||
BODY
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await?;
|
||||
stream.shutdown().await
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// The gate — pure, so it can be tested without sockets
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Verdict {
|
||||
/// Forward it. Either it is not a browser, or the browser says this is a
|
||||
/// navigation or a same-origin request.
|
||||
Allow,
|
||||
/// Fetch metadata says a document on another site pulled this in as a
|
||||
/// sub-resource. No login flow looks like that.
|
||||
RefuseCrossSite,
|
||||
}
|
||||
|
||||
/// Decide whether an HTTP request head arriving on a bridged port may be
|
||||
/// forwarded into the container.
|
||||
///
|
||||
/// Deliberately fail-open — see the module docs for exactly what that leaves
|
||||
/// uncovered. The only refusal is the case with no innocent reading:
|
||||
/// `Sec-Fetch-Site` says another site, and `Sec-Fetch-Mode` says this is not a
|
||||
/// navigation. `Sec-Fetch-*` are forbidden header names, so page script cannot
|
||||
/// set or clear them.
|
||||
pub(crate) fn web_request_verdict(head: &str) -> Verdict {
|
||||
let mut lines = head.split(['\r', '\n']).filter(|l| !l.is_empty());
|
||||
// Skip the request line.
|
||||
if lines.next().is_none() {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
|
||||
let mut site: Option<&str> = None;
|
||||
let mut mode: Option<&str> = None;
|
||||
for line in lines {
|
||||
let Some((name, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim();
|
||||
match name.trim().to_ascii_lowercase().as_str() {
|
||||
// A duplicate of either header is header smuggling, not a client.
|
||||
// Refuse rather than pick a winner: last-occurrence-wins is what
|
||||
// turns a smuggling primitive into a bypass.
|
||||
"sec-fetch-site" if site.is_some() => return Verdict::RefuseCrossSite,
|
||||
"sec-fetch-mode" if mode.is_some() => return Verdict::RefuseCrossSite,
|
||||
"sec-fetch-site" => site = Some(value),
|
||||
"sec-fetch-mode" => mode = Some(value),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(site) = site else {
|
||||
// No fetch metadata: a CLI, `curl`, or a browser old enough not to send
|
||||
// it. Not something this gate can judge.
|
||||
return Verdict::Allow;
|
||||
};
|
||||
if site.eq_ignore_ascii_case("same-origin") || site.eq_ignore_ascii_case("none") {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
// `navigate` is precisely the OAuth redirect: the provider sends the browser
|
||||
// to `http://localhost:<port>/callback`, cross-site, as a document load.
|
||||
// Refusing it would refuse the feature.
|
||||
if mode.is_none_or(|m| m.eq_ignore_ascii_case("navigate")) {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
Verdict::RefuseCrossSite
|
||||
}
|
||||
|
||||
/// As [`tunnel_connection`], but `prelude` is written into the container first,
|
||||
@@ -225,21 +553,36 @@ pub async fn tunnel_connection_with_prelude(
|
||||
}
|
||||
let mut buf = vec![0u8; PUMP_BUF];
|
||||
loop {
|
||||
match host_rx.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
// Idle-bounded. Without this a client that connects, sends a
|
||||
// request and then never speaks or closes holds the exec open for
|
||||
// as long as the container runs.
|
||||
match tokio::time::timeout(IDLE_TIMEOUT, host_rx.read(&mut buf)).await {
|
||||
Ok(Ok(0)) | Err(_) => break,
|
||||
Ok(Ok(n)) => {
|
||||
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
Ok(Err(_)) => break,
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Container → host. This direction is authoritative: when the exec's output
|
||||
// stream ends, socat has exited and the connection is over.
|
||||
while let Some(chunk) = output.next().await {
|
||||
// stream ends, socat has exited and the connection is over. It is also the
|
||||
// one that decides the connection is dead: nothing back from the container
|
||||
// for `IDLE_TIMEOUT` tears the whole thing down, exec included.
|
||||
while let Some(chunk) = match tokio::time::timeout(IDLE_TIMEOUT, output.next()).await {
|
||||
Ok(chunk) => chunk,
|
||||
Err(_) => {
|
||||
log::debug!(
|
||||
"Auth bridge: bridged port {} idle for {}s — closing the tunnel",
|
||||
port,
|
||||
IDLE_TIMEOUT.as_secs()
|
||||
);
|
||||
None
|
||||
}
|
||||
} {
|
||||
match chunk {
|
||||
// Only stdout is payload. The exec is created with tty = false
|
||||
// precisely so Docker demultiplexes these, keeping socat's stderr
|
||||
@@ -268,3 +611,218 @@ pub async fn tunnel_connection_with_prelude(
|
||||
// Explicit: stop reading from the host now that the container side is gone.
|
||||
drop(upstream);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn head(lines: &[&str]) -> String {
|
||||
format!("{}\r\n\r\n", lines.join("\r\n"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cli_callback_with_no_fetch_metadata_is_forwarded() {
|
||||
// The overwhelmingly common case, and the reason the gate fails open:
|
||||
// `curl`, a CLI's own probe, and anything not a browser send none of
|
||||
// these headers, and none of them can be judged from the wire.
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"GET /callback?code=abc HTTP/1.1",
|
||||
"Host: localhost:41733",
|
||||
"User-Agent: curl/8.5.0",
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_oauth_redirect_is_forwarded_even_though_it_is_cross_site() {
|
||||
// This is the feature. The provider bounces the browser to
|
||||
// `http://localhost:<port>/callback`, which is cross-site and a
|
||||
// navigation. Refusing it would refuse every login the bridge exists
|
||||
// for.
|
||||
for site in ["cross-site", "same-site"] {
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"GET /callback?code=abc&state=xyz HTTP/1.1",
|
||||
"Host: localhost:41733",
|
||||
&format!("Sec-Fetch-Site: {}", site),
|
||||
"Sec-Fetch-Mode: navigate",
|
||||
"Sec-Fetch-Dest: document",
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::Allow, "site={}", site);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_post_callback_is_forwarded() {
|
||||
// `response_mode=form_post` providers POST the callback as a
|
||||
// navigation. Still a navigation, still allowed.
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"POST /callback HTTP/1.1",
|
||||
"Host: localhost:41733",
|
||||
"Origin: https://login.microsoftonline.com",
|
||||
"Sec-Fetch-Site: cross-site",
|
||||
"Sec-Fetch-Mode: navigate",
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cross_site_subresource_from_a_web_page_is_refused() {
|
||||
// The case the gate exists for: a page the user happens to have open
|
||||
// scanning loopback and poking whatever answers.
|
||||
for mode in ["cors", "no-cors", "same-origin", "websocket"] {
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"GET /admin HTTP/1.1",
|
||||
"Host: 127.0.0.1:41733",
|
||||
"Origin: https://evil.example",
|
||||
"Sec-Fetch-Site: cross-site",
|
||||
&format!("Sec-Fetch-Mode: {}", mode),
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::RefuseCrossSite, "mode={}", mode);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_containers_own_same_origin_requests_are_forwarded() {
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"GET /style.css HTTP/1.1",
|
||||
"Host: localhost:41733",
|
||||
"Sec-Fetch-Site: same-origin",
|
||||
"Sec-Fetch-Mode: no-cors",
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::Allow);
|
||||
// `none` is a user-initiated load — typed URL, bookmark.
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"GET / HTTP/1.1",
|
||||
"Host: localhost:41733",
|
||||
"Sec-Fetch-Site: none",
|
||||
"Sec-Fetch-Mode: navigate",
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicated_fetch_metadata_is_refused_rather_than_resolved() {
|
||||
// Last-occurrence-wins is what turns any header-smuggling primitive
|
||||
// into a bypass, and no real client sends two.
|
||||
let verdict = web_request_verdict(&head(&[
|
||||
"GET /x HTTP/1.1",
|
||||
"Sec-Fetch-Site: cross-site",
|
||||
"Sec-Fetch-Mode: cors",
|
||||
"Sec-Fetch-Site: same-origin",
|
||||
]));
|
||||
assert_eq!(verdict, Verdict::RefuseCrossSite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_head_is_ever_judged() {
|
||||
// A cross-site `text/plain` POST is not preflighted, so its *body* is
|
||||
// fully attacker-chosen. `tunnel_connection` slices at the blank line
|
||||
// before calling in; this pins that the slice is what gets judged.
|
||||
let raw = "POST /x HTTP/1.1\r\n\
|
||||
Sec-Fetch-Site: cross-site\r\n\
|
||||
Sec-Fetch-Mode: cors\r\n\
|
||||
Content-Type: text/plain\r\n\r\n\
|
||||
Sec-Fetch-Site: same-origin\r\n";
|
||||
let head_len = find_head_end(raw.as_bytes()).expect("head terminator");
|
||||
let head = &raw[..head_len];
|
||||
assert!(!head.contains("same-origin"), "the forged line must be past the slice");
|
||||
assert_eq!(web_request_verdict(head), Verdict::RefuseCrossSite);
|
||||
|
||||
// And if the slice were ever got wrong, the duplicate rule is the
|
||||
// backstop: a forged `Sec-Fetch-*` line is by construction a second
|
||||
// copy of one the browser already sent, which is refused outright
|
||||
// rather than resolved in the forgery's favour.
|
||||
assert_eq!(web_request_verdict(raw), Verdict::RefuseCrossSite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_http_first_line_is_never_treated_as_a_request() {
|
||||
// Bridged ports are not all HTTP. Anything whose first line is not a
|
||||
// request line is forwarded verbatim rather than parsed.
|
||||
assert!(!is_http_request_line(b"\x16\x03\x01\x02\x00\x01"));
|
||||
assert!(!is_http_request_line(b"*1\r"));
|
||||
assert!(!is_http_request_line(b"SSH-2.0-OpenSSH_9.6"));
|
||||
assert!(!is_http_request_line(b"GET /x HTTP/2.0"));
|
||||
assert!(!is_http_request_line(b"get /x HTTP/1.1"));
|
||||
assert!(is_http_request_line(b"GET /x HTTP/1.1\r"));
|
||||
assert!(is_http_request_line(b"POST /callback?code=a%20b HTTP/1.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_end_is_found_for_both_terminators() {
|
||||
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\n\r\nBODY"), Some(18));
|
||||
assert_eq!(find_head_end(b"GET / HTTP/1.1\n\nBODY"), Some(16));
|
||||
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\nHost: x\r\n"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_client_that_says_nothing_never_costs_a_container_exec() {
|
||||
// Every accepted connection would otherwise spawn a `docker exec`
|
||||
// immediately, so silence was free for the caller and expensive here.
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let accept = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||
read_leading_bytes(&mut stream).await
|
||||
});
|
||||
|
||||
let _client = TcpStream::connect(addr).await.expect("connect");
|
||||
let started = tokio::time::Instant::now();
|
||||
let result = accept.await.expect("join");
|
||||
|
||||
assert!(result.is_err(), "silence should not be forwarded");
|
||||
assert!(
|
||||
started.elapsed() >= FIRST_BYTE_TIMEOUT,
|
||||
"should have waited out the first-byte grace period"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_http_client_is_classified_from_its_first_line_alone() {
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let accept = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||
read_leading_bytes(&mut stream).await
|
||||
});
|
||||
|
||||
let mut client = TcpStream::connect(addr).await.expect("connect");
|
||||
client.write_all(b"SSH-2.0-OpenSSH_9.6\r\n").await.expect("write");
|
||||
|
||||
let result = accept.await.expect("join").expect("classified");
|
||||
// Verbatim, and without waiting for a head terminator that will never
|
||||
// come — the whole buffer is replayed into the tunnel.
|
||||
assert!(matches!(result, LeadingBytes::Opaque(_)));
|
||||
assert_eq!(result.into_buffer(), b"SSH-2.0-OpenSSH_9.6\r\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_http_head_is_read_whole_and_replayed_whole() {
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
let accept = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||
read_leading_bytes(&mut stream).await
|
||||
});
|
||||
|
||||
let raw = b"POST /callback HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\n\r\ncode";
|
||||
let mut client = TcpStream::connect(addr).await.expect("connect");
|
||||
client.write_all(raw).await.expect("write");
|
||||
|
||||
let result = accept.await.expect("join").expect("classified");
|
||||
match &result {
|
||||
LeadingBytes::HttpRequest { buffer, head_len } => {
|
||||
assert_eq!(&buffer[*head_len..], b"code", "body must survive the peek");
|
||||
assert!(!buffer[..*head_len].ends_with(b"code"));
|
||||
}
|
||||
LeadingBytes::Opaque(_) => panic!("should have been recognised as HTTP"),
|
||||
}
|
||||
assert_eq!(result.into_buffer(), raw.to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,8 @@ pub async fn open_page_in_container_browser(
|
||||
url: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
show_window: bool,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<page::PageState, String> {
|
||||
let trimmed = url.trim();
|
||||
@@ -187,14 +189,59 @@ pub async fn open_page_in_container_browser(
|
||||
return Err("Only http:// and https:// URLs can be opened in the browser.".to_string());
|
||||
}
|
||||
let container_id = running_container(&state, &project_id, "opening a page").await?;
|
||||
crate::commands::project_commands::emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
"Checking the container for Playwright…",
|
||||
);
|
||||
let detection = crate::browser_view::detect::detect(&container_id).await?;
|
||||
page::open(
|
||||
let opened = page::open(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
&container_id,
|
||||
&detection,
|
||||
trimmed,
|
||||
page::Viewport::sane(width, height),
|
||||
)
|
||||
.await
|
||||
.await?;
|
||||
|
||||
// A page nobody can see is not an opened page. Opening one used to leave
|
||||
// the user to go and press Start in the Browser tab themselves — and from
|
||||
// the terminal's URL prompt, with no indication that was even needed.
|
||||
// Asking for a page *is* asking to watch it, so the viewer comes up too.
|
||||
let status = manager().status(&project_id).await;
|
||||
if status.state != BrowserViewState::Running {
|
||||
crate::commands::project_commands::emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
"Starting the viewer…",
|
||||
);
|
||||
manager()
|
||||
.start(
|
||||
project_id.clone(),
|
||||
container_id,
|
||||
app_handle.clone(),
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// From the terminal there is no pane on screen to fill, so the page needs a
|
||||
// window of its own or it lands somewhere the user isn't looking.
|
||||
if show_window {
|
||||
let status = manager().status(&project_id).await;
|
||||
if let Some(url) = status.url.as_deref() {
|
||||
let name = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_else(|| "Triple-C".to_string());
|
||||
popout::open(&app_handle, &project_id, &name, url, false)?;
|
||||
}
|
||||
}
|
||||
|
||||
crate::commands::project_commands::emit_progress(&app_handle, &project_id, "");
|
||||
Ok(opened)
|
||||
}
|
||||
|
||||
/// Resize the page this opened. The pop-out's "match window" mode calls this on
|
||||
|
||||
@@ -194,11 +194,24 @@ impl BrowserTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `channel` a launch check must pass. `None` means the bundled build.
|
||||
fn channel(self) -> Option<&'static str> {
|
||||
/// Every `channel` a launch check must pass, comma-separated, where
|
||||
/// `default` means "no channel — the bundled build".
|
||||
///
|
||||
/// Chromium is checked twice because the two consumers of this install do
|
||||
/// not launch the same binary. A script calling `chromium.launch()` with
|
||||
/// no channel gets `chromium-headless-shell`; the viewer reads
|
||||
/// `~/.playwright/cli.config.json`, which pins channel
|
||||
/// `chrome-for-testing`, and that resolves to the *full* `chromium-<rev>`
|
||||
/// build — a separate download under the same `install chromium`.
|
||||
///
|
||||
/// Checking only the first is how a container reaches "verified" and then
|
||||
/// fails in the pane with `Browser "chrome-for-testing" is not installed`.
|
||||
/// Observed on a real project, where a stale `chromium-1217` satisfied the
|
||||
/// headless-shell launch while the viewer wanted `chromium-1237`.
|
||||
fn channels(self) -> &'static str {
|
||||
match self {
|
||||
Self::Chromium => None,
|
||||
Self::Chrome => Some("chrome"),
|
||||
Self::Chromium => "default,chrome-for-testing",
|
||||
Self::Chrome => "chrome",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,7 +248,7 @@ pub async fn install_packages(
|
||||
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
|
||||
);
|
||||
|
||||
let mut step = npm_install(app, project_id, container_id, VIEWER_PACKAGE).await?;
|
||||
let mut step = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE]).await?;
|
||||
if step.exit_code != 0 {
|
||||
return Err(format!(
|
||||
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
|
||||
@@ -246,9 +259,15 @@ pub async fn install_packages(
|
||||
|
||||
// Second, `playwright` at the version the viewer package pins — see
|
||||
// `VIEWER_PACKAGE`. Installing it as `@latest` is what splits the tree.
|
||||
//
|
||||
// The viewer package is named *again* here. It is already installed, so
|
||||
// this adds no work, but omitting it is what made npm prune it back out —
|
||||
// see the note on `npm_install`. The pin can only be read after the first
|
||||
// install has written the manifest, which is why this stays two commands
|
||||
// rather than one.
|
||||
let spec = pinned_playwright_spec(container_id).await;
|
||||
emit_progress(app, project_id, &format!("Installing {}…", spec));
|
||||
let second = npm_install(app, project_id, container_id, &spec).await?;
|
||||
let second = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE, &spec]).await?;
|
||||
if second.exit_code != 0 {
|
||||
return Err(format!(
|
||||
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
|
||||
@@ -290,7 +309,7 @@ pub async fn install_packages(
|
||||
})
|
||||
}
|
||||
|
||||
/// One `npm install` of one spec, into [`INSTALL_DIR`], as `claude`.
|
||||
/// One `npm install` of one or more specs, into [`INSTALL_DIR`], as `claude`.
|
||||
///
|
||||
/// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in
|
||||
/// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
|
||||
@@ -298,13 +317,24 @@ pub async fn install_packages(
|
||||
/// has no postinstall (verified — `playwright@1.62.1` declares no `scripts` at
|
||||
/// all), but if a future release brings the browser download back, this step
|
||||
/// must stay small and the download must stay the step the user asked for.
|
||||
///
|
||||
/// **Every package that must survive has to appear in `specs`.** `--no-save`
|
||||
/// in a directory with no `package.json` — which [`INSTALL_DIR`] is — leaves
|
||||
/// npm with the command line as its only statement of what the tree should
|
||||
/// contain, and npm ≥7 reconciles the tree against that on every run by
|
||||
/// removing whatever it now considers extraneous. Installing `@playwright/cli`
|
||||
/// and then installing `playwright` in a second command therefore *deletes the
|
||||
/// first one*: verified in a container, `removed 3 packages`, leaving an empty
|
||||
/// `node_modules/@playwright/` behind `playwright` and `playwright-core`. That
|
||||
/// empty directory is why a fresh setup could report success and still leave
|
||||
/// the pane saying `@playwright/cli` was not installed.
|
||||
async fn npm_install(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
spec: &str,
|
||||
specs: &[&str],
|
||||
) -> Result<StepResult, String> {
|
||||
let cmd = vec![
|
||||
let mut cmd = vec![
|
||||
"env".to_string(),
|
||||
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
|
||||
"npm".to_string(),
|
||||
@@ -313,8 +343,8 @@ async fn npm_install(
|
||||
"--no-save".to_string(),
|
||||
"--no-fund".to_string(),
|
||||
"--no-audit".to_string(),
|
||||
spec.to_string(),
|
||||
];
|
||||
cmd.extend(specs.iter().map(|s| s.to_string()));
|
||||
run_step(
|
||||
app,
|
||||
project_id,
|
||||
@@ -704,7 +734,7 @@ async fn verify_launch(
|
||||
],
|
||||
vec![
|
||||
format!("TRIPLE_C_PW_DIR={}", dir),
|
||||
format!("TRIPLE_C_PW_CHANNEL={}", target.channel().unwrap_or("")),
|
||||
format!("TRIPLE_C_PW_CHANNELS={}", target.channels()),
|
||||
format!("TRIPLE_C_PW_URL={}", REACHABILITY_URL),
|
||||
],
|
||||
);
|
||||
@@ -795,27 +825,43 @@ fn parse_launch_output(output: &str) -> LaunchVerdict {
|
||||
/// The launch check. One `argv` element, no newlines, same contract as the
|
||||
/// detection probe.
|
||||
///
|
||||
/// Playwright leaves the Chromium sandbox disabled by default, which is what
|
||||
/// makes this work in a container at all. The timeout exists so a browser that
|
||||
/// hangs on a missing library still returns a verdict rather than sitting there
|
||||
/// until the exec is torn down. The navigation is best-effort and never decides
|
||||
/// `ok` — it exists to tell a TLS-intercepted network apart from a broken
|
||||
/// install.
|
||||
/// `chromiumSandbox` is set explicitly rather than left to Playwright's
|
||||
/// default, so this check states the same thing the seeded
|
||||
/// `cli.config.json` does instead of agreeing with it by coincidence. The
|
||||
/// containers forbid unprivileged user namespaces, so a sandboxed Chromium
|
||||
/// aborts on launch; nothing here should be able to drift back into testing a
|
||||
/// configuration the viewer will not use.
|
||||
///
|
||||
/// Each channel in `TRIPLE_C_PW_CHANNELS` is launched in turn — see
|
||||
/// [`BrowserTarget::channels`] for why Chromium needs two — and a failure
|
||||
/// names the channel that failed, because "is not installed" is meaningless
|
||||
/// without it. Only the last launch loads a page: the navigation is
|
||||
/// best-effort, never decides `ok`, and exists to tell a TLS-intercepted
|
||||
/// network apart from a broken install, so doing it once is enough.
|
||||
///
|
||||
/// The timeout exists so a browser that hangs on a missing library still
|
||||
/// returns a verdict rather than sitting there until the exec is torn down.
|
||||
const LAUNCH_PROBE: &str = concat!(
|
||||
r#"const d=process.env.TRIPLE_C_PW_DIR,ch=process.env.TRIPLE_C_PW_CHANNEL||undefined,u=process.env.TRIPLE_C_PW_URL;"#,
|
||||
r#"const d=process.env.TRIPLE_C_PW_DIR,chs=process.env.TRIPLE_C_PW_CHANNELS||"default",u=process.env.TRIPLE_C_PW_URL;"#,
|
||||
r#"let done=false;const say=(ok,detail,nav)=>{if(done)return;done=true;"#,
|
||||
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_LAUNCH__"+JSON.stringify({ok,detail,nav:nav||null})+"\n");};"#,
|
||||
r#"const one=(e)=>String((e&&e.message)||e).split("\n").slice(0,8).join(" | ");"#,
|
||||
r#"const t=setTimeout(()=>{say(false,"the browser did not finish starting within 90s");process.exit(0);},90000);"#,
|
||||
r#"(async()=>{let b=null;try{const {chromium}=require(d);b=await chromium.launch(ch?{channel:ch}:{});"#,
|
||||
r#"let v="";try{v=b.version();}catch(e){}"#,
|
||||
r#"let nav={ok:true,cert:false,detail:""};"#,
|
||||
r#"(async()=>{let b=null,cur="";try{const {chromium}=require(d);"#,
|
||||
r#"const list=chs.split(",").map(s=>s.trim()).filter(Boolean);"#,
|
||||
r#"let v="",nav={ok:true,cert:false,detail:""};"#,
|
||||
r#"for(let i=0;i<list.length;i++){cur=list[i];const c=cur==="default"?undefined:cur;"#,
|
||||
r#"b=await chromium.launch(Object.assign({chromiumSandbox:false},c?{channel:c}:{}));"#,
|
||||
r#"try{v=b.version();}catch(e){}"#,
|
||||
r#"if(i===list.length-1){"#,
|
||||
r#"try{const p=await b.newPage();await p.goto(u,{timeout:20000});}"#,
|
||||
// A certificate failure is classified here, next to the message, because
|
||||
// Chromium's wording is the only place the distinction exists.
|
||||
r#"catch(e){const m=one(e);nav={ok:false,cert:/ERR_CERT|CERT_AUTHORITY|ERR_SSL|SSL_ERROR|self.signed/i.test(m),detail:m};}"#,
|
||||
r#"await b.close();clearTimeout(t);say(true,v,nav);}"#,
|
||||
r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}say(false,one(e));}"#,
|
||||
r#"catch(e){const m=one(e);nav={ok:false,cert:/ERR_CERT|CERT_AUTHORITY|ERR_SSL|SSL_ERROR|self.signed/i.test(m),detail:m};}}"#,
|
||||
r#"await b.close();b=null;}"#,
|
||||
r#"clearTimeout(t);say(true,v,nav);}"#,
|
||||
r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}"#,
|
||||
r#"say(false,(cur&&cur!=="default"?"channel "+cur+": ":"")+one(e));}"#,
|
||||
r#"process.exit(0);})();"#,
|
||||
);
|
||||
|
||||
@@ -984,8 +1030,10 @@ mod tests {
|
||||
// `@playwright/mcp` asks for the chrome channel specifically, so the UI
|
||||
// must be able to say so.
|
||||
assert!(BrowserTarget::Chrome.needed_for().contains("@playwright/mcp"));
|
||||
assert_eq!(BrowserTarget::Chrome.channel(), Some("chrome"));
|
||||
assert_eq!(BrowserTarget::Chromium.channel(), None);
|
||||
assert_eq!(BrowserTarget::Chrome.channels(), "chrome");
|
||||
// Both of Chromium's consumers, or the check passes for a browser the
|
||||
// viewer cannot open — see `channels`.
|
||||
assert_eq!(BrowserTarget::Chromium.channels(), "default,chrome-for-testing");
|
||||
// And a size, before the click, for both.
|
||||
for t in [BrowserTarget::Chromium, BrowserTarget::Chrome] {
|
||||
assert!(t.download_note().to_lowercase().contains("mb"), "{:?}", t);
|
||||
|
||||
@@ -304,21 +304,7 @@ impl BrowserViewManager {
|
||||
// a container with no dashboard makes this a no-op.
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
|
||||
let container_port = pick_viewer_port(&container_id).await?;
|
||||
launch_viewer(&container_id, &cli_entry, container_port).await?;
|
||||
|
||||
// Wait for it to actually answer, and learn the entry URL while we're
|
||||
// there — see `probe_entry_path` for why that matters. This, not the
|
||||
// launcher's stdout, is the readiness signal: verified that the
|
||||
// "Listening on …" line is printed only on the very first start.
|
||||
let entry_path = match wait_until_ready(&container_id, container_port).await {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
let log = read_viewer_log(&container_id).await;
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
return Err(explain_start_failure(&e, &log));
|
||||
}
|
||||
};
|
||||
let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).await?;
|
||||
|
||||
let token = generate_token();
|
||||
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
||||
@@ -601,12 +587,91 @@ async fn read_viewer_log(container_id: &str) -> String {
|
||||
// Readiness, ports, URLs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on.
|
||||
async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
||||
/// How many free ports a start will try before giving up.
|
||||
///
|
||||
/// More than one because port choice is a check-then-bind: the free list comes
|
||||
/// from a snapshot of the container's `/proc/net/tcp`, and anything in the
|
||||
/// container may bind the port we picked before the dashboard gets to it. One
|
||||
/// retry per lost race is the recovery; the cap is what stops a container that
|
||||
/// binds every candidate from holding a start open for
|
||||
/// `MAX_PORT_ATTEMPTS × READY_TIMEOUT`.
|
||||
const MAX_PORT_ATTEMPTS: usize = 3;
|
||||
|
||||
/// Get a viewer listening inside the container and return the port it is on
|
||||
/// plus the path the pane should load.
|
||||
///
|
||||
/// ## The check/bind race
|
||||
///
|
||||
/// [`pick_viewer_port`] reads a *snapshot* of container listeners; the dashboard
|
||||
/// binds some milliseconds later. Nothing here can make that atomic — the bind
|
||||
/// happens in another process, in another namespace, and `playwright-cli show`
|
||||
/// reports the port it actually took only on a first-ever start (see
|
||||
/// [`wait_until_ready`]). What is possible is to stop treating the first
|
||||
/// candidate as the only one: if the port we picked does not come up, walk to
|
||||
/// the next free candidate rather than failing the whole start.
|
||||
///
|
||||
/// Residual, stated rather than glossed: a container-side process that binds the
|
||||
/// candidate port *and answers HTTP* is indistinguishable from the dashboard at
|
||||
/// this layer, and the pane would then front it. What contains that is
|
||||
/// downstream — the host proxy is loopback-only and token-gated, and the pane's
|
||||
/// iframe is sandboxed — not this function.
|
||||
async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, String), String> {
|
||||
let mut tried: Vec<u16> = Vec::new();
|
||||
let mut last: Option<String> = None;
|
||||
|
||||
for _ in 0..MAX_PORT_ATTEMPTS {
|
||||
// Re-read the listener snapshot each attempt: the port that was free a
|
||||
// moment ago is exactly the one we may have just lost.
|
||||
let port = match pick_viewer_port(container_id, &tried).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// Report why the *attempts* failed, not just "nothing free":
|
||||
// the exhausted range is the symptom, the last start failure is
|
||||
// the thing the user can act on.
|
||||
return Err(match last {
|
||||
Some(prev) => format!("{} ({})", e, prev),
|
||||
None => e,
|
||||
});
|
||||
}
|
||||
};
|
||||
tried.push(port);
|
||||
|
||||
launch_viewer(container_id, cli_entry, port).await?;
|
||||
|
||||
// Wait for it to actually answer, and learn the entry URL while we're
|
||||
// there — see `probe_entry_path` for why that matters. This, not the
|
||||
// launcher's stdout, is the readiness signal: verified that the
|
||||
// "Listening on …" line is printed only on the very first start.
|
||||
match wait_until_ready(container_id, port).await {
|
||||
Ok(path) => return Ok((port, path)),
|
||||
Err(e) => {
|
||||
let log = read_viewer_log(container_id).await;
|
||||
// Always kill before retrying: the dashboard is a singleton, so
|
||||
// a launcher that came up on some *other* port would otherwise
|
||||
// make every further attempt a no-op that silently ignores the
|
||||
// port we asked for.
|
||||
let _ = kill_dashboard(container_id, cli_entry).await;
|
||||
last = Some(explain_start_failure(&e, &log));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last.unwrap_or_else(|| "The Playwright viewer did not start.".to_string()))
|
||||
}
|
||||
|
||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on
|
||||
/// and that this start has not already tried.
|
||||
async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, String> {
|
||||
let text = exec_oneshot(
|
||||
container_id,
|
||||
vec![
|
||||
"cat".to_string(),
|
||||
// Absolute path, deliberately, for the same reason the auth bridge
|
||||
// uses one: `container/Dockerfile` puts a container-writable
|
||||
// directory first on `PATH`, so a bare `cat` is a name the container
|
||||
// can rebind to a shim. A shimmed listener list is a shimmed answer
|
||||
// to "which port is free" — i.e. the container choosing which port
|
||||
// the viewer, and therefore the host-side proxy, ends up on.
|
||||
"/usr/bin/cat".to_string(),
|
||||
"/proc/net/tcp".to_string(),
|
||||
"/proc/net/tcp6".to_string(),
|
||||
],
|
||||
@@ -616,7 +681,7 @@ async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
||||
let taken = proc_net::parse_loopback_listeners(&text);
|
||||
VIEWER_PORTS
|
||||
.clone()
|
||||
.find(|p| !taken.contains_key(p))
|
||||
.find(|p| !taken.contains_key(p) && !tried.contains(p))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"No free port in {}–{} inside the container for the Playwright viewer.",
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
//! larger. This is what makes the pop-out usable as a responsive-design ruler.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::commands::project_commands::emit_progress;
|
||||
use crate::docker::exec::exec_oneshot_as;
|
||||
|
||||
use super::detect::PlaywrightDetection;
|
||||
@@ -87,6 +89,8 @@ pub struct PageState {
|
||||
/// Replaces any page this opened before: one helper per container, because the
|
||||
/// pane shows one browser and a second would just compete for the pane.
|
||||
pub async fn open(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
detection: &PlaywrightDetection,
|
||||
url: &str,
|
||||
@@ -112,6 +116,7 @@ pub async fn open(
|
||||
// browser's cookies and storage — which for the auth case means signing in
|
||||
// again to reach the second page, having just signed in on the first.
|
||||
if state(container_id).await.ready {
|
||||
emit_progress(app, project_id, "Navigating the container's browser…");
|
||||
set_viewport(container_id, viewport).await?;
|
||||
navigate(container_id, url).await?;
|
||||
if let Some(state) = wait_for_url(container_id, url).await {
|
||||
@@ -121,6 +126,10 @@ pub async fn open(
|
||||
}
|
||||
|
||||
close(container_id).await;
|
||||
// Cold start: a browser launch plus a page load, which is the several
|
||||
// seconds the user would otherwise spend wondering whether the click
|
||||
// registered.
|
||||
emit_progress(app, project_id, "Launching a browser in the container…");
|
||||
|
||||
let config = serde_json::json!({
|
||||
"core": core_dir,
|
||||
@@ -150,6 +159,7 @@ pub async fn open(
|
||||
.await
|
||||
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
|
||||
|
||||
emit_progress(app, project_id, "Waiting for the page to load…");
|
||||
wait_until_ready(container_id).await
|
||||
}
|
||||
|
||||
|
||||
@@ -1188,16 +1188,53 @@ pub async fn has_claude_token() -> Result<bool, String> {
|
||||
Ok(secure::has_claude_oauth_token())
|
||||
}
|
||||
|
||||
/// What [`clear_claude_token`] managed to reach. The keychain entry is always
|
||||
/// gone by the time this is returned — the rest is about copies of the token
|
||||
/// that live outside it.
|
||||
#[derive(Debug, Default, serde::Serialize)]
|
||||
/// The tail of every refusal [`crate::project_lock::try_acquire`] produces.
|
||||
///
|
||||
/// [`crate::docker::container::scrub_secrets_from_snapshots`] folds two very
|
||||
/// different things into one `failed` list: an image that genuinely could not
|
||||
/// be rewritten, and one that was never *attempted* because another operation
|
||||
/// held the project. Only the second is retryable, and only the second should
|
||||
/// be described to the user as "come back in a minute" rather than "reset this
|
||||
/// project". Splitting them needs a discriminator, and the refusal string is
|
||||
/// the only one that crosses the module boundary — `try_acquire` returns
|
||||
/// `Result<ProjectGuard, String>`, and `container.rs` pushes that `String`
|
||||
/// through unchanged.
|
||||
///
|
||||
/// Matching on prose is normally a mistake, so this is pinned by
|
||||
/// [`tests::a_real_lock_refusal_is_recognised_as_retryable`], which builds a
|
||||
/// refusal by actually taking a guard rather than by copying the wording. If
|
||||
/// `project_lock` ever rephrases, that test fails instead of this silently
|
||||
/// misclassifying a credential that was left in place.
|
||||
const PROJECT_BUSY_MARKER: &str = "Wait for it to finish before ";
|
||||
|
||||
/// Whether a scrub failure means "somebody else has this project right now",
|
||||
/// which is transient, rather than "this image cannot be rewritten", which is
|
||||
/// not. Nothing bollard returns contains [`PROJECT_BUSY_MARKER`].
|
||||
fn is_project_busy_refusal(reason: &str) -> bool {
|
||||
reason.contains(PROJECT_BUSY_MARKER)
|
||||
}
|
||||
|
||||
/// What a cleanup managed to reach. Every field is about copies of the token
|
||||
/// that live *outside* the keychain — snapshot images — so the same shape
|
||||
/// serves [`clear_claude_token`], where the keychain entry is already gone by
|
||||
/// the time this is returned, and [`sweep_claude_token_snapshots`], where the
|
||||
/// keychain was never touched.
|
||||
///
|
||||
/// Three lists rather than one, because "we rewrote it", "we could not rewrite
|
||||
/// it" and "we did not try" are three different things to tell somebody who
|
||||
/// just revoked a credential, and only the last one is fixed by waiting.
|
||||
#[derive(Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct ClearTokenOutcome {
|
||||
/// Snapshot images that were holding the token and have been rewritten.
|
||||
pub snapshots_scrubbed: Vec<String>,
|
||||
/// Images still holding it, with the reason each could not be rewritten.
|
||||
/// Non-empty means the revocation is **incomplete** and the UI must say so.
|
||||
pub snapshots_failed: Vec<String>,
|
||||
/// Images still holding it that were **not attempted**, because another
|
||||
/// operation held the project (a start, a compaction, a migration). Also an
|
||||
/// incomplete revocation — but a retryable one, and the UI must not offer
|
||||
/// "Reset the project" as the remedy for it.
|
||||
pub snapshots_skipped: Vec<String>,
|
||||
/// Rewritten, but the pre-rewrite image object could not be deleted because
|
||||
/// a container is still running off it. Worth mentioning, not worth
|
||||
/// alarming about — see `SnapshotScrubReport::superseded_retained`.
|
||||
@@ -1207,7 +1244,130 @@ pub struct ClearTokenOutcome {
|
||||
pub docker_unavailable: Option<String>,
|
||||
}
|
||||
|
||||
/// Forget the shared Claude token.
|
||||
impl ClearTokenOutcome {
|
||||
/// Whether a copy of the credential is known — or suspected — to still be
|
||||
/// reachable, so the caller should offer to run the sweep again.
|
||||
///
|
||||
/// `snapshots_superseded` is deliberately not counted: that image is
|
||||
/// untagged, nothing new is built from it, and it goes away on the next
|
||||
/// restart. Re-running would report it forever and train the user to
|
||||
/// ignore the warning.
|
||||
pub fn needs_another_pass(&self) -> bool {
|
||||
!self.snapshots_failed.is_empty()
|
||||
|| !self.snapshots_skipped.is_empty()
|
||||
|| self.docker_unavailable.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a scrub report into the IPC shape, splitting the busy projects out of
|
||||
/// the failures. Separate from the command so it can be tested without Docker.
|
||||
fn summarise_scrub(report: crate::docker::container::SnapshotScrubReport) -> ClearTokenOutcome {
|
||||
let mut outcome = ClearTokenOutcome {
|
||||
snapshots_scrubbed: report.scrubbed,
|
||||
snapshots_superseded: report.superseded_retained,
|
||||
docker_unavailable: report.unavailable,
|
||||
..Default::default()
|
||||
};
|
||||
for (image, reason) in report.failed {
|
||||
let line = format!("{}: {}", image, reason);
|
||||
if is_project_busy_refusal(&reason) {
|
||||
outcome.snapshots_skipped.push(line);
|
||||
} else {
|
||||
outcome.snapshots_failed.push(line);
|
||||
}
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Which halves of a cleanup to run.
|
||||
///
|
||||
/// The distinction has to exist **on the wire**, not in a toast string. The UI
|
||||
/// offers a "Retry snapshot cleanup" button after an incomplete revocation, and
|
||||
/// while [`clear_claude_token`] was the only command behind it that button was
|
||||
/// a *second revoke* wearing a retry's label: it deleted the keychain entry
|
||||
/// unconditionally, with no confirmation, in a panel that survived the user
|
||||
/// re-authenticating from the button directly above it. Pressing it then threw
|
||||
/// away the token they had just acquired and said only that some images had
|
||||
/// been checked.
|
||||
///
|
||||
/// [`Cleanup::ImagesOnly`] is the honest primitive the retry actually wanted:
|
||||
/// the images are the durable record of what is left to do, so re-deriving the
|
||||
/// work from Docker needs no keychain entry and must not consume one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Cleanup {
|
||||
/// Delete the keychain entry, then rewrite the images. What "Revoke" does,
|
||||
/// behind its confirmation modal.
|
||||
KeychainThenImages,
|
||||
/// Rewrite the images and leave the keychain entirely alone. What "Retry
|
||||
/// snapshot cleanup" and "Check snapshot images" do.
|
||||
ImagesOnly,
|
||||
}
|
||||
|
||||
/// The body of both cleanup commands, with its two halves injected so the
|
||||
/// *order* — and the fact that [`Cleanup::ImagesOnly`] never reaches the
|
||||
/// keychain at all — can be tested without a keychain or a Docker daemon.
|
||||
async fn run_cleanup<K, S, F>(
|
||||
what: Cleanup,
|
||||
delete_keychain: K,
|
||||
sweep: S,
|
||||
) -> Result<ClearTokenOutcome, String>
|
||||
where
|
||||
K: FnOnce() -> Result<(), String>,
|
||||
S: FnOnce() -> F,
|
||||
F: std::future::Future<Output = crate::docker::container::SnapshotScrubReport>,
|
||||
{
|
||||
let revoking = what == Cleanup::KeychainThenImages;
|
||||
|
||||
if revoking {
|
||||
// First, and before anything slow — see "Why the keychain goes first".
|
||||
// Nothing has been touched if this fails, so the error is the whole
|
||||
// answer: the caller still has its Revoke button, and the standalone
|
||||
// sweep is available for the images regardless.
|
||||
if let Err(e) = delete_keychain() {
|
||||
log::error!(
|
||||
"Could not delete the shared Claude token from the keychain; no snapshot image \
|
||||
was touched: {}",
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
log::info!("Cleared the shared Claude authentication token from the keychain");
|
||||
}
|
||||
|
||||
let report = sweep().await;
|
||||
let swept_clean = !report.left_something_behind();
|
||||
let outcome = summarise_scrub(report);
|
||||
|
||||
let lead = if revoking {
|
||||
"Revoked the shared Claude token but"
|
||||
} else {
|
||||
"Swept the snapshot images but"
|
||||
};
|
||||
for image in &outcome.snapshots_failed {
|
||||
log::warn!("{} could not clear it from {}", lead, image);
|
||||
}
|
||||
for image in &outcome.snapshots_skipped {
|
||||
log::warn!(
|
||||
"{} left it in {} — the project was busy; running the snapshot sweep again will retry",
|
||||
lead,
|
||||
image
|
||||
);
|
||||
}
|
||||
if let Some(ref reason) = outcome.docker_unavailable {
|
||||
log::warn!("{} checked no snapshot image at all: {}", lead, reason);
|
||||
}
|
||||
if swept_clean && !outcome.needs_another_pass() {
|
||||
log::info!(
|
||||
"No snapshot image is still holding the shared Claude token ({} rewritten)",
|
||||
outcome.snapshots_scrubbed.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Forget the shared Claude token, and remove the copies of it that outlive the
|
||||
/// keychain entry.
|
||||
///
|
||||
/// Deleting the keychain entry is the easy half. The token also exists in two
|
||||
/// other places, and a "Revoke" button that leaves either of them behind is
|
||||
@@ -1223,34 +1383,81 @@ pub struct ClearTokenOutcome {
|
||||
/// as long as the image exists. New commits no longer bake it in (see
|
||||
/// [`crate::docker::container::commit_container_snapshot`]), but images
|
||||
/// committed by earlier builds have to be rewritten, which is what
|
||||
/// [`scrub_secrets_from_snapshots`] does here.
|
||||
/// [`crate::docker::container::scrub_secrets_from_snapshots`] does here.
|
||||
///
|
||||
/// The keychain deletion is never rolled back if the scrub fails; a partially
|
||||
/// completed revocation is still better than none, and the outcome is reported
|
||||
/// so the UI can be explicit about what is left.
|
||||
/// ## Why the keychain goes first
|
||||
///
|
||||
/// The sweep is not quick. It lists every `triple-c-snapshot-*` image and then
|
||||
/// inspects, creates, commits and removes *per image*, over bollard's Docker
|
||||
/// socket with its 120-second-per-request default. Deferring the keychain
|
||||
/// delete behind all of that leaves the credential live for the whole window
|
||||
/// while the UI says "Revoking…", and two separate things go wrong in it:
|
||||
///
|
||||
/// * A quit, a crash or a kill mid-sweep and the entry was never deleted at
|
||||
/// all. The token the user believes they revoked is still in the keychain,
|
||||
/// still ~1-year valid, and still injected into every container start.
|
||||
/// * [`has_claude_token`] stays true throughout, and
|
||||
/// [`crate::docker::container::create_container`] reads the keychain at
|
||||
/// container-**create** time rather than at app start. The per-project
|
||||
/// [`crate::project_lock::ProjectOp::SecretScrub`] guard is released as soon
|
||||
/// as that one project's image has been rewritten — so a project scrubbed
|
||||
/// early in the sweep can be started again later in the *same* sweep and be
|
||||
/// handed a fresh copy of the credential in its env. The images end up clean
|
||||
/// and the running fleet does not.
|
||||
///
|
||||
/// An earlier version ran the sweep first, on the argument that a crash
|
||||
/// mid-sweep would otherwise leave the token in an image with the keychain
|
||||
/// entry — and therefore the Revoke button — already gone. That argument was
|
||||
/// about *recoverability*, and [`sweep_claude_token_snapshots`] answers it
|
||||
/// directly: the images are the durable record, so the retry needs no keychain
|
||||
/// entry to exist and no persisted to-do list. The comment that ordering
|
||||
/// carried ("no window in which a scrubbed image is re-poisoned") was true of
|
||||
/// images and silent about containers, which is where the leak was, and silent
|
||||
/// about the minutes the token stayed live.
|
||||
///
|
||||
/// The keychain deletion is never rolled back if the scrub then fails; a
|
||||
/// partially completed revocation is still better than none, and the outcome is
|
||||
/// reported so the UI can be explicit about what is left.
|
||||
#[tauri::command]
|
||||
pub async fn clear_claude_token() -> Result<ClearTokenOutcome, String> {
|
||||
secure::delete_claude_oauth_token()?;
|
||||
log::info!("Cleared the shared Claude authentication token");
|
||||
run_cleanup(
|
||||
Cleanup::KeychainThenImages,
|
||||
secure::delete_claude_oauth_token,
|
||||
crate::docker::container::scrub_secrets_from_snapshots,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
let report = crate::docker::container::scrub_secrets_from_snapshots().await;
|
||||
if report.left_something_behind() {
|
||||
log::warn!(
|
||||
"Revoked the shared Claude token but {} snapshot image(s) may still contain it",
|
||||
report.failed.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(ClearTokenOutcome {
|
||||
snapshots_scrubbed: report.scrubbed,
|
||||
snapshots_failed: report
|
||||
.failed
|
||||
.into_iter()
|
||||
.map(|(image, reason)| format!("{}: {}", image, reason))
|
||||
.collect(),
|
||||
snapshots_superseded: report.superseded_retained,
|
||||
docker_unavailable: report.unavailable,
|
||||
})
|
||||
/// Rewrite every snapshot image that still carries a credential, **without
|
||||
/// touching the keychain**.
|
||||
///
|
||||
/// This is the retry, and it is its own command because the retry is its own
|
||||
/// act. `docker commit` copied the token into each project's snapshot image;
|
||||
/// rewriting those images is a cleanup that has nothing to do with whether a
|
||||
/// token is stored today, and folding it into [`clear_claude_token`] made every
|
||||
/// press of "Retry snapshot cleanup" an unconfirmed credential deletion.
|
||||
///
|
||||
/// Safe to call at any time and in any state:
|
||||
///
|
||||
/// * with a token stored — a snapshot committed by an older build carries the
|
||||
/// *current* token, and clearing it out of the image does not stop the
|
||||
/// keychain entry being injected on the next container start;
|
||||
/// * with nothing stored — images committed by earlier builds still carry
|
||||
/// whatever token was live when they were committed, which is exactly the
|
||||
/// case the old sweep-first ordering could strand;
|
||||
/// * repeatedly — the work is re-derived from Docker each time, so an image
|
||||
/// whose project was busy on the last pass is simply picked up on this one.
|
||||
#[tauri::command]
|
||||
pub async fn sweep_claude_token_snapshots() -> Result<ClearTokenOutcome, String> {
|
||||
run_cleanup(
|
||||
Cleanup::ImagesOnly,
|
||||
// Never called; the `ImagesOnly` branch is the entire point of this
|
||||
// command, and a change that made it reachable must fail loudly rather
|
||||
// than delete a credential quietly.
|
||||
|| -> Result<(), String> { unreachable!("an images-only sweep must never touch the keychain") },
|
||||
crate::docker::container::scrub_secrets_from_snapshots,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1761,5 +1968,265 @@ mod tests {
|
||||
seen.push_str(&s.push(format!("\nYour token: {}\n", tok).as_bytes()));
|
||||
assert_eq!(parse_setup_token(&seen), Some(tok));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Revocation: what the sweep leaves behind, and how it is described ──
|
||||
|
||||
use crate::docker::container::SnapshotScrubReport;
|
||||
use crate::project_lock::{try_acquire, ProjectOp};
|
||||
|
||||
/// The classifier is a substring match on a message another module owns,
|
||||
/// which is only safe if something notices when that module rephrases. So
|
||||
/// build the refusal the way production does — by actually losing the
|
||||
/// race — rather than by pasting the wording in here.
|
||||
#[test]
|
||||
fn a_real_lock_refusal_is_recognised_as_retryable() {
|
||||
let project = "auth-token-test-busy-project";
|
||||
let _held = try_acquire(project, ProjectOp::Compaction).expect("first claim");
|
||||
let refusal = try_acquire(project, ProjectOp::SecretScrub)
|
||||
.expect_err("a second claim on the same project must be refused");
|
||||
|
||||
assert!(
|
||||
is_project_busy_refusal(&refusal),
|
||||
"project_lock's refusal is no longer recognised as retryable: {:?}",
|
||||
refusal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_docker_failure_is_not_mistaken_for_a_busy_project() {
|
||||
for reason in [
|
||||
"could not inspect: error trying to connect: No such file or directory",
|
||||
"could not create a scratch container: conflict: name already in use",
|
||||
"an untagged snapshot image holds a credential and cannot be rewritten",
|
||||
] {
|
||||
assert!(
|
||||
!is_project_busy_refusal(reason),
|
||||
"{:?} was misclassified as a transient lock refusal",
|
||||
reason
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarise_scrub_separates_a_busy_project_from_a_broken_image() {
|
||||
let project = "auth-token-test-summarise-busy";
|
||||
let _held = try_acquire(project, ProjectOp::Recreate).expect("first claim");
|
||||
let refusal = try_acquire(project, ProjectOp::SecretScrub).expect_err("refused");
|
||||
|
||||
let outcome = summarise_scrub(SnapshotScrubReport {
|
||||
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||
failed: vec![
|
||||
("triple-c-snapshot-b:latest".into(), refusal),
|
||||
(
|
||||
"triple-c-snapshot-c:latest".into(),
|
||||
"could not create a scratch container: no such image".into(),
|
||||
),
|
||||
],
|
||||
superseded_retained: vec!["triple-c-snapshot-a:latest".into()],
|
||||
unavailable: None,
|
||||
});
|
||||
|
||||
assert_eq!(outcome.snapshots_scrubbed, vec!["triple-c-snapshot-a:latest"]);
|
||||
assert_eq!(outcome.snapshots_skipped.len(), 1, "{:?}", outcome);
|
||||
assert!(outcome.snapshots_skipped[0].starts_with("triple-c-snapshot-b:latest: "));
|
||||
assert_eq!(outcome.snapshots_failed.len(), 1, "{:?}", outcome);
|
||||
assert!(outcome.snapshots_failed[0].starts_with("triple-c-snapshot-c:latest: "));
|
||||
// The whole point: a skipped image is never folded into the scrubbed
|
||||
// list, which is what "success" is rendered from.
|
||||
assert!(!outcome.snapshots_scrubbed.iter().any(|s| s.contains("snapshot-b")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_sweep_needs_no_second_pass() {
|
||||
let outcome = summarise_scrub(SnapshotScrubReport {
|
||||
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!outcome.needs_another_pass());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retained_superseded_image_alone_does_not_ask_for_a_second_pass() {
|
||||
// The tag is clean; what is left is untagged and dies with the running
|
||||
// container. Asking the user to sweep again would never stop.
|
||||
let outcome = summarise_scrub(SnapshotScrubReport {
|
||||
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||
superseded_retained: vec!["triple-c-snapshot-a:latest".into()],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!outcome.needs_another_pass());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_still_holding_the_credential_asks_for_a_second_pass() {
|
||||
let skipped = summarise_scrub(SnapshotScrubReport {
|
||||
failed: vec![(
|
||||
"triple-c-snapshot-b:latest".into(),
|
||||
format!("This project is being reset. {}resetting it.", PROJECT_BUSY_MARKER),
|
||||
)],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(skipped.needs_another_pass());
|
||||
assert_eq!(skipped.snapshots_skipped.len(), 1);
|
||||
|
||||
let failed = summarise_scrub(SnapshotScrubReport {
|
||||
failed: vec![("triple-c-snapshot-c:latest".into(), "could not inspect: boom".into())],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(failed.needs_another_pass());
|
||||
|
||||
let blind = summarise_scrub(SnapshotScrubReport {
|
||||
unavailable: Some("Docker is not running".into()),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(blind.needs_another_pass());
|
||||
assert!(blind.snapshots_scrubbed.is_empty());
|
||||
}
|
||||
|
||||
/// The IPC contract the frontend reads. A field renamed on this side and
|
||||
/// not on that one is a silent "nothing was skipped".
|
||||
#[test]
|
||||
fn the_outcome_serialises_under_the_names_the_frontend_reads() {
|
||||
let json = serde_json::to_value(ClearTokenOutcome::default()).expect("serialise");
|
||||
let object = json.as_object().expect("an object");
|
||||
for key in [
|
||||
"snapshots_scrubbed",
|
||||
"snapshots_failed",
|
||||
"snapshots_skipped",
|
||||
"snapshots_superseded",
|
||||
"docker_unavailable",
|
||||
] {
|
||||
assert!(object.contains_key(key), "missing {} in {:?}", key, object);
|
||||
}
|
||||
}
|
||||
|
||||
// ── The order of a revocation, and what a retry may touch ─────────────
|
||||
//
|
||||
// `run_cleanup` takes both halves as arguments precisely so this can be
|
||||
// asserted with no keychain and no Docker daemon: the recorded order *is*
|
||||
// the subject. Sweep-first put a live ~1-year credential behind a
|
||||
// per-image inspect/create/commit/rmi loop — minutes, at bollard's
|
||||
// 120s-per-request default — during which `has_claude_token` stayed true
|
||||
// and `create_container` kept handing the token to anything started.
|
||||
|
||||
/// Records which half ran, in order.
|
||||
type Trace = std::sync::Arc<std::sync::Mutex<Vec<&'static str>>>;
|
||||
|
||||
fn trace() -> Trace {
|
||||
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn scrubbed_one() -> SnapshotScrubReport {
|
||||
SnapshotScrubReport {
|
||||
scrubbed: vec!["triple-c-snapshot-a:latest".into()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_keychain_entry_is_gone_before_the_first_image_is_touched() {
|
||||
let t = trace();
|
||||
let (tk, ts) = (t.clone(), t.clone());
|
||||
|
||||
let outcome = run_cleanup(
|
||||
Cleanup::KeychainThenImages,
|
||||
move || {
|
||||
tk.lock().unwrap().push("keychain");
|
||||
Ok(())
|
||||
},
|
||||
move || async move {
|
||||
ts.lock().unwrap().push("sweep");
|
||||
scrubbed_one()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a cleanup whose halves both succeed is not an error");
|
||||
|
||||
assert_eq!(
|
||||
*t.lock().unwrap(),
|
||||
["keychain", "sweep"],
|
||||
"the token stayed in the keychain — and therefore in every container created — \
|
||||
for the whole length of the image sweep"
|
||||
);
|
||||
assert_eq!(outcome.snapshots_scrubbed, vec!["triple-c-snapshot-a:latest"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_keychain_failure_leaves_the_images_untouched_and_is_reported() {
|
||||
let t = trace();
|
||||
let ts = t.clone();
|
||||
|
||||
let err = run_cleanup(
|
||||
Cleanup::KeychainThenImages,
|
||||
|| Err("the keychain is locked".to_string()),
|
||||
move || async move {
|
||||
ts.lock().unwrap().push("sweep");
|
||||
SnapshotScrubReport::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("a keychain that refused the delete must be reported, not swallowed");
|
||||
|
||||
assert_eq!(err, "the keychain is locked");
|
||||
assert!(
|
||||
t.lock().unwrap().is_empty(),
|
||||
"images were rewritten for a revocation that never happened; the report is then \
|
||||
discarded with the error and the user is told nothing they can act on"
|
||||
);
|
||||
}
|
||||
|
||||
/// The bug the images-only primitive exists to close: the "Retry snapshot
|
||||
/// cleanup" button used to run `clear_claude_token`, so pressing it after
|
||||
/// re-authenticating deleted the brand-new token with no confirmation.
|
||||
#[tokio::test]
|
||||
async fn an_images_only_cleanup_never_reaches_the_keychain() {
|
||||
let t = trace();
|
||||
let (tk, ts) = (t.clone(), t.clone());
|
||||
|
||||
let outcome = run_cleanup(
|
||||
Cleanup::ImagesOnly,
|
||||
move || {
|
||||
tk.lock().unwrap().push("keychain");
|
||||
Ok(())
|
||||
},
|
||||
move || async move {
|
||||
ts.lock().unwrap().push("sweep");
|
||||
scrubbed_one()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a sweep-only cleanup is not an error");
|
||||
|
||||
assert_eq!(
|
||||
*t.lock().unwrap(),
|
||||
["sweep"],
|
||||
"the retry deleted a credential nobody confirmed deleting"
|
||||
);
|
||||
assert_eq!(outcome.snapshots_scrubbed, vec!["triple-c-snapshot-a:latest"]);
|
||||
}
|
||||
|
||||
/// …and it still has to report what it could not finish, because "run it
|
||||
/// again once that project is idle" is the whole affordance.
|
||||
#[tokio::test]
|
||||
async fn an_images_only_cleanup_still_reports_what_it_could_not_finish() {
|
||||
let outcome = run_cleanup(
|
||||
Cleanup::ImagesOnly,
|
||||
|| -> Result<(), String> { unreachable!("images only") },
|
||||
|| async {
|
||||
SnapshotScrubReport {
|
||||
failed: vec![(
|
||||
"triple-c-snapshot-b:latest".into(),
|
||||
format!("This project is being started. {}removing a credential from its snapshot.", PROJECT_BUSY_MARKER),
|
||||
)],
|
||||
..Default::default()
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("an image left for the next pass is not a command failure");
|
||||
|
||||
assert!(outcome.needs_another_pass());
|
||||
assert_eq!(outcome.snapshots_skipped.len(), 1, "{:?}", outcome);
|
||||
assert!(outcome.snapshots_failed.is_empty(), "{:?}", outcome);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,20 +37,3 @@ pub async fn get_container_info(
|
||||
docker::get_container_info(&project).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String> {
|
||||
let containers = docker::list_sibling_containers().await?;
|
||||
let result: Vec<serde_json::Value> = containers
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"id": c.id,
|
||||
"names": c.names,
|
||||
"image": c.image,
|
||||
"state": c.state,
|
||||
"status": c.status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -164,6 +164,11 @@ pub struct ScheduledTask {
|
||||
/// Only known for enabled one-shot tasks (their `at` time). Recurring cron
|
||||
/// expressions are not evaluated here.
|
||||
pub next_run: Option<String>,
|
||||
/// Whether a run is in flight right now, from the runner's state file in
|
||||
/// `~/.claude/scheduler/running/<id>.json` with its pid verified live.
|
||||
pub running: bool,
|
||||
/// When the in-flight run started, ISO 8601 (UTC). `None` unless `running`.
|
||||
pub running_since: Option<String>,
|
||||
}
|
||||
|
||||
/// A completion notice written by `triple-c-task-runner` after a task ran.
|
||||
@@ -614,13 +619,25 @@ const SCHEDULER_LIST_SCRIPT: &str = r#"exec 2>/dev/null
|
||||
set -u
|
||||
TASKS="$HOME/.claude/scheduler/tasks"
|
||||
LOGS="$HOME/.claude/scheduler/logs"
|
||||
RUNNING="$HOME/.claude/scheduler/running"
|
||||
[ -d "$TASKS" ] || { echo '[]'; exit 0; }
|
||||
for f in "$TASKS"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
id=$(jq -r '.id // ""' "$f") || continue
|
||||
[ -n "$id" ] || id=$(basename "$f" .json)
|
||||
last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1)
|
||||
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" '{
|
||||
# Live-run state. The pid is checked, not trusted: a container stopped
|
||||
# mid-run cannot fire the runner's cleanup trap, and a task stuck on
|
||||
# "running" forever is a worse lie than showing nothing.
|
||||
started=""
|
||||
state="$RUNNING/$id.json"
|
||||
if [ -f "$state" ]; then
|
||||
pid=$(jq -r '.pid // empty' "$state")
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
started=$(jq -r '.started_epoch // empty' "$state")
|
||||
fi
|
||||
fi
|
||||
jq -c --arg fallback_id "$id" --arg lr "${last%%.*}" --arg started "$started" '{
|
||||
id: (if (.id // "") == "" then $fallback_id else .id end),
|
||||
name: (.name // ""),
|
||||
prompt: (.prompt // ""),
|
||||
@@ -630,7 +647,8 @@ for f in "$TASKS"/*.json; do
|
||||
enabled: (.enabled == true),
|
||||
working_dir: (.working_dir // "/workspace"),
|
||||
created_at: (.created_at // null),
|
||||
last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end)
|
||||
last_run_epoch: (if $lr == "" then null else ($lr | tonumber) end),
|
||||
running_since_epoch: (if $started == "" then null else ($started | tonumber) end)
|
||||
}' "$f"
|
||||
done | jq -s 'sort_by(.name, .id)'
|
||||
"#;
|
||||
@@ -673,6 +691,7 @@ struct RawScheduledTask {
|
||||
working_dir: String,
|
||||
created_at: Option<String>,
|
||||
last_run_epoch: Option<i64>,
|
||||
running_since_epoch: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -723,6 +742,8 @@ pub async fn list_scheduled_tasks(
|
||||
created_at: t.created_at,
|
||||
last_run: t.last_run_epoch.map(epoch_to_iso),
|
||||
next_run,
|
||||
running: t.running_since_epoch.is_some(),
|
||||
running_since: t.running_since_epoch.map(epoch_to_iso),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
@@ -1179,9 +1200,14 @@ pub async fn add_scheduled_task(
|
||||
/// * **In that order**, so a rejected `add` leaves the original untouched
|
||||
/// rather than deleting a prompt the user cannot get back. The cost is a
|
||||
/// sub-second window in which both tasks are in the crontab.
|
||||
/// * The task therefore gets a **new id**. Its old log directory
|
||||
/// (`~/.claude/scheduler/logs/<old-id>/`) stays behind under the old id; the
|
||||
/// UI warns about this before saving.
|
||||
/// * The task therefore gets a **new id**, and its old log directory
|
||||
/// (`~/.claude/scheduler/logs/<old-id>/`) goes with the removal — the
|
||||
/// scheduler reaps a task's logs when the task stops existing, because
|
||||
/// nothing can name that id again afterwards. The UI warns before saving.
|
||||
/// (A project still running an older base image carries the older
|
||||
/// `/usr/local/bin/triple-c-scheduler`, which left the directory behind;
|
||||
/// `/usr/local/bin` only changes on a base-image migration or a Reset. The
|
||||
/// copy is deliberately written for the case that loses data.)
|
||||
/// * `enabled` is carried over explicitly, because `add` always creates an
|
||||
/// enabled task and silently re-enabling a task the user had switched off
|
||||
/// would schedule a run they did not ask for.
|
||||
|
||||
@@ -73,6 +73,25 @@ use crate::AppState;
|
||||
/// Report how far behind the current base image a project's container is, and
|
||||
/// what migrating it would actually carry across.
|
||||
///
|
||||
/// Choose the recorded lineage from the two places it can be written, most
|
||||
/// authoritative first: the live container's label, then the snapshot image's.
|
||||
///
|
||||
/// **An empty label is absence, not an answer.** `create_container` always
|
||||
/// writes `triple-c.base-image-id`, even when the value is unknown — that is
|
||||
/// deliberate, because Docker merges an image's labels into a container's and
|
||||
/// an inherited value would otherwise ride a snapshot forever. The consequence
|
||||
/// is that `Some("")` is the *common* reading from a container whose lineage
|
||||
/// was never established, so treating it as an answer silently skips the
|
||||
/// snapshot, which may well have recorded a real one.
|
||||
fn pick_recorded_lineage(
|
||||
from_container: Option<String>,
|
||||
from_snapshot: Option<String>,
|
||||
) -> Option<String> {
|
||||
from_container
|
||||
.filter(|v| !v.is_empty())
|
||||
.or_else(|| from_snapshot.filter(|v| !v.is_empty()))
|
||||
}
|
||||
|
||||
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
|
||||
/// be called on demand, not polled.
|
||||
#[tauri::command]
|
||||
@@ -98,20 +117,24 @@ pub async fn get_container_staleness(
|
||||
// Lineage, most authoritative source first: the live container's label,
|
||||
// then the snapshot image's. Both are written by `create_container` and
|
||||
// propagated onto the snapshot by `docker commit`.
|
||||
// Each source is filtered for emptiness *before* it is allowed to satisfy
|
||||
// the lookup. `create_container` always writes this label, even when the
|
||||
// value is unknown — deliberately, so an inherited image label cannot ride
|
||||
// a snapshot forever — which means the container's copy is very often
|
||||
// `Some("")`. Filtering only the final result let that empty string count
|
||||
// as an answer and skip the snapshot entirely, so a snapshot that *did*
|
||||
// record a lineage was never consulted and the project reported "unknown"
|
||||
// with the information sitting one lookup away.
|
||||
let container_id = docker::find_existing_container(&project).await.unwrap_or(None);
|
||||
let recorded = match &container_id {
|
||||
let from_container = match &container_id {
|
||||
Some(id) => container_label(id, mig::LABEL_BASE_IMAGE_ID).await,
|
||||
None => None,
|
||||
}
|
||||
.or_else(|| None);
|
||||
let recorded = match recorded {
|
||||
Some(v) => Some(v),
|
||||
None => mig::image_labels(&snapshot_image)
|
||||
.await
|
||||
.get(mig::LABEL_BASE_IMAGE_ID)
|
||||
.cloned(),
|
||||
}
|
||||
.filter(|v| !v.is_empty());
|
||||
};
|
||||
let from_snapshot = mig::image_labels(&snapshot_image)
|
||||
.await
|
||||
.get(mig::LABEL_BASE_IMAGE_ID)
|
||||
.cloned();
|
||||
let recorded = pick_recorded_lineage(from_container, from_snapshot);
|
||||
|
||||
out.base_image_id = recorded.clone();
|
||||
out.known = recorded.is_some();
|
||||
@@ -204,58 +227,62 @@ async fn container_label(container_id: &str, label: &str) -> Option<String> {
|
||||
// Migrate
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Project ids with a migration running **in this process right now**.
|
||||
///
|
||||
/// Two things need it. `reconcile_project_statuses` is callable from the
|
||||
/// frontend at any time, not only at startup, and a live migration looks
|
||||
/// exactly like a crashed one from the outside (state file says `in-progress`,
|
||||
/// container carries the label) — without this guard a reconcile mid-run would
|
||||
/// rewrite the phase to `interrupted` underneath a migration that is fine.
|
||||
/// It also makes a second concurrent `migrate_project_to_base` for the same
|
||||
/// project impossible.
|
||||
static ACTIVE_MIGRATIONS: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashSet<String>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
fn active_migrations() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
|
||||
ACTIVE_MIGRATIONS.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||
}
|
||||
|
||||
/// Whether a migration for this project is running **in this process right
|
||||
/// now**. Every command that stops, removes or recreates the project's
|
||||
/// container has to consult it: the window between `remove_container` and the
|
||||
/// create that follows looks exactly like "no container", and an ordinary
|
||||
/// Start landing in it creates a second container under the same name.
|
||||
///
|
||||
/// **This is now a view onto [`crate::project_lock`], not a set of its own.**
|
||||
/// It used to be the app's only mutual-exclusion primitive, and it was one-way:
|
||||
/// a migration claimed a project, everything else merely polled this once at
|
||||
/// entry and never claimed anything. Two non-migration writers of
|
||||
/// `triple-c-snapshot-{id}:latest` — a compaction and a recreate — could not
|
||||
/// see each other at all. Folding the set into the shared registry means there
|
||||
/// is exactly one answer to "is something happening to this project", and this
|
||||
/// function is the specialisation of it that reconcile still needs: a *live*
|
||||
/// migration is indistinguishable from a crashed one from the outside, and only
|
||||
/// this process knows which it is looking at.
|
||||
///
|
||||
/// No production caller on this branch: the Disk panel's survey was the last
|
||||
/// one, and it went to `hold/disk-and-dragout`. Kept — and still exercised by
|
||||
/// `a_live_migration_is_distinguishable_from_a_crashed_one` — because it is the
|
||||
/// one named answer to that question and re-inventing it is how the two
|
||||
/// disagreeing answers happened the first time.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn is_migrating(project_id: &str) -> bool {
|
||||
active_migrations()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.contains(project_id)
|
||||
crate::project_lock::is_held_by(project_id, crate::project_lock::ProjectOp::Migration)
|
||||
}
|
||||
|
||||
/// RAII marker: removes the project from [`ACTIVE_MIGRATIONS`] however the
|
||||
/// migration ends, including an early `?`.
|
||||
struct ActiveGuard(String);
|
||||
/// RAII marker: releases the project's [`crate::project_lock`] claim however
|
||||
/// the migration ends, including an early `?`.
|
||||
///
|
||||
/// Kept as a named type rather than using [`crate::project_lock::ProjectGuard`]
|
||||
/// directly so the migration path keeps reading as "take the migration guard",
|
||||
/// and so the one place that decides what a migration's claim *is* stays here.
|
||||
struct ActiveGuard(#[allow(dead_code)] crate::project_lock::ProjectGuard);
|
||||
|
||||
impl ActiveGuard {
|
||||
/// `None` when a migration is already running for this project.
|
||||
fn acquire(project_id: &str) -> Option<Self> {
|
||||
let mut set = active_migrations()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if !set.insert(project_id.to_string()) {
|
||||
return None;
|
||||
}
|
||||
Some(Self(project_id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ActiveGuard {
|
||||
fn drop(&mut self) {
|
||||
active_migrations()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&self.0);
|
||||
/// `Err` with the registry's own refusal when a migration — **or anything
|
||||
/// else** — already holds this project.
|
||||
///
|
||||
/// The error string is the point. This returned `Option`, and all three
|
||||
/// callers replaced the discarded reason with a sentence about a migration
|
||||
/// — so a user blocked by a *compaction*, a reset or a cache clear was told
|
||||
/// to wait for a base update that was not running, with nothing in the UI
|
||||
/// that could ever name what actually held the project.
|
||||
/// `project_lock::try_acquire` already composes "what holds it" with "what
|
||||
/// you were trying to do"; there is nothing to add to it here.
|
||||
///
|
||||
/// The tail it composes is `ProjectOp::Migration`'s — "…before starting a
|
||||
/// base update" — for confirm and rollback as well as for the migration
|
||||
/// itself. That is the class all three belong to, and splitting it would
|
||||
/// mean a `ProjectOp` variant per command: the wrong place to encode a
|
||||
/// verb, for a phrase that is at worst imprecise where the old one was
|
||||
/// simply wrong.
|
||||
fn acquire(project_id: &str) -> Result<Self, String> {
|
||||
crate::project_lock::try_acquire(project_id, crate::project_lock::ProjectOp::Migration)
|
||||
.map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,10 +298,9 @@ pub async fn migrate_project_to_base(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<MigrationReport, String> {
|
||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
||||
return Ok(MigrationReport::failed_preflight(
|
||||
"A migration is already running for this project.",
|
||||
));
|
||||
let _guard = match ActiveGuard::acquire(&project_id) {
|
||||
Ok(guard) => guard,
|
||||
Err(busy) => return Ok(MigrationReport::failed_preflight(&busy)),
|
||||
};
|
||||
|
||||
let existing = migration_store::load(&project_id)?;
|
||||
@@ -437,6 +463,33 @@ async fn fresh_migration(
|
||||
}
|
||||
}
|
||||
|
||||
// Scrub *before* the stop, not inside the commit below. `scrub_writable_layer`
|
||||
// is a `docker exec`, which only works on a running container — and the
|
||||
// pre-swap commit is the single largest snapshot Triple-C ever takes, so
|
||||
// letting this one path commit unscrubbed is what the scrub exists to
|
||||
// prevent. Failure is swallowed inside; it must never block a migration.
|
||||
//
|
||||
// The outcome is logged rather than discarded: this is the one scrub whose
|
||||
// silence would be expensive, because the layer it declined to clean is
|
||||
// about to be committed into a snapshot that outlives the migration.
|
||||
//
|
||||
// H2: **bind it first.** `ScrubOutcome` is `#[must_use]`, and the way that
|
||||
// was satisfied here was by folding the awaited call into `log::info!`'s
|
||||
// argument list. `log::info!` expands to
|
||||
// `if Info <= max_level() { … }` — so the arguments, this data-integrity
|
||||
// step among them, live inside the level check and do not run at all when
|
||||
// the global filter is `Off`. That is reachable: `logging::init` tolerates
|
||||
// `dispatch.apply()` failing, and fern returns *before* `set_max_level` on
|
||||
// error, so a process that failed to install a logger sits at `Off` with
|
||||
// every `log::` argument list silently dead. The scrub would then never
|
||||
// run, and the unscrubbed layer would be committed into the longest-lived
|
||||
// snapshot the app takes. `commit_container_snapshot` gets this right for
|
||||
// the same reason; nothing may put an effect inside a log macro's
|
||||
// arguments. (`logging::init` now also restores the level on failure, but
|
||||
// the call site is not allowed to depend on that.)
|
||||
let scrub = docker::scrub_writable_layer(&container_id).await;
|
||||
log::info!("Pre-migration scrub of {}{}", container_id, scrub.commit_log_suffix());
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Stopping the container...");
|
||||
let _ = state
|
||||
.projects_store
|
||||
@@ -805,12 +858,7 @@ pub async fn confirm_migration(
|
||||
let _ = &state;
|
||||
// Confirming drops the only way back. Doing that underneath a running
|
||||
// migration would delete the pin it is relying on mid-flight.
|
||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
||||
return Err(
|
||||
"A container base update is running for this project right now. Wait for it to finish."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
let _guard = ActiveGuard::acquire(&project_id)?;
|
||||
let Some(mstate) = migration_store::load(&project_id)? else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -833,6 +881,16 @@ pub async fn confirm_migration(
|
||||
migration_store::clear_staging(&project_id)?;
|
||||
migration_store::clear(&project_id)?;
|
||||
log::info!("Migration confirmed for project {}", project_id);
|
||||
|
||||
// Dropping the pin above is what turns the pre-migration image into an
|
||||
// orphan: it was the only tag holding a multi-gigabyte pre-migration
|
||||
// snapshot. Accepting the update is therefore the moment to sweep, and
|
||||
// waiting for the project's next recreation would leave it lying around
|
||||
// indefinitely.
|
||||
tauri::async_runtime::spawn(async {
|
||||
crate::docker::sweep_orphaned_snapshots_logged("after migration confirmed").await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -847,12 +905,7 @@ pub async fn rollback_migration(
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
||||
return Err(
|
||||
"A container base update is running for this project right now. Wait for it to finish."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
let _guard = ActiveGuard::acquire(&project_id)?;
|
||||
|
||||
let mut project = state
|
||||
.projects_store
|
||||
@@ -924,6 +977,16 @@ pub async fn rollback_migration(
|
||||
let _ = mig::untag_image(&rollback_ref).await;
|
||||
migration_store::clear_staging(&project_id)?;
|
||||
migration_store::clear(&project_id)?;
|
||||
|
||||
// Retagging above moved `:latest` off the *migrated* snapshot, and the
|
||||
// container that was built from it was removed a few lines up — so a
|
||||
// multi-gigabyte image is sitting there untagged and unreferenced with
|
||||
// nothing else in the app that would ever look at it again. The confirm
|
||||
// path sweeps for exactly this reason; rolling back orphans just as much
|
||||
// and did not.
|
||||
tauri::async_runtime::spawn(async {
|
||||
crate::docker::sweep_orphaned_snapshots_logged("after migration rollback").await;
|
||||
});
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
@@ -955,9 +1018,203 @@ pub async fn get_migration_state(
|
||||
pub async fn reconcile_migration(project: &Project, app_handle: &tauri::AppHandle) {
|
||||
// A migration running right now is indistinguishable from a crashed one
|
||||
// from the outside; only this process knows the difference.
|
||||
if is_migrating(&project.id) {
|
||||
//
|
||||
// **Any holder, not just a migration.** This asked `is_migrating`, which is
|
||||
// `held() == Some(Migration)` — so a compaction, a reset or a destroy
|
||||
// holding the project made this fall straight through and start rewriting
|
||||
// the migration record's phase and untagging its rollback image underneath
|
||||
// whatever was running. `reconcile_project_statuses` is a command, not just
|
||||
// a startup step, so "nothing else can be running yet" is not available as
|
||||
// an argument.
|
||||
//
|
||||
// Yielding is right; yielding *forever* was not. The only caller fires once
|
||||
// per "Docker became available", so a project that happened to be held at
|
||||
// that instant was never looked at again for the rest of the session: its
|
||||
// phase stayed un-normalised, no resume or rollback was ever offered, and
|
||||
// its `:pre-migration-*` pin stayed `Claimed`. So the visit is deferred
|
||||
// rather than dropped — see [`defer_migration_reconcile`].
|
||||
if let Some(holder) = crate::project_lock::held(&project.id) {
|
||||
log::debug!(
|
||||
"Deferring migration reconcile for '{}' ({}): {}",
|
||||
project.name,
|
||||
project.id,
|
||||
holder.describe()
|
||||
);
|
||||
defer_migration_reconcile(project, app_handle);
|
||||
return;
|
||||
}
|
||||
reconcile_migration_now(project, app_handle).await;
|
||||
}
|
||||
|
||||
/// How long [`defer_migration_reconcile`] waits between looks, and how many
|
||||
/// times it looks.
|
||||
///
|
||||
/// The operations it is waiting behind are minutes long — a Reset recreates a
|
||||
/// container from a base image, a compaction rebuilds a multi-gigabyte
|
||||
/// snapshot — so the interval is coarse on purpose: this is a `held()` read
|
||||
/// against an in-process map, but every wakeup is a task and the point is to
|
||||
/// catch the release, not to catch it promptly. Twenty seconds × ninety is
|
||||
/// thirty minutes, comfortably past the longest measured compaction, after
|
||||
/// which the project is left for the next `reconcile_project_statuses`.
|
||||
const RECONCILE_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
const RECONCILE_RETRY_ATTEMPTS: usize = 90;
|
||||
|
||||
/// Project ids with a deferred reconcile already waiting.
|
||||
///
|
||||
/// `reconcile_project_statuses` is a command the frontend can call more than
|
||||
/// once — every "Docker became available" — and each call walks every project.
|
||||
/// Without this, a project held for a few minutes would accumulate one waiting
|
||||
/// task per call, all of which would then reconcile the same record in a row.
|
||||
fn reconcile_retries() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
|
||||
static RETRIES: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
|
||||
std::sync::OnceLock::new();
|
||||
RETRIES.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||
}
|
||||
|
||||
/// One project's place in [`reconcile_retries`], handed back on drop.
|
||||
///
|
||||
/// RAII for the reason [`crate::project_lock::ProjectGuard`] sets out, and this
|
||||
/// claim is the case that proves the rule: the release used to be a trailing
|
||||
/// statement at the bottom of the spawned task in
|
||||
/// [`defer_migration_reconcile`], sitting after an `.await` on
|
||||
/// [`reconcile_migration_now`]. A panic in there — or the future simply being
|
||||
/// dropped, which is what happens to every in-flight task at shutdown — skips
|
||||
/// the statement, and nothing else ever removes an id from that set. The
|
||||
/// project is then fenced off from *every* later deferral for the rest of the
|
||||
/// process: each `reconcile_project_statuses` pass finds it held, fails to
|
||||
/// claim, and returns, so the phase stays un-normalised, no resume or rollback
|
||||
/// is offered, and the `:pre-migration-*` pin stays `Claimed`. That is the
|
||||
/// session-long silence deferring was written to end, reintroduced one panic
|
||||
/// later and lasting until the app is restarted.
|
||||
///
|
||||
/// Dropping this hands the claim straight back, so a caller that discards the
|
||||
/// value has claimed nothing while reading as though it had; `#[must_use]`
|
||||
/// makes that a compile warning rather than a second waiter on one record.
|
||||
#[must_use = "the claim is handed back the moment this guard drops; bind it inside the waiting task, for the whole task"]
|
||||
struct ReconcileRetryClaim {
|
||||
project_id: String,
|
||||
}
|
||||
|
||||
impl Drop for ReconcileRetryClaim {
|
||||
fn drop(&mut self) {
|
||||
// `into_inner` past poisoning, as in `project_lock`: the only thing
|
||||
// ever done while holding this mutex is a single `HashSet` insert or
|
||||
// remove, so a panic on another thread cannot have left it half
|
||||
// written — and declining to release here would strand the project
|
||||
// permanently, which is the exact failure the guard exists to stop.
|
||||
reconcile_retries()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&self.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim the right to be the one deferred reconcile for `project_id`.
|
||||
/// `None` means somebody else already is.
|
||||
fn claim_reconcile_retry(project_id: &str) -> Option<ReconcileRetryClaim> {
|
||||
reconcile_retries()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(project_id.to_string())
|
||||
.then(|| ReconcileRetryClaim {
|
||||
project_id: project_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Come back to a project that was held when [`reconcile_migration`] reached it.
|
||||
///
|
||||
/// Only for projects that have a record on disk: [`migration_store::has_record`]
|
||||
/// is filesystem presence, so it costs nothing and is deliberately the *cheap*
|
||||
/// question — every project is walked on every reconcile and almost none of
|
||||
/// them have a migration in flight. A record that exists but cannot be parsed
|
||||
/// answers `true` here and is handled, conservatively, by `load` when the
|
||||
/// retry lands.
|
||||
///
|
||||
/// The wait is a poll rather than a notification because `project_lock` has no
|
||||
/// release hook and giving it one would mean a guard's `Drop` waking tasks
|
||||
/// while it still holds the map's mutex. A read of an in-process `HashMap`
|
||||
/// every twenty seconds, for as long as one operation is running on one
|
||||
/// project, is not worth a condvar.
|
||||
fn defer_migration_reconcile(project: &Project, app_handle: &tauri::AppHandle) {
|
||||
// No record means nothing to come back for. An unreadable migrations
|
||||
// directory answers "maybe", and maybe is worth a look.
|
||||
if !migration_store::has_record(&project.id).unwrap_or(true) {
|
||||
return;
|
||||
}
|
||||
let Some(claim) = claim_reconcile_retry(&project.id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let project = project.clone();
|
||||
let app_handle = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Moved in and bound for the whole body, rather than released by a
|
||||
// statement at the bottom: everything below this line can panic or be
|
||||
// dropped mid-await, and a claim that only comes back on the happy path
|
||||
// is a claim that eventually does not come back at all. See
|
||||
// [`ReconcileRetryClaim`].
|
||||
let _claim = claim;
|
||||
let released =
|
||||
await_release(&project.id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await;
|
||||
if released {
|
||||
// Whatever was holding it may have finished the migration itself or
|
||||
// cleared the record — `reconcile_migration_now` loads the record
|
||||
// first and returns on `None`, so that is a no-op rather than a
|
||||
// special case here.
|
||||
reconcile_migration_now(&project, &app_handle).await;
|
||||
} else {
|
||||
log::warn!(
|
||||
"Gave up waiting to reconcile the migration record for '{}' ({}): it has been \
|
||||
held for {} minutes. Its phase is unchanged and its rollback pin is still \
|
||||
claimed; the next reconcile pass will try again.",
|
||||
project.name,
|
||||
project.id,
|
||||
RECONCILE_RETRY_INTERVAL.as_secs() as usize * RECONCILE_RETRY_ATTEMPTS / 60
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Wait for `project_id` to stop being held: `attempts` looks, the first
|
||||
/// immediate and the rest `interval` apart. `true` means it was released,
|
||||
/// `false` that the budget ran out with it still held.
|
||||
///
|
||||
/// Split out of [`defer_migration_reconcile`] so the waiting can be tested
|
||||
/// against a real [`crate::project_lock`] guard on a paused clock — the parts
|
||||
/// that are easy to get wrong are "gives up while still holding the claim",
|
||||
/// "never looks again", and the ordering of the look against the sleep, none of
|
||||
/// which is visible from the constants.
|
||||
async fn await_release(
|
||||
project_id: &str,
|
||||
interval: std::time::Duration,
|
||||
attempts: usize,
|
||||
) -> bool {
|
||||
for attempt in 0..attempts {
|
||||
// Look first, sleep second. Sleeping first charged every deferral a
|
||||
// full interval before anyone read the map even once, and the common
|
||||
// case is a holder that has already let go: `held()` is sampled in
|
||||
// `reconcile_migration`, a task is spawned, and by the time it is first
|
||||
// polled the Reset that was on its last step is frequently finished.
|
||||
// That bought nothing and cost twenty seconds of a startup pass waiting
|
||||
// on a lock nobody holds, in front of a check that is one `HashMap`
|
||||
// lookup.
|
||||
if crate::project_lock::held(project_id).is_none() {
|
||||
return true;
|
||||
}
|
||||
// And no sleep after the final look: nothing reads the map again
|
||||
// afterwards, so it is twenty seconds of delay in front of a `false`
|
||||
// that has already been decided. The budget is still `attempts` looks,
|
||||
// which is what the constants above are chosen against.
|
||||
if attempt + 1 < attempts {
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// [`reconcile_migration`] with the "is anything holding this project" question
|
||||
/// already answered.
|
||||
async fn reconcile_migration_now(project: &Project, app_handle: &tauri::AppHandle) {
|
||||
let state = match migration_store::load(&project.id) {
|
||||
Ok(Some(s)) => s,
|
||||
Ok(None) => return,
|
||||
@@ -1113,7 +1370,23 @@ pub(crate) async fn purge_migration_artifacts(project_id: &str) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => return,
|
||||
Ok(None) => {
|
||||
// **`Ok(None)` is not the same as "no file".** `migration_store::load`
|
||||
// now reports an *unparseable* record as absent while deliberately
|
||||
// leaving it on disk, so that `has_record` goes on protecting the
|
||||
// rollback pin it describes. Returning here on that would leave the
|
||||
// file — and therefore a permanently "claimed" pin — behind a Reset
|
||||
// that has just deleted the snapshot and both volumes the record
|
||||
// could possibly refer to.
|
||||
if !migration_store::has_record(project_id).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
log::warn!(
|
||||
"Project {} has a migration record that could not be read; removing it anyway \
|
||||
because a Reset supersedes it",
|
||||
project_id
|
||||
);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Could not read the migration record for {} while cleaning up: {}",
|
||||
project_id,
|
||||
@@ -1122,6 +1395,10 @@ pub(crate) async fn purge_migration_artifacts(project_id: &str) {
|
||||
}
|
||||
let _ = migration_store::clear_staging(project_id);
|
||||
let _ = migration_store::clear(project_id);
|
||||
// The pins this project had are gone with the snapshot; their grace clocks
|
||||
// are meaningless and would otherwise sit in the migrations directory
|
||||
// forever.
|
||||
migration_store::clear_ownerless_for_project(project_id);
|
||||
}
|
||||
|
||||
fn default_docker_socket() -> String {
|
||||
@@ -1661,6 +1938,32 @@ fn summarize(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_empty_lineage_label_is_absence_and_falls_through_to_the_snapshot() {
|
||||
let some = |s: &str| Some(s.to_string());
|
||||
|
||||
// The regression: the container always carries the label, so an
|
||||
// unknown lineage reads as `Some("")`. Letting that satisfy the lookup
|
||||
// skipped a snapshot that had recorded the real thing.
|
||||
assert_eq!(
|
||||
pick_recorded_lineage(some(""), some("sha256:base")),
|
||||
some("sha256:base")
|
||||
);
|
||||
|
||||
// Ordinary precedence still holds: the container wins when it has one.
|
||||
assert_eq!(
|
||||
pick_recorded_lineage(some("sha256:container"), some("sha256:snapshot")),
|
||||
some("sha256:container")
|
||||
);
|
||||
assert_eq!(pick_recorded_lineage(None, some("sha256:snap")), some("sha256:snap"));
|
||||
|
||||
// Genuinely unknown stays unknown — "probe instead", never a lineage
|
||||
// invented to make the comparison succeed.
|
||||
assert_eq!(pick_recorded_lineage(None, None), None);
|
||||
assert_eq!(pick_recorded_lineage(some(""), some("")), None);
|
||||
assert_eq!(pick_recorded_lineage(some(""), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
||||
assert_eq!(human_bytes(512), "512 B");
|
||||
@@ -1819,16 +2122,23 @@ mod tests {
|
||||
{
|
||||
let g = ActiveGuard::acquire(id).expect("first acquire must succeed");
|
||||
assert!(is_migrating(id));
|
||||
let refused = ActiveGuard::acquire(id)
|
||||
.err()
|
||||
.expect("a second concurrent migration must be refused");
|
||||
// The refusal has to say what is holding the project, not what the
|
||||
// caller happens to be — the three commands used to substitute
|
||||
// their own sentence for this and lost the distinction.
|
||||
assert!(
|
||||
ActiveGuard::acquire(id).is_none(),
|
||||
"a second concurrent migration must be refused"
|
||||
refused.contains("base update"),
|
||||
"the refusal must name the holder: {}",
|
||||
refused
|
||||
);
|
||||
drop(g);
|
||||
}
|
||||
assert!(!is_migrating(id), "the guard must release on drop");
|
||||
// …including when the migration bailed out through an early return.
|
||||
fn early_return(id: &str) -> Option<()> {
|
||||
let _g = ActiveGuard::acquire(id)?;
|
||||
let _g = ActiveGuard::acquire(id).ok()?;
|
||||
None
|
||||
}
|
||||
assert!(early_return(id).is_none());
|
||||
@@ -1842,4 +2152,225 @@ mod tests {
|
||||
assert!(!r.rollback_available);
|
||||
assert!(r.packages_requested.is_empty());
|
||||
}
|
||||
|
||||
/// No `await` may sit inside a `log::*!` argument list — H2, generalised.
|
||||
///
|
||||
/// `log::info!(a, b)` expands to `if Info <= max_level() { … a … b … }`, so
|
||||
/// an argument is only evaluated while the level admits the record. Folding
|
||||
/// `scrub_writable_layer(&id).await.commit_log_suffix()` into the arguments
|
||||
/// here — done to satisfy `#[must_use]` on `ScrubOutcome` — therefore made
|
||||
/// the pre-migration scrub conditional on the log level, and
|
||||
/// `logging::init` deliberately tolerates failing to install a logger,
|
||||
/// which leaves `max_level()` at `Off`. A scrub that never runs before the
|
||||
/// largest snapshot the app takes is not something a log level may decide.
|
||||
///
|
||||
/// Scanned over the source rather than asserted at one call site: the bug
|
||||
/// is a shape, and it is reintroduced by whoever next has a `#[must_use]`
|
||||
/// value they only want to log.
|
||||
#[test]
|
||||
fn nothing_awaits_inside_a_log_macros_arguments() {
|
||||
let sources: &[(&str, &str)] = &[
|
||||
("commands/migration_commands.rs", include_str!("migration_commands.rs")),
|
||||
("docker/container.rs", include_str!("../docker/container.rs")),
|
||||
("docker/migration.rs", include_str!("../docker/migration.rs")),
|
||||
("logging.rs", include_str!("../logging.rs")),
|
||||
];
|
||||
let macros = ["log::error!(", "log::warn!(", "log::info!(", "log::debug!(", "log::trace!("];
|
||||
let mut scanned = 0usize;
|
||||
for (name, src) in sources {
|
||||
for mac in macros {
|
||||
let mut from = 0usize;
|
||||
while let Some(at) = src[from..].find(mac) {
|
||||
let start = from + at + mac.len();
|
||||
// Balance the macro's own parentheses. String literals in
|
||||
// these call sites never contain an unbalanced one, and a
|
||||
// `(` inside a format string would only ever widen the
|
||||
// slice, i.e. fail safe.
|
||||
let mut depth = 1usize;
|
||||
let mut end = start;
|
||||
for (i, c) in src[start..].char_indices() {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = start + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let args = &src[start..end];
|
||||
// This test's own name mentions the thing it forbids.
|
||||
assert!(
|
||||
!args.contains(".await"),
|
||||
"{}: an `.await` inside a `{}` argument list stops happening whenever the \
|
||||
log level does not admit the record:\n{}",
|
||||
name,
|
||||
mac.trim_end_matches('('),
|
||||
args
|
||||
);
|
||||
scanned += 1;
|
||||
from = end.max(start);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A scanner that matched nothing would pass silently forever.
|
||||
assert!(scanned > 80, "only {} log call sites were scanned", scanned);
|
||||
}
|
||||
|
||||
/// MEDIUM: a project held when the reconcile pass reached it must be
|
||||
/// revisited, not dropped for the session.
|
||||
///
|
||||
/// `reconcile_project_statuses` fires once per "Docker became available",
|
||||
/// so the old `return` meant a project that happened to be mid-Reset at
|
||||
/// that instant never had its migration phase normalised, was never offered
|
||||
/// resume or rollback, and kept its `:pre-migration-*` pin `Claimed` — for
|
||||
/// the rest of the session. On a paused clock, so the thirty-minute budget
|
||||
/// costs nothing.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_held_project_is_revisited_once_the_holder_lets_go() {
|
||||
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
|
||||
let guard = crate::project_lock::try_acquire(&id, crate::project_lock::ProjectOp::Reset)
|
||||
.expect("a fresh project id is not held");
|
||||
|
||||
let waiting = {
|
||||
let id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await
|
||||
})
|
||||
};
|
||||
|
||||
// Long enough that several looks have already happened and found it
|
||||
// held, so this cannot pass by the waiter never having polled.
|
||||
tokio::time::sleep(RECONCILE_RETRY_INTERVAL * 3).await;
|
||||
assert!(!waiting.is_finished(), "the waiter returned while the project was held");
|
||||
|
||||
drop(guard);
|
||||
assert!(
|
||||
waiting.await.expect("the waiter task"),
|
||||
"the holder let go and the reconcile never came back"
|
||||
);
|
||||
}
|
||||
|
||||
/// And the budget is finite: a project held indefinitely does not leave a
|
||||
/// task waiting on it forever, and the claim is handed back either way.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn waiting_for_a_holder_gives_up_eventually() {
|
||||
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
|
||||
let _guard =
|
||||
crate::project_lock::try_acquire(&id, crate::project_lock::ProjectOp::Migration)
|
||||
.expect("a fresh project id is not held");
|
||||
assert!(!await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await);
|
||||
// Thirty minutes: past the longest measured compaction, and the thing
|
||||
// being waited on is always a bounded, user-initiated operation.
|
||||
assert!(RECONCILE_RETRY_ATTEMPTS > 0, "deferring would be a no-op");
|
||||
let budget = RECONCILE_RETRY_INTERVAL * RECONCILE_RETRY_ATTEMPTS as u32;
|
||||
assert!(budget >= std::time::Duration::from_secs(15 * 60), "{:?}", budget);
|
||||
}
|
||||
|
||||
/// MEDIUM: an unheld project is reconciled now, not in twenty seconds.
|
||||
///
|
||||
/// The wait slept before its first look, so a holder that let go between
|
||||
/// `reconcile_migration` sampling `held()` and this task being polled — the
|
||||
/// *common* case, since a deferral is only taken when something was on its
|
||||
/// way out — still cost a full `RECONCILE_RETRY_INTERVAL` of a startup pass
|
||||
/// waiting on a lock nobody held. On a paused clock the assertion is exact:
|
||||
/// the fixed shape returns without the clock moving at all, the sleep-first
|
||||
/// shape cannot return before it has advanced one interval.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn an_unheld_project_is_seen_without_waiting_out_an_interval() {
|
||||
let id = format!("await-release-{}", uuid::Uuid::new_v4().simple());
|
||||
assert!(
|
||||
crate::project_lock::held(&id).is_none(),
|
||||
"a fresh uuid is not held"
|
||||
);
|
||||
|
||||
let before = tokio::time::Instant::now();
|
||||
assert!(await_release(&id, RECONCILE_RETRY_INTERVAL, RECONCILE_RETRY_ATTEMPTS).await);
|
||||
let waited = tokio::time::Instant::now() - before;
|
||||
assert_eq!(
|
||||
waited,
|
||||
std::time::Duration::ZERO,
|
||||
"an already-released project cost {:?} before anyone looked",
|
||||
waited
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_deferred_reconcile_waits_per_project() {
|
||||
// Every "Docker became available" walks every project, so without the
|
||||
// claim a project held for a few minutes accumulates one waiting task
|
||||
// per call — all of which then reconcile the same record in a row.
|
||||
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||
let other = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||
let first = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
|
||||
assert!(
|
||||
claim_reconcile_retry(&id).is_none(),
|
||||
"a second waiter was allowed in"
|
||||
);
|
||||
let other_claim =
|
||||
claim_reconcile_retry(&other).expect("the claim is not per-project");
|
||||
drop(first);
|
||||
// Bound rather than discarded: the guard releases on drop, so
|
||||
// `claim_reconcile_retry(&id);` as a bare statement would test nothing
|
||||
// — which is what `#[must_use]` is there to catch in real callers.
|
||||
let retaken = claim_reconcile_retry(&id).expect("the claim was never handed back");
|
||||
drop(retaken);
|
||||
drop(other_claim);
|
||||
// And the other project's claim was never the same claim.
|
||||
drop(claim_reconcile_retry(&other).expect("released independently"));
|
||||
}
|
||||
|
||||
/// MEDIUM: the claim survives the task that holds it dying badly.
|
||||
///
|
||||
/// The release used to be a trailing statement after
|
||||
/// `reconcile_migration_now(...).await` at the bottom of the spawned task,
|
||||
/// so a panic anywhere in that call — or the future being dropped at
|
||||
/// shutdown — skipped it and left the id in the set with no task behind it.
|
||||
/// Nothing removes it afterwards, so that project could never be deferred
|
||||
/// again for the rest of the process: exactly the state deferring was added
|
||||
/// to prevent, now permanent instead of one pass long. Fails against the
|
||||
/// trailing-statement shape, which is the point.
|
||||
#[tokio::test]
|
||||
async fn a_panicking_deferred_reconcile_hands_its_claim_back() {
|
||||
let id = format!("retry-claim-{}", uuid::Uuid::new_v4().simple());
|
||||
let claimed = claim_reconcile_retry(&id).expect("a fresh project id is unclaimed");
|
||||
|
||||
// Spawned, not just called: the real claim is held across an await
|
||||
// inside a `tauri::async_runtime::spawn`, and a task panic is caught by
|
||||
// the runtime rather than unwinding the caller.
|
||||
let task = {
|
||||
let id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
let _claim = claimed;
|
||||
tokio::task::yield_now().await;
|
||||
panic!("reconcile_migration_now blew up on '{}'", id);
|
||||
})
|
||||
};
|
||||
assert!(task.await.is_err(), "the task was supposed to panic");
|
||||
|
||||
let after = claim_reconcile_retry(&id);
|
||||
assert!(
|
||||
after.is_some(),
|
||||
"a panicking reconcile stranded the claim — this project can never be \
|
||||
deferred again for the rest of the process"
|
||||
);
|
||||
drop(after);
|
||||
|
||||
// The other half of the same failure: a task that is simply dropped
|
||||
// mid-flight, which is every in-flight task at shutdown.
|
||||
let claimed = claim_reconcile_retry(&id).expect("released above");
|
||||
let never_finishes = tokio::spawn(async move {
|
||||
let _claim = claimed;
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
never_finishes.abort();
|
||||
let _ = never_finishes.await;
|
||||
assert!(
|
||||
claim_reconcile_retry(&id).is_some(),
|
||||
"a dropped task stranded the claim"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod install_helper_commands;
|
||||
pub mod migration_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod settings_export_commands;
|
||||
pub mod stt_commands;
|
||||
pub mod terminal_commands;
|
||||
pub mod update_commands;
|
||||
|
||||
@@ -10,12 +10,72 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, Str
|
||||
Ok(state.settings_store.get())
|
||||
}
|
||||
|
||||
/// Everything `update_settings` refuses a save over, run against the store's
|
||||
/// *current* value and the incoming one.
|
||||
///
|
||||
/// Pulled out so a caller that does other, harder-to-undo work alongside a
|
||||
/// settings save — `settings_export_commands::apply_settings_import`
|
||||
/// restores three keychain secrets in the same command — can run this
|
||||
/// *first* and bail before touching anything, rather than discovering the
|
||||
/// rejection only when `update_settings` itself runs partway through.
|
||||
pub fn validate_settings_update(
|
||||
before: &AppSettings,
|
||||
incoming: &AppSettings,
|
||||
) -> Result<(), String> {
|
||||
// The global half of the same rule the project half gets in
|
||||
// `update_project`: a global custom env var is merged into every project's
|
||||
// container environment, so an unchecked name here reaches all of them.
|
||||
crate::models::validate_env_vars_update(
|
||||
&before.global_custom_env_vars,
|
||||
&incoming.global_custom_env_vars,
|
||||
)?;
|
||||
|
||||
// The same for the two host paths this struct owns. `update_project`
|
||||
// validated its per-project overrides and this side validated nothing,
|
||||
// which left the wider hole of the two: `default_ssh_key_path` is the
|
||||
// fallback for **every** project without an override
|
||||
// (`container.rs`'s `create_container`), so `/` here read-only bind-mounts
|
||||
// the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh`
|
||||
// then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the
|
||||
// home volume this release exists to bound.
|
||||
//
|
||||
// Grandfathered the same way project paths are: a value carried over
|
||||
// unchanged still saves, so a store written before this check cannot lock
|
||||
// the user out of their own settings.
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"SSH key path",
|
||||
before.default_ssh_key_path.as_deref(),
|
||||
incoming.default_ssh_key_path.as_deref(),
|
||||
)?;
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"CA certificate path",
|
||||
before.ca_cert_path.as_deref(),
|
||||
incoming.ca_cert_path.as_deref(),
|
||||
)?;
|
||||
|
||||
// Third host path this struct owns, same reasoning: any project with
|
||||
// `allow_docker_access` bind-mounts this path in as the Docker socket
|
||||
// (`project_commands.rs`'s container creation), so an unchecked value
|
||||
// here is a read-write bind mount of whatever it names into every such
|
||||
// project's container.
|
||||
crate::commands::project_commands::validate_mounted_host_path(
|
||||
"Docker socket path",
|
||||
before.docker_socket_path.as_deref(),
|
||||
incoming.docker_socket_path.as_deref(),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_settings(
|
||||
settings: AppSettings,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AppSettings, String> {
|
||||
let before = state.settings_store.get();
|
||||
|
||||
validate_settings_update(&before, &settings)?;
|
||||
|
||||
let saved = state.settings_store.update(settings)?;
|
||||
|
||||
// Persisting a setting is not the same as applying it. The gateway is the
|
||||
@@ -90,7 +150,10 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
||||
GatewayAction::StopIfRunning => {
|
||||
log::info!("Model gateway disabled in settings — stopping the container");
|
||||
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
||||
log::error!("Failed to stop the model gateway after it was disabled: {}", e);
|
||||
log::error!(
|
||||
"Failed to stop the model gateway after it was disabled: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
GatewayAction::RestartIfRunning => {
|
||||
@@ -106,10 +169,7 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pull_image(
|
||||
image_name: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri::Emitter;
|
||||
docker::pull_image(&image_name, move |msg| {
|
||||
let _ = app_handle.emit("image-pull-progress", msg);
|
||||
@@ -302,7 +362,10 @@ mod tests {
|
||||
let before = enabled_gateway();
|
||||
let mut after = before.clone();
|
||||
after.enabled = false;
|
||||
assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
|
||||
assert_eq!(
|
||||
gateway_action(&before, &after),
|
||||
GatewayAction::StopIfRunning
|
||||
);
|
||||
// Still true when it was already off — a stray running container is
|
||||
// still a container that shouldn't be up.
|
||||
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
//! Settings export/import — see triple-c#35.
|
||||
//!
|
||||
//! Exports the *host* environment (global `AppSettings` plus the global
|
||||
//! secrets kept in the OS keychain: the shared Claude Code OAuth login and
|
||||
//! the model gateway's two keys), encrypted with a user-chosen password —
|
||||
//! see `storage::settings_crypto` for the actual cryptography. Deliberately
|
||||
//! out of scope: per-project settings, per-project secrets, and anything
|
||||
//! living in a project's Docker volumes.
|
||||
//!
|
||||
//! **The save/open dialogs are opened from Rust**, the same pattern
|
||||
//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already
|
||||
//! establish and document at length: a frontend-driven dialog handing Rust a
|
||||
//! host path string is the exact shape of bug that produced this app's past
|
||||
//! criticals, so the boundary here is drawn the same place. The frontend can
|
||||
//! ask for a picker; it cannot name a host path as an *input*. `preview_
|
||||
//! settings_import` resolves the chosen path itself and remembers it
|
||||
//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads
|
||||
//! the same file without the path ever crossing back over IPC.
|
||||
//!
|
||||
//! The *decrypted payload* is not cached between preview and apply — the
|
||||
//! password the frontend passes to each call is what it already held for
|
||||
//! the first, not a fresh secret extracted from the user, but nothing here
|
||||
//! keeps the plaintext itself — export/import secrets included — around for
|
||||
//! longer than one command's execution; `apply_settings_import` re-decrypts
|
||||
//! the file rather than reusing anything `preview_settings_import` computed.
|
||||
//!
|
||||
//! **This is new attack surface**: a settings export is a file one person
|
||||
//! can hand another and ask them to import, together with a password, and
|
||||
//! `apply_settings_import` applies whatever `AppSettings` it decrypts to
|
||||
//! wholesale — see the module doc on `models::settings_export` for the
|
||||
//! `web_terminal.access_token` carve-out a review of this feature found,
|
||||
//! and treat that as the standing example of the class of thing to keep
|
||||
//! checking for here, not a one-off fixed bug.
|
||||
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tauri::State;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::models::{
|
||||
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
|
||||
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
};
|
||||
use crate::storage::{secure, settings_crypto};
|
||||
use crate::AppState;
|
||||
|
||||
/// What `preview_settings_import` pins so `apply_settings_import` can tell
|
||||
/// whether the file it's about to re-read is the same one the user actually
|
||||
/// saw a preview of. Confirming a preview is only meaningful if it's binding
|
||||
/// on what gets applied — without this, a file replaced on disk between the
|
||||
/// two calls (this app's own stated threat model is a file shared between
|
||||
/// people, which may sit in a synced or shared directory) would decrypt and
|
||||
/// apply silently different content than what the confirmation dialog showed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingSettingsImport {
|
||||
path: PathBuf,
|
||||
ciphertext_hash: [u8; 32],
|
||||
}
|
||||
|
||||
fn hash_ciphertext(data: &[u8]) -> [u8; 32] {
|
||||
Sha256::digest(data).into()
|
||||
}
|
||||
|
||||
const FILE_EXTENSION: &str = "triplec";
|
||||
|
||||
/// Enforced here, not only in the export modal: the frontend's minimum is a
|
||||
/// UX nudge, but `export_settings` is the actual boundary a weak password
|
||||
/// has to cross, and Argon2id's memory-hardness buys little against an
|
||||
/// attacker who can just try a three-character password directly.
|
||||
const MIN_PASSWORD_LEN: usize = 8;
|
||||
|
||||
fn suggested_export_name() -> String {
|
||||
// Timestamped so exporting more than once doesn't silently overwrite an
|
||||
// earlier file just because the save dialog defaults to the same name.
|
||||
format!(
|
||||
"triple-c-settings-{}.{}",
|
||||
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||
FILE_EXTENSION
|
||||
)
|
||||
}
|
||||
|
||||
async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
window
|
||||
.dialog()
|
||||
.file()
|
||||
.set_parent(window)
|
||||
.set_title("Export Triple-C settings")
|
||||
.set_file_name(suggested)
|
||||
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||
.save_file(move |picked| {
|
||||
let _ = tx.send(picked);
|
||||
});
|
||||
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||
}
|
||||
|
||||
async fn pick_import_open_path(window: &tauri::Window) -> Option<PathBuf> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
window
|
||||
.dialog()
|
||||
.file()
|
||||
.set_parent(window)
|
||||
.set_title("Import Triple-C settings")
|
||||
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||
.pick_file(move |picked| {
|
||||
let _ = tx.send(picked);
|
||||
});
|
||||
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||
}
|
||||
|
||||
/// Gather the current global secrets, and hand back the `AppSettings` to
|
||||
/// export with the web-terminal token blanked out of it — see the module
|
||||
/// doc comment on `models::settings_export` for why that field cannot
|
||||
/// travel through `settings` like the rest of this struct.
|
||||
///
|
||||
/// A missing keychain secret reads as `None` — a keychain read failure is
|
||||
/// treated as "nothing to export" for that one entry rather than aborting
|
||||
/// the whole export, matching how the rest of this app degrades a keychain
|
||||
/// error to "absent" (`has_claude_oauth_token`, `has_gateway_api_key`)
|
||||
/// rather than surfacing it as a hard failure.
|
||||
fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSecrets) {
|
||||
let mut settings = current;
|
||||
let web_terminal_access_token = settings.web_terminal.access_token.take();
|
||||
|
||||
let secrets = ExportedSecrets {
|
||||
claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(),
|
||||
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
|
||||
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
|
||||
web_terminal_access_token,
|
||||
};
|
||||
|
||||
(settings, secrets)
|
||||
}
|
||||
|
||||
/// Export the current global settings and secrets to a password-encrypted
|
||||
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
|
||||
/// deliberately distinguishable from one so the frontend shows nothing
|
||||
/// rather than a "failed" toast for a plain cancel.
|
||||
#[tauri::command]
|
||||
pub async fn export_settings(
|
||||
password: String,
|
||||
window: tauri::Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
// `.chars().count()` — Unicode scalar values, not bytes — to stay as
|
||||
// close as this pair of languages allows to the frontend's `.length`
|
||||
// check (UTF-16 code units); the two only diverge on astral-plane
|
||||
// characters, which no reasonable password touches.
|
||||
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||
return Err(format!(
|
||||
"Use a password of at least {} characters.",
|
||||
MIN_PASSWORD_LEN
|
||||
));
|
||||
}
|
||||
|
||||
let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let (settings, secrets) = split_settings_and_secrets(state.settings_store.get());
|
||||
if secrets.is_empty() {
|
||||
log::info!("Exporting settings with no global secrets configured on this machine");
|
||||
}
|
||||
|
||||
let payload = SettingsExportPayload {
|
||||
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
settings,
|
||||
secrets,
|
||||
};
|
||||
|
||||
let plaintext = Zeroizing::new(
|
||||
serde_json::to_vec(&payload)
|
||||
.map_err(|e| format!("Failed to prepare settings for export: {}", e))?,
|
||||
);
|
||||
let encrypted = settings_crypto::encrypt(&plaintext, &password)?;
|
||||
|
||||
std::fs::write(&dest, &encrypted).map_err(|e| format!("Failed to write export file: {}", e))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Open a file picker, decrypt the chosen file with `password`, and return a
|
||||
/// preview (counts and presence flags only — never a secret value) for a
|
||||
/// confirmation UI. `Ok(None)` means the picker was dismissed.
|
||||
///
|
||||
/// Remembers the resolved path *and a hash of the file's ciphertext* in
|
||||
/// `AppState::pending_settings_import` for `apply_settings_import` to check
|
||||
/// against — does **not** remember the decrypted payload itself, so the
|
||||
/// password must be supplied again to actually apply it — seeing the preview
|
||||
/// is not the same as committing to it. The hash exists so it also can't be
|
||||
/// swapped out from under that commitment: `apply_settings_import` refuses to
|
||||
/// proceed if the file on disk no longer matches what was just previewed.
|
||||
#[tauri::command]
|
||||
pub async fn preview_settings_import(
|
||||
password: String,
|
||||
window: tauri::Window,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<SettingsImportPreview>, String> {
|
||||
if password.is_empty() {
|
||||
return Err("A password is required to open a settings export.".to_string());
|
||||
}
|
||||
|
||||
let Some(path) = pick_import_open_path(&window).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
|
||||
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
|
||||
path,
|
||||
ciphertext_hash: hash_ciphertext(&encrypted),
|
||||
});
|
||||
|
||||
Ok(Some(preview))
|
||||
}
|
||||
|
||||
/// Apply the import a prior `preview_settings_import` call resolved a path
|
||||
/// for. Fails if no preview is pending — this is not a general "decrypt and
|
||||
/// apply this file" entry point, deliberately: seeing the preview first is
|
||||
/// required, not just encouraged, since it is the only place a user is told
|
||||
/// what an import is about to touch before it touches it. That requirement
|
||||
/// is only real if the file can't change out from under it, so this also
|
||||
/// refuses to proceed if the file's ciphertext no longer matches the hash
|
||||
/// `preview_settings_import` pinned — a file replaced on disk between the
|
||||
/// two calls (this feature's own threat model is a file shared between
|
||||
/// people, which may sit in a synced or shared directory) must not be able
|
||||
/// to apply silently different content than what the confirmation dialog
|
||||
/// showed.
|
||||
///
|
||||
/// Global settings are replaced wholesale — an import is "restore this
|
||||
/// environment," not a field-by-field merge. Global secrets are handled
|
||||
/// differently and on purpose: **only secrets actually present in the
|
||||
/// import are written**; a secret the export doesn't have is left alone on
|
||||
/// this machine rather than cleared, because an absent secret in the export
|
||||
/// means "the source machine never had this configured," not "delete this
|
||||
/// on import." A user who wants to clear a secret already has dedicated UI
|
||||
/// for that (signing out of shared auth, clearing the gateway key).
|
||||
///
|
||||
/// Order matters here, twice over.
|
||||
///
|
||||
/// First: the imported settings are **validated before any secret is
|
||||
/// written**, using the same checks `update_settings` itself runs
|
||||
/// (`settings_commands::validate_settings_update`). Restoring a secret is
|
||||
/// hard to undo unnoticed — a stale env-var-name rejection or a disallowed
|
||||
/// host path used to be caught only when `update_settings` ran, by which
|
||||
/// point the three keychain secrets below were already overwritten with the
|
||||
/// file's, each with a fresh rotation id, silently flagging every project
|
||||
/// container for recreation — while the error the user saw talked only
|
||||
/// about the rejected setting and said nothing about the credentials that
|
||||
/// had already moved. Failing this check first makes a rejected import
|
||||
/// leave nothing touched, matching what "the import failed" is supposed to
|
||||
/// mean.
|
||||
///
|
||||
/// Second, among the things that *do* get written: secrets are restored
|
||||
/// **before** the settings replace runs (which is what triggers
|
||||
/// `reconcile_gateway`), so a gateway recreation that replace provokes sees
|
||||
/// the final key material rather than racing it — restoring the other way
|
||||
/// round left a real window where the running gateway and the keychain
|
||||
/// briefly disagreed. A gateway *secret* alone (same shape, new key) is
|
||||
/// invisible to `reconcile_gateway`'s shape comparison, so this additionally
|
||||
/// nudges a running gateway container to recreate itself whenever a secret
|
||||
/// this import carried was actually written — otherwise the running
|
||||
/// container keeps serving the old key material indefinitely while every
|
||||
/// project container is handed the new one.
|
||||
///
|
||||
/// A keychain write failing is reported back rather than only logged: an
|
||||
/// import that silently restores two of three secrets but not the third
|
||||
/// must not read as unqualified success.
|
||||
///
|
||||
/// The pending import is only cleared on success. A failure here (rejected
|
||||
/// by the validation above, a stale-file mismatch, or some other error)
|
||||
/// leaves it pending so the frontend can let the user retry `apply` without
|
||||
/// making them pick the file and re-enter the password again — the
|
||||
/// preview's job was confirming *what* to import, not spending the one
|
||||
/// attempt at applying it.
|
||||
#[tauri::command]
|
||||
pub async fn apply_settings_import(
|
||||
password: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SettingsImportOutcome, String> {
|
||||
if password.is_empty() {
|
||||
return Err("A password is required to import settings.".to_string());
|
||||
}
|
||||
|
||||
let pending = state
|
||||
.pending_settings_import
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
|
||||
|
||||
let encrypted = std::fs::read(&pending.path)
|
||||
.map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
if hash_ciphertext(&encrypted) != pending.ciphertext_hash {
|
||||
return Err(
|
||||
"This file changed since you reviewed it — choose it again to see an up-to-date preview."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||
|
||||
let current = state.settings_store.get();
|
||||
|
||||
// The web-terminal token lives inside `AppSettings` itself rather than
|
||||
// the keychain, so "leave an absent secret alone" has to be done by
|
||||
// hand here: carry the destination's current token forward when the
|
||||
// import doesn't have one, instead of letting the wholesale replace
|
||||
// below blank it (every export writes `None` there — see
|
||||
// `split_settings_and_secrets`).
|
||||
let mut settings = payload.settings;
|
||||
settings.web_terminal.access_token = non_blank(payload.secrets.web_terminal_access_token)
|
||||
.or_else(|| current.web_terminal.access_token.clone());
|
||||
|
||||
crate::commands::settings_commands::validate_settings_update(¤t, &settings)?;
|
||||
|
||||
let mut secret_restore_warnings = Vec::new();
|
||||
let mut gateway_secret_changed = false;
|
||||
|
||||
if let Some(token) = non_blank(payload.secrets.claude_oauth_token) {
|
||||
if let Err(e) = secure::store_claude_oauth_token(&token) {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the shared Claude login: {}",
|
||||
e
|
||||
);
|
||||
secret_restore_warnings
|
||||
.push(format!("Could not restore your shared Claude login: {}", e));
|
||||
}
|
||||
}
|
||||
if let Some(key) = non_blank(payload.secrets.gateway_api_key) {
|
||||
match secure::store_gateway_api_key(&key) {
|
||||
Ok(()) => gateway_secret_changed = true,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the gateway provider API key: {}",
|
||||
e
|
||||
);
|
||||
secret_restore_warnings.push(format!(
|
||||
"Could not restore the gateway provider API key: {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(key) = non_blank(payload.secrets.gateway_master_key) {
|
||||
match secure::store_gateway_master_key(&key) {
|
||||
Ok(()) => gateway_secret_changed = true,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Settings import: could not restore the gateway master key: {}",
|
||||
e
|
||||
);
|
||||
secret_restore_warnings
|
||||
.push(format!("Could not restore the gateway master key: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let saved =
|
||||
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
|
||||
|
||||
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
|
||||
// *shape* — port, provider, base URL, models — because that's what's
|
||||
// rendered into the container's config. A secret changing with the shape
|
||||
// held constant is invisible to it, so a running gateway container would
|
||||
// otherwise keep serving the old key material forever after an import
|
||||
// that restored a new one, while `docker::gateway`'s own fingerprint
|
||||
// (which does include the secret rotation id) means the *next* unrelated
|
||||
// settings save would suddenly and confusingly recreate it instead.
|
||||
if gateway_secret_changed && saved.gateway.enabled {
|
||||
match crate::docker::gateway::gateway_container_presence().await {
|
||||
Ok((true, true)) => {
|
||||
if let Err(e) = crate::docker::gateway::ensure_gateway_running(&saved.gateway).await
|
||||
{
|
||||
log::error!(
|
||||
"Settings import: could not apply the restored gateway credentials to the running gateway container: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::debug!("Settings import: gateway reconcile skipped ({})", e),
|
||||
}
|
||||
}
|
||||
|
||||
state.pending_settings_import.lock().await.take();
|
||||
|
||||
Ok(SettingsImportOutcome {
|
||||
settings: saved,
|
||||
secret_restore_warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<String>) -> Option<String> {
|
||||
value.filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Only the field `read_and_decrypt` needs before deciding whether the rest
|
||||
/// of the payload is even worth attempting to parse.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct FormatVersionProbe {
|
||||
format_version: u32,
|
||||
}
|
||||
|
||||
/// Read and decrypt an export file at `path`, then parse it — see
|
||||
/// `read_and_decrypt_bytes` for why the format-version check runs before the
|
||||
/// full parse. Every real caller already has the file's bytes in hand by the
|
||||
/// time it needs this (`preview_settings_import`/`apply_settings_import`
|
||||
/// both hash the ciphertext first) and calls `read_and_decrypt_bytes`
|
||||
/// directly to avoid reading the file twice; this path-based wrapper only
|
||||
/// exists now for tests that don't need that.
|
||||
#[cfg(test)]
|
||||
fn read_and_decrypt(path: &Path, password: &str) -> Result<SettingsExportPayload, String> {
|
||||
let encrypted =
|
||||
std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||
read_and_decrypt_bytes(&encrypted, password)
|
||||
}
|
||||
|
||||
/// Decrypt and parse an already-read export file's bytes, checking the
|
||||
/// format version **before** attempting to deserialize the full payload.
|
||||
///
|
||||
/// That ordering is not just tidiness: a version bump that isn't
|
||||
/// deserialize-compatible (a field's type changes, not just a new
|
||||
/// `#[serde(default)]`-covered one) is exactly the case this check exists
|
||||
/// for, and parsing the full struct first would fail on the shape mismatch
|
||||
/// before the version check ever ran, surfacing a raw parse error instead
|
||||
/// of "update Triple-C" — and, more seriously, `serde_json`'s type-mismatch
|
||||
/// errors quote the offending value inline. This file is not attacker
|
||||
/// content in the usual sense (it must still decrypt under the right
|
||||
/// password), but the plaintext it decrypts to can hold a live credential,
|
||||
/// so neither error path below ever interpolates what `serde_json`
|
||||
/// actually says — only a fixed, generic message.
|
||||
fn read_and_decrypt_bytes(encrypted: &[u8], password: &str) -> Result<SettingsExportPayload, String> {
|
||||
let plaintext = settings_crypto::decrypt(encrypted, password)?;
|
||||
|
||||
let probe: FormatVersionProbe = serde_json::from_slice(&plaintext)
|
||||
.map_err(|_| "This file doesn't look like a valid settings export.".to_string())?;
|
||||
if probe.format_version > SETTINGS_EXPORT_FORMAT_VERSION {
|
||||
return Err(format!(
|
||||
"This export was made by a newer version of Triple-C (format {}, this app supports up to {}). \
|
||||
Update Triple-C before importing it.",
|
||||
probe.format_version, SETTINGS_EXPORT_FORMAT_VERSION
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::from_slice(&plaintext).map_err(|_| {
|
||||
"This file doesn't look like a valid settings export (unexpected shape).".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_blank_treats_whitespace_only_as_absent() {
|
||||
assert_eq!(non_blank(Some(" ".to_string())), None);
|
||||
assert_eq!(non_blank(Some("".to_string())), None);
|
||||
assert_eq!(non_blank(None), None);
|
||||
assert_eq!(non_blank(Some(" a ".to_string())), Some(" a ".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ciphertext_hashing_is_deterministic_and_tamper_sensitive() {
|
||||
// What `apply_settings_import` compares against the pinned hash from
|
||||
// `preview_settings_import` to detect a file swapped out from under a
|
||||
// pending import — this only defends anything if identical bytes
|
||||
// always hash identically and any change to those bytes changes the
|
||||
// hash.
|
||||
let bytes = b"pretend this is an encrypted export file";
|
||||
assert_eq!(hash_ciphertext(bytes), hash_ciphertext(bytes));
|
||||
|
||||
let mut tampered = bytes.to_vec();
|
||||
tampered[0] ^= 0xFF;
|
||||
assert_ne!(hash_ciphertext(bytes), hash_ciphertext(&tampered));
|
||||
}
|
||||
|
||||
fn write_export(
|
||||
dir: &std::path::Path,
|
||||
name: &str,
|
||||
payload: &SettingsExportPayload,
|
||||
password: &str,
|
||||
) -> PathBuf {
|
||||
write_raw_export(dir, name, &serde_json::to_value(payload).unwrap(), password)
|
||||
}
|
||||
|
||||
/// Like `write_export`, but takes an arbitrary `serde_json::Value` rather
|
||||
/// than a real `SettingsExportPayload` — for fixtures that are
|
||||
/// deliberately not shape-compatible, which the typed helper above can't
|
||||
/// produce at all.
|
||||
fn write_raw_export(
|
||||
dir: &std::path::Path,
|
||||
name: &str,
|
||||
value: &serde_json::Value,
|
||||
password: &str,
|
||||
) -> PathBuf {
|
||||
let plaintext = serde_json::to_vec(value).unwrap();
|
||||
let encrypted = settings_crypto::encrypt(&plaintext, password).unwrap();
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, &encrypted).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_settings_moves_the_web_terminal_token_out_rather_than_copying_it() {
|
||||
let mut settings = AppSettings::default();
|
||||
settings.web_terminal.access_token = Some("super-secret-token".to_string());
|
||||
|
||||
let (settings, secrets) = split_settings_and_secrets(settings);
|
||||
|
||||
assert_eq!(settings.web_terminal.access_token, None);
|
||||
assert_eq!(
|
||||
secrets.web_terminal_access_token,
|
||||
Some("super-secret-token".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_settings_with_no_token_leaves_it_absent_on_both_sides() {
|
||||
let (settings, secrets) = split_settings_and_secrets(AppSettings::default());
|
||||
|
||||
assert_eq!(settings.web_terminal.access_token, None);
|
||||
assert_eq!(secrets.web_terminal_access_token, None);
|
||||
}
|
||||
|
||||
fn sample_payload(format_version: u32) -> SettingsExportPayload {
|
||||
SettingsExportPayload {
|
||||
format_version,
|
||||
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
||||
app_version: "0.4.14".to_string(),
|
||||
settings: AppSettings::default(),
|
||||
secrets: ExportedSecrets::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-settings-export-test-{}-{}",
|
||||
name,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_a_newer_format_is_refused_before_the_full_shape_is_parsed() {
|
||||
// Shape-incompatible with the *current* `SettingsExportPayload` (a
|
||||
// future version could easily have changed `settings` from an object
|
||||
// to something else) as well as newer — so this only passes under
|
||||
// the probe-first ordering. Parsing the full struct first (the old
|
||||
// behavior) would fail on the shape mismatch and never reach the
|
||||
// version check, producing the "unexpected shape" message instead of
|
||||
// "newer version" / "Update Triple-C".
|
||||
let dir = temp_dir("newer-format");
|
||||
let path = write_raw_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&serde_json::json!({
|
||||
"format_version": SETTINGS_EXPORT_FORMAT_VERSION + 1,
|
||||
"exported_at": "2026-08-27T00:00:00Z",
|
||||
"app_version": "9.9.9",
|
||||
"settings": "this-app-version-stores-settings-differently",
|
||||
"secrets": {},
|
||||
}),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||
assert!(err.contains("newer version"), "unexpected message: {}", err);
|
||||
assert!(err.contains("Update Triple-C"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_at_the_current_format_is_accepted() {
|
||||
let dir = temp_dir("current-format");
|
||||
let path = write_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let payload = read_and_decrypt(&path, "correct password").unwrap();
|
||||
assert_eq!(payload.format_version, SETTINGS_EXPORT_FORMAT_VERSION);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_payload_produces_a_generic_error_not_a_raw_serde_message() {
|
||||
// A `format_version` the probe accepts, but a `settings` field of
|
||||
// the wrong *type* rather than just a missing field — this is what
|
||||
// makes `serde_json` produce an "invalid type: string `...`, expected
|
||||
// struct AppSettings" error that quotes the offending value
|
||||
// verbatim. That value here stands in for plaintext that, in a real
|
||||
// export, could be a live credential — the assertion below is only
|
||||
// meaningful against a fixture that actually exercises serde's
|
||||
// value-quoting behavior, which a merely-missing-field fixture does
|
||||
// not.
|
||||
let dir = temp_dir("malformed");
|
||||
let path = write_raw_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&serde_json::json!({
|
||||
"format_version": SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
"exported_at": "2026-08-27T00:00:00Z",
|
||||
"app_version": "0.4.14",
|
||||
"settings": "NOT-A-REAL-CREDENTIAL-abc123",
|
||||
"secrets": {},
|
||||
}),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||
assert!(
|
||||
!err.contains("NOT-A-REAL-CREDENTIAL-abc123"),
|
||||
"leaked plaintext into the error: {}",
|
||||
err
|
||||
);
|
||||
assert!(err.contains("doesn't look like a valid settings export"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wrong_password_is_reported_without_a_version_check_ever_running() {
|
||||
let dir = temp_dir("wrong-password");
|
||||
let path = write_export(
|
||||
&dir,
|
||||
"export.triplec",
|
||||
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||
"correct password",
|
||||
);
|
||||
|
||||
let err = read_and_decrypt(&path, "wrong password").unwrap_err();
|
||||
assert!(
|
||||
err.contains("Wrong password"),
|
||||
"unexpected message: {}",
|
||||
err
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -196,31 +196,64 @@ pub async fn upload_host_file_to_terminal(
|
||||
host_path: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
// The drop target is a host path chosen by the webview, not by the OS drag
|
||||
// itself, so it goes through `file_commands`' host-read policy: absolute,
|
||||
// no traversal, and nothing whose path passes through a hidden directory
|
||||
// (`~/.ssh`, `~/.aws`, `~/.local/bin`) or a system location — applied to
|
||||
// the path with its symlinks already resolved, so a visible directory that
|
||||
// *leads* to one of those is refused too. What comes back is that resolved
|
||||
// path, and it is what gets opened. Four commands touch a host path now,
|
||||
// but only two take it *over IPC*: this one and `download_container_backup`.
|
||||
// The Files pane's `download_container_file` and `upload_files_to_container`
|
||||
// open their dialog from Rust instead, so for them the policy above is
|
||||
// defence in depth and for these two it is the boundary itself.
|
||||
// The name is taken from the path the user actually dropped, *before*
|
||||
// resolution. Deriving it from the resolved path renames the file behind
|
||||
// the user's back: dropping `~/Downloads/latest.log`, where `latest.log` is
|
||||
// a symlink, would land it in the container as `2026-08-23.log`.
|
||||
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
|
||||
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
|
||||
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
|
||||
let meta = tokio::fs::metadata(&host_path)
|
||||
.await
|
||||
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||
if meta.is_dir() {
|
||||
return Err(format!("{} is a directory — drop individual files", host_path));
|
||||
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
|
||||
// regular file, reports `len() == 0`, and passes both the directory check
|
||||
// and the size cap below — and `std::fs::File::open` on one blocks forever
|
||||
// with no writer, with no timeout anywhere on this path. The upload then
|
||||
// never returns, the toast sticks on "Adding N files…" for the session and
|
||||
// the rest of the batch is abandoned. Sockets and device nodes are the same
|
||||
// shape. This is one of two routes for getting a host file into a
|
||||
// container (the Files pane's upload is the other), so it is the wrong
|
||||
// place to be clever.
|
||||
if !meta.is_file() {
|
||||
return Err(if meta.is_dir() {
|
||||
format!("{} is a directory — drop individual files", host_path)
|
||||
} else {
|
||||
format!(
|
||||
"{} is not a regular file — only ordinary files can be dropped into a terminal",
|
||||
host_path
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Guard against ballooning host RAM: the file is packed into an in-memory
|
||||
// tar before upload, so cap the size of a dropped file.
|
||||
const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB
|
||||
// tar before upload, so cap the size of a dropped file. The ceiling lives
|
||||
// with the code that does the reading, which re-applies it to the open
|
||||
// descriptor — this check is here only so the refusal reads like a sentence
|
||||
// instead of arriving after a 300 MB read.
|
||||
use crate::docker::exec::MAX_DROP_BYTES;
|
||||
if meta.len() > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
|
||||
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
||||
meta.len() as f64 / (1024.0 * 1024.0),
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
|
||||
let base = std::path::Path::new(&host_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "dropped-file".to_string());
|
||||
|
||||
|
||||
// Ensure the destination directory exists rather than relying on Docker's
|
||||
// archive extractor to create the parent for the uploaded tar entry.
|
||||
@@ -231,7 +264,13 @@ pub async fn upload_host_file_to_terminal(
|
||||
.await?;
|
||||
|
||||
let file_name = format!("triple-c-drops/{}", base);
|
||||
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
|
||||
crate::docker::exec::upload_host_file_to_container(
|
||||
&container_id,
|
||||
&host_path,
|
||||
"/tmp",
|
||||
&file_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -283,3 +322,37 @@ pub async fn stop_audio_bridge(
|
||||
state.exec_manager.close_session(&audio_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A dropped file must be named the way the *user* named it.
|
||||
///
|
||||
/// The bug this pins: `upload_host_file_to_terminal` derived the tar entry
|
||||
/// name from the path *after* symlink resolution, so dropping
|
||||
/// `~/Downloads/latest.log` — where `latest.log` is a symlink to
|
||||
/// `2026-08-23.log` — silently landed the file in the container under the
|
||||
/// target's name. Nothing errored; the user just got a name they never
|
||||
/// typed.
|
||||
///
|
||||
/// This asserts the shared helper's contract from the terminal side: the
|
||||
/// answer comes from the spelling, and a path that does not name a file is
|
||||
/// refused rather than silently substituted (it used to fall back to
|
||||
/// `"dropped-file"`).
|
||||
#[test]
|
||||
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||
use crate::commands::file_commands::host_upload_name;
|
||||
|
||||
assert_eq!(
|
||||
host_upload_name("/home/u/Downloads/latest.log").unwrap(),
|
||||
"latest.log"
|
||||
);
|
||||
assert!(
|
||||
host_upload_name("/home/u/Downloads/").is_err(),
|
||||
"a directory is not a file to drop"
|
||||
);
|
||||
assert!(
|
||||
host_upload_name("/home/u/..").is_err(),
|
||||
"the name becomes a tar entry, a container path and an argv element"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,37 @@ const REGISTRY_API_BASE: &str =
|
||||
const GHCR_TOKEN_URL: &str =
|
||||
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
||||
|
||||
/// The build-time preview suffix, if one was baked in and isn't blank.
|
||||
///
|
||||
/// The bundle version itself (`tauri.conf.json`, `Cargo.toml`, `package.json`)
|
||||
/// is never given a `-preview.<sha>` suffix — `build-app-preview.yml` strips
|
||||
/// it before patching those files, because the Windows MSI's `ProductVersion`
|
||||
/// is a fixed-width numeric field with no room for one, and nothing here can
|
||||
/// verify a change to that without an actual Windows build. `TRIPLE_C_BUILD_SUFFIX`
|
||||
/// is the workaround: set as a build-time env var in the preview workflow
|
||||
/// only, so `option_env!` bakes it into the binary without the bundle version
|
||||
/// ever seeing it. A production build sets nothing, so `option_env!` reads
|
||||
/// `None` here — see triple-c#32.
|
||||
///
|
||||
/// The single source of truth for "is this a preview build": both
|
||||
/// `get_app_version()` (what the About panel shows) and `check_for_updates()`
|
||||
/// (whether a same-numbered release counts as an update — see `pick_update`)
|
||||
/// read this rather than each calling `option_env!` themselves, so the two
|
||||
/// can never silently disagree about which build this is.
|
||||
fn preview_build_suffix() -> Option<&'static str> {
|
||||
option_env!("TRIPLE_C_BUILD_SUFFIX").filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn format_app_version(base: &str, build_suffix: Option<&str>) -> String {
|
||||
match build_suffix {
|
||||
Some(suffix) if !suffix.is_empty() => format!("{}-{}", base, suffix),
|
||||
_ => base.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -51,30 +79,20 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
&[".AppImage", ".deb", ".rpm"]
|
||||
};
|
||||
|
||||
// Filter releases that have at least one asset matching the current platform
|
||||
let platform_releases: Vec<&GitHubRelease> = releases
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
r.assets.iter().any(|a| {
|
||||
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// `current_version` above is always the bare, stripped `CARGO_PKG_VERSION`
|
||||
// — the preview workflow patches `Cargo.toml` with that before compiling,
|
||||
// never the `-preview.<sha>`-suffixed one `get_app_version()` reports —
|
||||
// so a preview build and the release it precedes compile to the identical
|
||||
// numeric tuple by construction (see `build-app-preview.yml`'s "highest
|
||||
// tag used, +1" computation). A strict `>` therefore never fires for the
|
||||
// one release a preview most needs to be offered. `is_preview_build`
|
||||
// relaxes that one comparison to `>=` so "there is a real release at my
|
||||
// own number" reads as an update, without touching the production case
|
||||
// — see `pick_update`.
|
||||
let is_preview_build = preview_build_suffix().is_some();
|
||||
|
||||
// Find the latest release with a higher semver version
|
||||
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
|
||||
for release in &platform_releases {
|
||||
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
|
||||
if ver > current_semver {
|
||||
if best.is_none() || ver > best.unwrap().1 {
|
||||
best = Some((release, ver));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match best {
|
||||
Some((release, _)) => {
|
||||
match pick_update(&releases, current_semver, platform_extensions, is_preview_build) {
|
||||
Some(release) => {
|
||||
// Only include assets matching the current platform
|
||||
let assets = release
|
||||
.assets
|
||||
@@ -105,6 +123,51 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the newest available update out of a release list, or `None` if
|
||||
/// nothing beats `current_semver`. Pure and synchronous — split out of
|
||||
/// `check_for_updates` so the prerelease/platform/version filtering can be
|
||||
/// tested without a live HTTP call.
|
||||
///
|
||||
/// Three filters, all of which must pass: not a prerelease (see the long
|
||||
/// comment on `GitHubRelease::prerelease`), at least one asset for this
|
||||
/// platform, and a tag that parses as semver *and* beats what is running. A
|
||||
/// tag that does not parse — `preview-<sha>` (the shape
|
||||
/// `build-app-preview.yml` actually creates release tags with), most
|
||||
/// realistically — is skipped rather than erroring, the same as it always
|
||||
/// has been; nothing here changes what an update tag is expected to look
|
||||
/// like, only what channel it is allowed to come from.
|
||||
///
|
||||
/// `is_preview_build` relaxes "beats" from `>` to `>=`. A preview build's
|
||||
/// `current_semver` is the bare number it was compiled with, which is by
|
||||
/// construction identical to the release it precedes — see the comment at
|
||||
/// `check_for_updates`'s call site — so a strict `>` would never fire for
|
||||
/// exactly the release a preview install most needs to be told about.
|
||||
fn pick_update<'a>(
|
||||
releases: &'a [GitHubRelease],
|
||||
current_semver: (u32, u32, u32),
|
||||
platform_extensions: &[&str],
|
||||
is_preview_build: bool,
|
||||
) -> Option<&'a GitHubRelease> {
|
||||
releases
|
||||
.iter()
|
||||
.filter(|r| !r.prerelease)
|
||||
.filter(|r| {
|
||||
r.assets
|
||||
.iter()
|
||||
.any(|a| platform_extensions.iter().any(|ext| a.name.ends_with(ext)))
|
||||
})
|
||||
.filter_map(|r| parse_semver_from_tag(&r.tag_name).map(|ver| (r, ver)))
|
||||
.filter(|(_, ver)| {
|
||||
if is_preview_build {
|
||||
*ver >= current_semver
|
||||
} else {
|
||||
*ver > current_semver
|
||||
}
|
||||
})
|
||||
.max_by_key(|(_, ver)| *ver)
|
||||
.map(|(r, _)| r)
|
||||
}
|
||||
|
||||
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||
let clean = version.trim_start_matches('v');
|
||||
@@ -131,6 +194,120 @@ fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||
Some(format!("{}.{}.{}", major, minor, patch))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::GitHubAsset;
|
||||
|
||||
// ── format_app_version ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_production_build_reports_the_bare_version() {
|
||||
assert_eq!(format_app_version("0.4.12", None), "0.4.12");
|
||||
// An empty env var (set but blank) must not print a trailing dash.
|
||||
assert_eq!(format_app_version("0.4.12", Some("")), "0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_build_reports_its_suffix() {
|
||||
assert_eq!(
|
||||
format_app_version("0.4.12", Some("preview.a1b2c3d")),
|
||||
"0.4.12-preview.a1b2c3d"
|
||||
);
|
||||
}
|
||||
|
||||
// ── pick_update ──────────────────────────────────────────────────────
|
||||
|
||||
fn release(tag: &str, prerelease: bool, asset_names: &[&str]) -> GitHubRelease {
|
||||
GitHubRelease {
|
||||
tag_name: tag.to_string(),
|
||||
html_url: format!("https://example.invalid/{}", tag),
|
||||
body: String::new(),
|
||||
assets: asset_names
|
||||
.iter()
|
||||
.map(|name| GitHubAsset {
|
||||
name: name.to_string(),
|
||||
browser_download_url: String::new(),
|
||||
size: 0,
|
||||
})
|
||||
.collect(),
|
||||
published_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
prerelease,
|
||||
}
|
||||
}
|
||||
|
||||
const LINUX_EXTENSIONS: &[&str] = &[".AppImage", ".deb", ".rpm"];
|
||||
|
||||
#[test]
|
||||
fn a_prerelease_is_never_offered_even_if_its_tag_would_otherwise_win() {
|
||||
let releases = vec![release("v9.9.9", true, &["app-9.9.9.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_with_no_asset_for_this_platform_is_skipped() {
|
||||
let releases = vec![release("v0.4.12", false, &["app-0.4.12.msi"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_that_is_not_newer_is_not_offered() {
|
||||
let releases = vec![release("v0.4.10", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_or_unparseable_release_is_skipped_not_fatal() {
|
||||
// A `-preview.<sha>` tag is exactly the shape this must not choke on
|
||||
// or mistake for an update — it simply never parses as a bare semver.
|
||||
let releases = vec![
|
||||
release("preview-a1b2c3d", false, &["app.AppImage"]),
|
||||
release("v0.4.12", false, &["app.AppImage"]),
|
||||
];
|
||||
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_highest_qualifying_version_wins_not_the_first_or_last_in_the_list() {
|
||||
let releases = vec![
|
||||
release("v0.4.11", false, &["app.AppImage"]),
|
||||
release("v0.4.13", false, &["app.AppImage"]),
|
||||
release("v0.4.12", false, &["app.AppImage"]),
|
||||
];
|
||||
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.13");
|
||||
}
|
||||
|
||||
// ── is_preview_build (>= instead of >) ─────────────────────────────────
|
||||
|
||||
/// The exact scenario triple-c#32 was filed to fix: a preview compiled as
|
||||
/// `0.4.12-preview.<sha>` (bare `CARGO_PKG_VERSION` "0.4.12") must be
|
||||
/// offered the `v0.4.12` release that follows it, even though the two
|
||||
/// compute to the identical numeric tuple.
|
||||
#[test]
|
||||
fn a_preview_build_is_offered_the_release_it_precedes() {
|
||||
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||
let best = pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).unwrap();
|
||||
assert_eq!(best.tag_name, "v0.4.12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_build_is_not_offered_an_older_release() {
|
||||
let releases = vec![release("v0.4.11", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_production_build_still_requires_strictly_newer() {
|
||||
// A production build must never treat "equal" as an update — that
|
||||
// would perpetually re-offer the version already running.
|
||||
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a newer container image is available in the registry.
|
||||
///
|
||||
/// Compares the local image digest with the remote registry digest using the
|
||||
|
||||
@@ -301,21 +301,10 @@ impl ExecSessionManager {
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Build a tar archive in memory containing the file
|
||||
let mut tar_buf = Vec::new();
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, file_name, data)
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
// Owned by the container user, stamped now: a default tar header would
|
||||
// land it as root:root/1970 and Claude Code could not rewrite it.
|
||||
let (uid, gid) = container_user_ids(container_id).await;
|
||||
let tar_buf = build_single_file_tar(file_name, data, 0o644, uid, gid, now_epoch_secs())?;
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
@@ -333,40 +322,85 @@ impl ExecSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
|
||||
/// Ceiling on one host file packed into a container upload.
|
||||
///
|
||||
/// The file goes through host RAM twice — once as bytes, once inside the tar —
|
||||
/// so this is a memory bound, and it is checked against the *descriptor* that
|
||||
/// was opened rather than a `metadata` call that described whatever the path
|
||||
/// meant a moment earlier.
|
||||
pub const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Upload a host file into `dest_dir` under `dest_name`. The file is
|
||||
/// read and packed into the tar inside a blocking task, so the synchronous IO
|
||||
/// runs off the async worker. The tar's declared entry size is taken from the
|
||||
/// bytes actually read (not a separate `stat`), so a file changing size between
|
||||
/// a size check and the read can't desync the header and corrupt the archive.
|
||||
/// Returns the in-container path (`/tmp/<dest_name>`).
|
||||
/// Returns the in-container path (`<dest_dir>/<dest_name>`).
|
||||
///
|
||||
/// `dest_dir` must already exist and must already have been checked by the
|
||||
/// caller — Docker's archive extractor writes wherever it is pointed. The two
|
||||
/// callers both do that first, by different routes because they are answering
|
||||
/// different questions: the terminal drop stages into a fixed `/tmp` path it
|
||||
/// creates itself, and the Files pane passes the directory the user is looking
|
||||
/// at, which `file_commands::resolve_container_dir` has already confirmed
|
||||
/// resolves inside `CONTAINER_WRITE_ROOTS`.
|
||||
pub async fn upload_host_file_to_container(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
) -> Result<String, String> {
|
||||
let ids = container_user_ids(container_id).await;
|
||||
upload_host_file_with_ids(container_id, host_path, dest_dir, dest_name, ids).await
|
||||
}
|
||||
|
||||
/// [`upload_host_file_to_container`] for a caller that already knows the
|
||||
/// container user's ids.
|
||||
///
|
||||
/// `container_user_ids` is a `docker exec`, and the Files pane's upload is a
|
||||
/// *selection* — one dialog can hand back twenty files. Resolving the ids per
|
||||
/// file made twenty extra round trips to answer the same `id -u` twenty times,
|
||||
/// which is seconds of latency for a fact that cannot change inside one
|
||||
/// container's lifetime. So the loop resolves once and passes the answer in.
|
||||
/// The wrapper above keeps the single-file callers unchanged.
|
||||
pub async fn upload_host_file_with_ids(
|
||||
container_id: &str,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
(uid, gid): (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
let host_path = host_path.to_string();
|
||||
let dest_name = dest_name.to_string();
|
||||
let dest_for_blk = dest_name.clone();
|
||||
let mtime = now_epoch_secs();
|
||||
|
||||
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
let data = std::fs::read(&host_path)
|
||||
// The caller resolved this path (`resolve_host_read_path`); opening it
|
||||
// is a second trip through the same directories, so the descriptor is
|
||||
// checked against the path that was validated before its bytes are
|
||||
// packed into anything. Two paths reach here: the terminal's drop
|
||||
// target, and the Files pane's upload via `upload_host_file_with_ids`.
|
||||
// Between them they are how host bytes enter a container.
|
||||
let file = std::fs::File::open(&host_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
// Size comes from the bytes in hand, so header and payload can't disagree.
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, &dest_for_blk, &data[..])
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
crate::commands::file_commands::verify_opened_path(
|
||||
&file,
|
||||
std::path::Path::new(&host_path),
|
||||
)?;
|
||||
let mut data = Vec::new();
|
||||
std::io::Read::read_to_end(
|
||||
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
|
||||
&mut data,
|
||||
)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
if data.len() as u64 > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to upload (limit {} MB)",
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
Ok(tar_buf)
|
||||
build_single_file_tar(&dest_for_blk, &data[..], 0o644, uid, gid, mtime)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
||||
@@ -376,7 +410,7 @@ pub async fn upload_host_file_to_container(
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: "/tmp".to_string(),
|
||||
path: dest_dir.to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
@@ -384,7 +418,17 @@ pub async fn upload_host_file_to_container(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(format!("/tmp/{}", dest_name))
|
||||
Ok(container_join(dest_dir, &dest_name))
|
||||
}
|
||||
|
||||
/// Join a container directory to a name that may itself carry separators.
|
||||
///
|
||||
/// Only the *reported* path — the bytes have already landed by the time this is
|
||||
/// called — but that path is what the terminal echoes and what the Files pane
|
||||
/// puts in its toast, so `/tmp//x` reading back as a different file than `/tmp/x`
|
||||
/// is worth the four lines. `"/"` trims to `""` and yields `/x`.
|
||||
fn container_join(dir: &str, name: &str) -> String {
|
||||
format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
/// Write `data` into the container at `<dest_dir>/<file_name>` with `mode`.
|
||||
@@ -402,20 +446,10 @@ pub async fn upload_bytes_to_container(
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(mode);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, file_name, data)
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
// Root-owned on purpose: the only caller is migration, whose `tar -T` list
|
||||
// is read back as root. The mtime still gets stamped so the file doesn't
|
||||
// read as 1970.
|
||||
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
@@ -432,6 +466,74 @@ pub async fn upload_bytes_to_container(
|
||||
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
|
||||
}
|
||||
|
||||
/// Build an in-memory tar archive holding a single regular file.
|
||||
///
|
||||
/// The uid/gid/mtime arguments exist because `tar::Header::new_gnu()` zeroes
|
||||
/// them and Docker's archive extractor honours the header verbatim: a header
|
||||
/// left at the defaults lands the file inside the container as `root:root`
|
||||
/// with a 1970-01-01 mtime — not writable by `claude`, and confusing in any
|
||||
/// listing. Callers that upload on a user's behalf should pass the container
|
||||
/// user's ids from [`container_user_ids`].
|
||||
pub fn build_single_file_tar(
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
uid: u64,
|
||||
gid: u64,
|
||||
mtime: u64,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
// Size comes from the bytes in hand, so header and payload can't disagree.
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(mode);
|
||||
header.set_uid(uid);
|
||||
header.set_gid(gid);
|
||||
header.set_mtime(mtime);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, file_name, data)
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
Ok(tar_buf)
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch, for a tar header mtime.
|
||||
pub fn now_epoch_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The numeric uid/gid of the container's `claude` user.
|
||||
///
|
||||
/// It is not a constant: `entrypoint.sh` remaps `claude` to the *host* user's
|
||||
/// ids on Unix so bind-mounted project files stay writable, and deliberately
|
||||
/// does not on Windows. So the only reliable answer comes from asking the
|
||||
/// container. Falls back to 1000:1000 (the image's build-time ids) if the exec
|
||||
/// fails, which is strictly better than the 0:0 a default tar header carries.
|
||||
pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
|
||||
let out = exec_oneshot_limited(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()],
|
||||
256,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut ids = out.lines().filter_map(|l| l.trim().parse::<u64>().ok());
|
||||
match (ids.next(), ids.next()) {
|
||||
(Some(uid), Some(gid)) => (uid, gid),
|
||||
_ => (1000, 1000),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ceiling on how much container output a one-shot exec will buffer into the
|
||||
/// host process.
|
||||
///
|
||||
@@ -450,14 +552,31 @@ pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024;
|
||||
/// past anything genuine, far short of a problem.
|
||||
pub const PROC_NET_OUTPUT_LIMIT: usize = 1024 * 1024;
|
||||
|
||||
/// Append to `buf` while it stays inside `limit`. Returns `false` once the
|
||||
/// limit is exceeded, at which point the caller must stop reading.
|
||||
fn push_capped(buf: &mut String, chunk: &str, limit: usize) -> bool {
|
||||
/// Marker on the "that command printed more than this will buffer" refusal.
|
||||
///
|
||||
/// The byte count on its own is a fact about the transport, not about what the
|
||||
/// user did — "Command output exceeded 8388608 bytes" is not a sentence anybody
|
||||
/// can act on. A caller that knows what it was reading can recognise this and
|
||||
/// say the useful thing instead; see `list_container_files`, where the real
|
||||
/// cause is a directory with more entries than the panel can render.
|
||||
pub const OUTPUT_LIMIT_MARKER: &str = "OUTPUT_LIMIT";
|
||||
|
||||
/// Append to `buf` while it stays inside `limit`, returning the range the chunk
|
||||
/// now occupies. `None` once the limit is exceeded, at which point the caller
|
||||
/// must stop reading — and nothing is appended, so a caller that ignored the
|
||||
/// answer cannot parse a half-read document.
|
||||
///
|
||||
/// Bytes rather than `str` on purpose: Docker frames a stream wherever it
|
||||
/// likes, so a chunk boundary can fall inside a UTF-8 sequence. Decoding each
|
||||
/// chunk on its own turned that into two replacement characters in the middle
|
||||
/// of a filename; the decode happens once, at the end, over the whole buffer.
|
||||
fn push_capped(buf: &mut Vec<u8>, chunk: &[u8], limit: usize) -> Option<(usize, usize)> {
|
||||
if buf.len() + chunk.len() > limit {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
buf.push_str(chunk);
|
||||
true
|
||||
let start = buf.len();
|
||||
buf.extend_from_slice(chunk);
|
||||
Some((start, buf.len()))
|
||||
}
|
||||
|
||||
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||
@@ -521,6 +640,65 @@ pub async fn exec_oneshot_as(
|
||||
exec_oneshot_inner(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
||||
}
|
||||
|
||||
/// What a one-shot exec printed, with the two streams still tellable apart.
|
||||
///
|
||||
/// `combined` is stdout and stderr interleaved in arrival order — the shape
|
||||
/// every existing caller reads, and the right one for surfacing "why did that
|
||||
/// fail". `stdout_ranges` indexes the parts of it that came from stdout, so a
|
||||
/// caller that is *parsing* output can have just that without the buffer being
|
||||
/// held twice.
|
||||
struct OneshotOutput {
|
||||
combined: Vec<u8>,
|
||||
stdout_ranges: Vec<(usize, usize)>,
|
||||
exit_code: i64,
|
||||
}
|
||||
|
||||
impl OneshotOutput {
|
||||
/// Everything the command printed, in the order it printed it.
|
||||
fn text(&self) -> String {
|
||||
String::from_utf8_lossy(&self.combined).into_owned()
|
||||
}
|
||||
|
||||
/// stdout alone — for callers that parse it, where a diagnostic spliced in
|
||||
/// mid-record is a parse error at best.
|
||||
fn stdout(&self) -> String {
|
||||
let mut out = Vec::with_capacity(self.combined.len());
|
||||
for (start, end) in &self.stdout_ranges {
|
||||
out.extend_from_slice(&self.combined[*start..*end]);
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// stderr alone — the complement of [`Self::stdout`], i.e. the diagnostics.
|
||||
fn stderr(&self) -> String {
|
||||
let mut out = Vec::with_capacity(self.combined.len());
|
||||
let mut cursor = 0usize;
|
||||
for (start, end) in &self.stdout_ranges {
|
||||
out.extend_from_slice(&self.combined[cursor..*start]);
|
||||
cursor = *end;
|
||||
}
|
||||
out.extend_from_slice(&self.combined[cursor..]);
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`exec_oneshot_as`] with the two streams kept apart, for callers that parse
|
||||
/// stdout.
|
||||
///
|
||||
/// `find`'s own diagnostics ("Permission denied") used to arrive inside the
|
||||
/// records its `-printf` was emitting. GNU `find` escapes tabs and newlines in
|
||||
/// those messages, so the listing parser held — but "the parser holds" is not
|
||||
/// the same as "the input is trustworthy", and the fix costs one enum match.
|
||||
pub async fn exec_oneshot_streams_as(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, String, i64), String> {
|
||||
let out = exec_oneshot_raw(container_id, user, cmd, env, MAX_ONESHOT_OUTPUT).await?;
|
||||
Ok((out.stdout(), out.stderr(), out.exit_code))
|
||||
}
|
||||
|
||||
async fn exec_oneshot_inner(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
@@ -528,6 +706,17 @@ async fn exec_oneshot_inner(
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<(String, i64), String> {
|
||||
let out = exec_oneshot_raw(container_id, user, cmd, env, limit).await?;
|
||||
Ok((out.text(), out.exit_code))
|
||||
}
|
||||
|
||||
async fn exec_oneshot_raw(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<OneshotOutput, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let exec = docker
|
||||
@@ -550,22 +739,31 @@ async fn exec_oneshot_inner(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||
|
||||
let mut combined = String::new();
|
||||
let mut combined: Vec<u8> = Vec::new();
|
||||
let mut stdout_ranges: Vec<(usize, usize)> = Vec::new();
|
||||
match result {
|
||||
StartExecResults::Attached { mut output, .. } => {
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(data) => {
|
||||
let chunk = String::from_utf8_lossy(&data.into_bytes()).into_owned();
|
||||
if !push_capped(&mut combined, &chunk, limit) {
|
||||
let from_stdout = matches!(data, LogOutput::StdOut { .. });
|
||||
let bytes = data.into_bytes();
|
||||
match push_capped(&mut combined, &bytes, limit) {
|
||||
Some(range) => {
|
||||
if from_stdout {
|
||||
stdout_ranges.push(range);
|
||||
}
|
||||
}
|
||||
// Stop reading rather than truncate silently: every
|
||||
// caller parses this output, and a half-read
|
||||
// manifest or JSON array is worse than an error.
|
||||
// Dropping `output` kills the exec's stream.
|
||||
return Err(format!(
|
||||
"Command output exceeded {} bytes and was abandoned",
|
||||
limit
|
||||
));
|
||||
None => {
|
||||
return Err(format!(
|
||||
"{}: Command output exceeded {} bytes and was abandoned",
|
||||
OUTPUT_LIMIT_MARKER, limit
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||
@@ -577,23 +775,60 @@ async fn exec_oneshot_inner(
|
||||
|
||||
// The output stream draining doesn't strictly guarantee inspect_exec has the
|
||||
// final exit_code populated yet, so poll until the exec reports finished.
|
||||
let exit_code = wait_for_exec_exit(&exec.id).await.unwrap_or(0);
|
||||
let exit_code = require_exit_code(wait_for_exec_exit(&exec.id).await)?;
|
||||
|
||||
Ok((combined, exit_code))
|
||||
Ok(OneshotOutput {
|
||||
combined,
|
||||
stdout_ranges,
|
||||
exit_code,
|
||||
})
|
||||
}
|
||||
|
||||
/// Turn "the exit code could not be determined" into an error rather than a 0.
|
||||
///
|
||||
/// `unwrap_or(0)` is how a rename that never happened reported success: callers
|
||||
/// branch on `code != 0`, so an unreadable status silently became "it worked",
|
||||
/// the UI closed its rename box and the file had not moved. An exec whose
|
||||
/// outcome cannot be established has not been established to have succeeded —
|
||||
/// fail closed and let the caller surface it.
|
||||
///
|
||||
/// The `test -e` probe in `rename_container_path` also fails closed under this:
|
||||
/// it propagates the error instead of reading an undeterminable status as
|
||||
/// "the destination does not exist".
|
||||
fn require_exit_code(code: Option<i64>) -> Result<i64, String> {
|
||||
code.ok_or_else(|| {
|
||||
"Could not determine whether the command finished (Docker did not report an exit status)"
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Poll `inspect_exec` until the exec reports finished and return its exit code.
|
||||
/// Returns `None` if the code can't be determined (inspect error, or the exec
|
||||
/// doesn't report finished within ~1s — which shouldn't happen once its output
|
||||
/// doesn't report finished within ~5s — which shouldn't happen once its output
|
||||
/// stream has drained).
|
||||
///
|
||||
/// The window is generous because `None` is no longer a shrug: since
|
||||
/// [`require_exit_code`], it fails the whole call. Waiting a few seconds longer
|
||||
/// for a busy daemon to settle costs nothing in the normal case — the loop exits
|
||||
/// on the first poll that reports finished — and it is the difference between a
|
||||
/// spurious "the rename failed" and a real one.
|
||||
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||
let docker = get_docker().ok()?;
|
||||
for _ in 0..40 {
|
||||
for _ in 0..200 {
|
||||
match docker.inspect_exec(exec_id).await {
|
||||
Ok(info) => {
|
||||
if info.running != Some(true) {
|
||||
// Finished: use the reported code (default 0 if somehow absent).
|
||||
return Some(info.exit_code.unwrap_or(0));
|
||||
// Finished. `exit_code` rather than `unwrap_or(0)`: an exec
|
||||
// that has stopped without a reported code is a status
|
||||
// nobody can vouch for, and flattening it to *success* is
|
||||
// the wrong default when a caller is deciding whether to
|
||||
// rename a downloaded file over the user's own.
|
||||
// `download_container_file` treats `None` as a failure
|
||||
// precisely because it cannot tell that silence from a
|
||||
// clean exit; an `unwrap_or` here made that check
|
||||
// unreachable. Callers that only care about "did it fail
|
||||
// loudly" use `is_some_and`, which reads `None` as before.
|
||||
return info.exit_code;
|
||||
}
|
||||
}
|
||||
Err(_) => return None,
|
||||
@@ -607,31 +842,96 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The frames a demultiplexed exec hands back, as `(is_stdout, bytes)`.
|
||||
fn collect(frames: &[(bool, &[u8])]) -> OneshotOutput {
|
||||
let mut combined = Vec::new();
|
||||
let mut stdout_ranges = Vec::new();
|
||||
for (from_stdout, bytes) in frames {
|
||||
let range = push_capped(&mut combined, bytes, usize::MAX).unwrap();
|
||||
if *from_stdout {
|
||||
stdout_ranges.push(range);
|
||||
}
|
||||
}
|
||||
OneshotOutput {
|
||||
combined,
|
||||
stdout_ranges,
|
||||
exit_code: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_under_the_limit_is_buffered_whole() {
|
||||
let mut buf = String::new();
|
||||
assert!(push_capped(&mut buf, "hello ", 16));
|
||||
assert!(push_capped(&mut buf, "world", 16));
|
||||
assert_eq!(buf, "hello world");
|
||||
let mut buf = Vec::new();
|
||||
assert_eq!(push_capped(&mut buf, b"hello ", 16), Some((0, 6)));
|
||||
assert_eq!(push_capped(&mut buf, b"world", 16), Some((6, 11)));
|
||||
assert_eq!(buf, b"hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_over_the_limit_is_refused_rather_than_truncated() {
|
||||
// The abandoned chunk must not land in the buffer either: a caller that
|
||||
// ignored the error would otherwise parse a half-read document.
|
||||
let mut buf = String::new();
|
||||
assert!(push_capped(&mut buf, "0123456789", 12));
|
||||
assert!(!push_capped(&mut buf, "0123456789", 12));
|
||||
assert_eq!(buf, "0123456789");
|
||||
let mut buf = Vec::new();
|
||||
assert!(push_capped(&mut buf, b"0123456789", 12).is_some());
|
||||
assert!(push_capped(&mut buf, b"0123456789", 12).is_none());
|
||||
assert_eq!(buf, b"0123456789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_oversized_chunk_is_refused() {
|
||||
let mut buf = String::new();
|
||||
assert!(!push_capped(&mut buf, "0123456789", 4));
|
||||
let mut buf = Vec::new();
|
||||
assert!(push_capped(&mut buf, b"0123456789", 4).is_none());
|
||||
assert!(buf.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_character_split_across_two_frames_survives_the_decode() {
|
||||
// Docker frames a stream wherever it likes, and a filename is where
|
||||
// that shows: decoding each chunk on its own turned the two halves of
|
||||
// `ü` into two replacement characters in the middle of a name.
|
||||
let out = collect(&[(true, &[0xc3]), (true, &[0xbc, b'.', b't', b'x', b't'])]);
|
||||
assert_eq!(out.stdout(), "ü.txt");
|
||||
assert_eq!(out.text(), "ü.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diagnostic_never_lands_in_the_stream_a_caller_parses() {
|
||||
// `find`'s "Permission denied" used to arrive inside the records its
|
||||
// `-printf` was emitting. Arrival order is still available for the
|
||||
// error message; the parser gets stdout alone.
|
||||
let out = collect(&[
|
||||
(true, b"first"),
|
||||
(false, b"find: /x: Permission denied\n"),
|
||||
(true, b"second"),
|
||||
]);
|
||||
assert_eq!(out.stdout(), "firstsecond");
|
||||
assert_eq!(out.stderr(), "find: /x: Permission denied\n");
|
||||
assert_eq!(out.text(), "firstfind: /x: Permission denied\nsecond");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_output_limit_refusal_is_marked_so_a_caller_can_reword_it() {
|
||||
// "Command output exceeded 8388608 bytes" is a fact about a buffer.
|
||||
// The marker is what lets `list_container_files` say "too many entries"
|
||||
// instead, which is the thing that actually happened.
|
||||
assert!(!OUTPUT_LIMIT_MARKER.is_empty());
|
||||
let refusal = format!(
|
||||
"{}: Command output exceeded {} bytes and was abandoned",
|
||||
OUTPUT_LIMIT_MARKER, MAX_ONESHOT_OUTPUT
|
||||
);
|
||||
assert!(refusal.starts_with(OUTPUT_LIMIT_MARKER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undeterminable_exit_status_is_an_error_not_a_zero() {
|
||||
// The bug this guards: `unwrap_or(0)` made every caller that branches on
|
||||
// `code != 0` — rename, mkdir — report success for an exec whose outcome
|
||||
// nobody could read.
|
||||
assert_eq!(require_exit_code(Some(0)).unwrap(), 0);
|
||||
assert_eq!(require_exit_code(Some(1)).unwrap(), 1);
|
||||
assert!(require_exit_code(None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bridge_budget_is_far_smaller_than_the_general_one() {
|
||||
// The auth bridge re-reads container-controlled procfs every 2s, so it
|
||||
@@ -640,4 +940,25 @@ mod tests {
|
||||
// …but still comfortably above a genuine /proc/net/tcp{,6} pair.
|
||||
assert!(PROC_NET_OUTPUT_LIMIT > 100 * 150);
|
||||
}
|
||||
|
||||
/// The reported path, which is what the terminal echoes back to Claude and
|
||||
/// what the Files pane puts in its log line. `/tmp//x` and `/tmp/x` are the
|
||||
/// same file to the kernel and different strings to a person reading either
|
||||
/// of those.
|
||||
#[test]
|
||||
fn container_join_produces_one_separator() {
|
||||
assert_eq!(container_join("/tmp", "a.txt"), "/tmp/a.txt");
|
||||
// The terminal's drop passes a nested name; it must not gain a second
|
||||
// slash at the seam.
|
||||
assert_eq!(
|
||||
container_join("/tmp", "triple-c-drops/a.txt"),
|
||||
"/tmp/triple-c-drops/a.txt"
|
||||
);
|
||||
// A directory the user navigated to can carry a trailing slash, and the
|
||||
// container root is the case where trimming it must not eat the only
|
||||
// separator there is.
|
||||
assert_eq!(container_join("/workspace/", "a.txt"), "/workspace/a.txt");
|
||||
assert_eq!(container_join("/", "a.txt"), "/a.txt");
|
||||
assert_eq!(container_join("/", "/a.txt"), "/a.txt");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,8 @@ pub const FEATURE_PROBES: &[(&str, &str)] = &[
|
||||
("/usr/local/bin/triple-c-task-runner", "Scheduled task runner"),
|
||||
("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"),
|
||||
("/opt/mission-control", "Mission Control (Flight Control)"),
|
||||
("/usr/bin/wg", "VPN tooling for the VPN Support toggle (WireGuard)"),
|
||||
("/opt/triple-c-skills", "Bundled skills for the VPN Support toggle (PIA VPN)"),
|
||||
];
|
||||
|
||||
/// Headroom demanded on Docker's storage backend on top of the measured
|
||||
@@ -367,6 +369,15 @@ pub fn set_delta(from: &BTreeSet<String>, base: &BTreeSet<String>) -> Vec<String
|
||||
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
|
||||
let mut out: Vec<String> = paths
|
||||
.iter()
|
||||
// **The same filter `project_path_mounts` applies, and it has to be.**
|
||||
// That function skips a row with an empty `host_path` or `mount_name`
|
||||
// so a legacy row cannot brick the create. The consequence is that
|
||||
// `/workspace/<name>` for such a row is *not* a bind mount — it is
|
||||
// ordinary writable-layer content. Excluding it here would tell
|
||||
// `compute_verbatim_paths` to skip staging it, and the container swap
|
||||
// would then destroy whatever the user has put there. The two
|
||||
// predicates must agree or a migration silently eats a directory.
|
||||
.filter(|p| !p.mount_name.trim().is_empty() && !p.host_path.trim().is_empty())
|
||||
.map(|p| format!("/workspace/{}", p.mount_name))
|
||||
.collect();
|
||||
out.sort();
|
||||
@@ -710,13 +721,83 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create probe container for {}: {}", image, e))?;
|
||||
let id = created.id;
|
||||
|
||||
let result = run_throwaway_inner(&id).await;
|
||||
// From here on the container's removal is owned by a guard rather than by
|
||||
// the statement that used to sit after the await below. A plain statement
|
||||
// only runs if this future is *polled to completion*: an `Err(...)?` was
|
||||
// already handled, but a **dropped** future — the app quitting mid-flight,
|
||||
// a timeout, any `select!` that loses — skipped it silently and left a
|
||||
// container behind holding a multi-gigabyte base image open. That image is
|
||||
// then unsweepable (removal is deliberately unforced) and there is nothing
|
||||
// in the UI that would ever mention it.
|
||||
let guard = ProbeContainerGuard::new(created.id);
|
||||
|
||||
if let Err(e) = docker
|
||||
let result = run_throwaway_inner(guard.id()).await;
|
||||
|
||||
// The happy path still removes it *synchronously*, so a caller that goes on
|
||||
// to `docker rmi` the image it probed does not race the removal.
|
||||
guard.remove_now().await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Owns the lifetime of a probe container.
|
||||
///
|
||||
/// [`Self::remove_now`] is the normal path and awaits the removal. `Drop` is the
|
||||
/// safety net for the abnormal one: it cannot await, so it hands the removal to
|
||||
/// a detached task. That covers a dropped future while the process lives; it
|
||||
/// cannot cover the process dying, which is what
|
||||
/// [`reap_probe_containers`] is for.
|
||||
struct ProbeContainerGuard {
|
||||
id: String,
|
||||
/// Cleared by `remove_now` so `Drop` does not queue a second removal.
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl ProbeContainerGuard {
|
||||
fn new(id: String) -> Self {
|
||||
Self { id, armed: true }
|
||||
}
|
||||
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// **Disarm after the await, never before it.** Clearing `armed` first
|
||||
/// looked equivalent and was the exact inverse of this guard's purpose: on
|
||||
/// the one path it exists for — this future being dropped part-way through
|
||||
/// the removal — `Drop` then saw a disarmed guard and did nothing, so the
|
||||
/// container survived with no background removal queued behind it. Setting
|
||||
/// it afterwards means a cancelled `remove_now` falls back to `Drop`'s
|
||||
/// detached removal, and only a removal that actually completed disarms.
|
||||
async fn remove_now(mut self) {
|
||||
remove_probe_container(&self.id).await;
|
||||
self.armed = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProbeContainerGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
let id = std::mem::take(&mut self.id);
|
||||
log::warn!("Probe container {} was abandoned; removing it in the background", id);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
remove_probe_container(&id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Force-remove one probe container. Missing is success — the point is that the
|
||||
/// container is gone.
|
||||
async fn remove_probe_container(id: &str) {
|
||||
let Ok(docker) = get_docker() else {
|
||||
return;
|
||||
};
|
||||
match docker
|
||||
.remove_container(
|
||||
&id,
|
||||
id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
v: true,
|
||||
@@ -725,12 +806,96 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to remove probe container {}: {}", id, e);
|
||||
Ok(())
|
||||
| Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 404, ..
|
||||
}) => {}
|
||||
Err(e) => log::warn!("Failed to remove probe container {}: {}", id, e),
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Remove probe containers left behind by a previous run of the app.
|
||||
///
|
||||
/// A probe is labelled [`LABEL_PROBE`] precisely so it stays findable after a
|
||||
/// crash, but until now nothing ever went looking. One leftover probe pins the
|
||||
/// base image it was created from — several gigabytes that
|
||||
/// `sweep_orphaned_snapshots` then reports as "in use" and correctly refuses to
|
||||
/// touch, with no way for the user to find out why.
|
||||
///
|
||||
/// Safe to run at startup: a probe is a short-lived `/bin/sh` with no mounts
|
||||
/// and no volumes, owned entirely by a `run_throwaway` call. If one is running
|
||||
/// right now it belongs to this process — and this runs before any migration
|
||||
/// can be started, so there is none to interrupt.
|
||||
///
|
||||
/// **Except that "belongs to this process" is not something this can know.**
|
||||
/// The filter is a label, and labels are daemon-wide: a second copy of the app
|
||||
/// migrating a project on the same daemon has probe containers carrying exactly
|
||||
/// this label, and force-removing one mid-manifest-capture fails that
|
||||
/// migration. In-process state cannot see the other instance, so the only
|
||||
/// available brake is age — [`PROBE_REAP_MIN_AGE_SECS`]. A probe runs a `df`, an
|
||||
/// `apt-get update` or a `find` over a root filesystem; none of those is a
|
||||
/// multi-minute job, so anything younger than the gate is far more likely to be
|
||||
/// someone's live probe than a leftover, and a leftover simply waits for the
|
||||
/// next start.
|
||||
pub async fn reap_probe_containers() {
|
||||
let Ok(docker) = get_docker() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"label".to_string(),
|
||||
vec![format!("{}={}", LABEL_PROBE, PROBE_LABEL_MIGRATION)],
|
||||
)]);
|
||||
|
||||
let containers = match docker
|
||||
.list_containers(Some(bollard::container::ListContainersOptions {
|
||||
all: true,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(list) => list,
|
||||
Err(e) => {
|
||||
log::warn!("Could not list leftover probe containers: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
for c in containers {
|
||||
// `created` is a unix timestamp; a summary without one is treated as
|
||||
// too young to touch, because unknown is never permission.
|
||||
let age = c.created.map(|created| now - created);
|
||||
match age {
|
||||
Some(age) if age >= PROBE_REAP_MIN_AGE_SECS => {}
|
||||
_ => {
|
||||
log::info!(
|
||||
"Leaving migration probe container {} alone — it is younger than {} minutes, \
|
||||
so it may belong to another Triple-C instance's live migration",
|
||||
c.id.as_deref().unwrap_or("<unknown>"),
|
||||
PROBE_REAP_MIN_AGE_SECS / 60
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(id) = c.id {
|
||||
log::info!("Removing leftover migration probe container {}", id);
|
||||
remove_probe_container(&id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How old a `triple-c.probe=migration` container must be before
|
||||
/// [`reap_probe_containers`] will force-remove it, in seconds.
|
||||
///
|
||||
/// The label is daemon-wide and this process cannot tell its own leftovers from
|
||||
/// another instance's live probe, so this is the whole guard. Generous against
|
||||
/// the longest probe there is (an `apt-get update` inside a throwaway container
|
||||
/// on a slow link) and still short enough that a crashed run's probe stops
|
||||
/// pinning a multi-gigabyte base image within the hour.
|
||||
pub const PROBE_REAP_MIN_AGE_SECS: i64 = 30 * 60;
|
||||
|
||||
async fn run_throwaway_inner(id: &str) -> Result<ThrowawayResult, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -908,6 +1073,210 @@ pub fn rollback_tag(now: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
format!("pre-migration-{}", now.format("%Y%m%d-%H%M%S"))
|
||||
}
|
||||
|
||||
/// How long a rollback pin may sit with no migration record behind it before
|
||||
/// [`reap_stale_migration_pins`] drops the tag.
|
||||
///
|
||||
/// Two weeks, chosen to be far longer than anyone deliberates over a base
|
||||
/// update and far shorter than "forever", which is what it was.
|
||||
///
|
||||
/// **Measured from when the record went missing, not from the tag.** See
|
||||
/// [`pin_is_reapable`] and
|
||||
/// [`crate::storage::migration_store::note_ownerless_since`].
|
||||
pub const STALE_PIN_MAX_AGE_DAYS: i64 = 14;
|
||||
|
||||
/// Recover the timestamp encoded in a tag produced by [`rollback_tag`].
|
||||
///
|
||||
/// `None` for anything that is not one of ours — a tag that merely *starts*
|
||||
/// with `pre-migration-` but does not carry a parseable timestamp is left alone
|
||||
/// rather than guessed at, because the consequence of guessing wrong is
|
||||
/// deleting the only copy of somebody's system layer.
|
||||
pub fn parse_rollback_tag(tag: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
let stamp = tag.strip_prefix("pre-migration-")?;
|
||||
let naive = chrono::NaiveDateTime::parse_from_str(stamp, "%Y%m%d-%H%M%S").ok()?;
|
||||
Some(naive.and_utc())
|
||||
}
|
||||
|
||||
/// Split `triple-c-snapshot-<projectId>:<tag>` into the project id and the tag.
|
||||
///
|
||||
/// `None` when the reference is not a snapshot repo at all.
|
||||
pub fn parse_snapshot_reference(reference: &str) -> Option<(String, String)> {
|
||||
let (repo, tag) = split_image_ref(reference);
|
||||
let project_id = repo.strip_prefix("triple-c-snapshot-")?.to_string();
|
||||
if project_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((project_id, tag))
|
||||
}
|
||||
|
||||
/// Whether a rollback pin is safe to drop, given whether the project it belongs
|
||||
/// to still has a migration record and how long it has been without one.
|
||||
///
|
||||
/// Pure so the decision can be tested without a daemon. The order of the
|
||||
/// conditions is the point: **a pin whose migration is still awaiting
|
||||
/// confirmation is never reaped at any age**, because it is the only copy of
|
||||
/// the rollback target and the user has not yet said they are happy with the
|
||||
/// new base.
|
||||
///
|
||||
/// ## `ownerless_since`, and why it is not the tag's timestamp
|
||||
///
|
||||
/// This used to compute the age from `parse_rollback_tag(tag)` — the instant
|
||||
/// the migration *started*. A migration is allowed to sit at
|
||||
/// `awaiting-confirmation` for as long as the user likes; that is what
|
||||
/// `keep_rollback` is for. A project parked there for a month whose record is
|
||||
/// then lost had a tag a month old, so the pin was reapable on the very next
|
||||
/// check and the startup sweep deleted the image immediately after. The
|
||||
/// fourteen days were nominal: the real grace period for the case the constant
|
||||
/// was written for was zero.
|
||||
///
|
||||
/// So the clock starts when the claim was lost, which is recorded by
|
||||
/// [`crate::storage::migration_store::note_ownerless_since`] the first time a
|
||||
/// reaper notices. `None` means no reaper has recorded a sighting yet, and that
|
||||
/// is **not** "sighted now": returning false there is what gives a pin its
|
||||
/// first full fourteen days instead of none.
|
||||
///
|
||||
/// ## Clock skew
|
||||
///
|
||||
/// A `now` earlier than `ownerless_since` — a host clock that ran fast and was
|
||||
/// corrected, or a data directory carried between machines — yields a negative
|
||||
/// elapsed time. That is treated as not reapable, and the marker writer
|
||||
/// re-anchors it, rather than letting a negative `num_days()` mean "never" or
|
||||
/// an inflated one mean "immediately".
|
||||
pub fn pin_is_reapable(
|
||||
tag: &str,
|
||||
has_migration_record: bool,
|
||||
ownerless_since: Option<chrono::DateTime<chrono::Utc>>,
|
||||
now: &chrono::DateTime<chrono::Utc>,
|
||||
) -> bool {
|
||||
if has_migration_record {
|
||||
return false;
|
||||
}
|
||||
// Still required: the tag has to be one of ours. A hand-made
|
||||
// `pre-migration-keepme` is somebody's deliberate pin and is never guessed
|
||||
// at, whatever a marker beside it says.
|
||||
if parse_rollback_tag(tag).is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(since) = ownerless_since else {
|
||||
return false;
|
||||
};
|
||||
let elapsed = *now - since;
|
||||
elapsed >= chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS)
|
||||
}
|
||||
|
||||
/// Drop `triple-c-snapshot-*:pre-migration-*` tags that no migration record
|
||||
/// claims any more, so the images behind them become sweepable.
|
||||
///
|
||||
/// ## Why this scans tags instead of reading the records
|
||||
///
|
||||
/// Every other path to a rollback pin starts from
|
||||
/// `migration_store::load`, and `load` reports an unparseable state file as
|
||||
/// *absent* — so a single corrupt record used to strand a 4–12 GB image that no
|
||||
/// code could ever name again. Confirming or rolling back both remove the
|
||||
/// record and drop the tag together, so a `pre-migration-*` tag with no record
|
||||
/// beside it is by definition one that lost its owner: a crash between the two,
|
||||
/// a record that was deleted by hand, or the corrupt-file case.
|
||||
///
|
||||
/// Scanning the *tag pattern* is the only way to find those. `load` moving a
|
||||
/// corrupt record aside (see `migration_store::load`) is what stops that case
|
||||
/// from being permanently invisible here too.
|
||||
///
|
||||
/// ## Why it only untags
|
||||
///
|
||||
/// Dropping the tag turns the image dangling, and it is already labelled
|
||||
/// `triple-c.managed=true` because `docker commit` created it — so
|
||||
/// `sweep_orphaned_snapshots` collects it on the same pass, under the same two
|
||||
/// safety conditions, with the daemon's "still in use by a container" refusal
|
||||
/// still in front of it. Nothing here calls `docker rmi` on a reachable image.
|
||||
pub async fn reap_stale_migration_pins() -> usize {
|
||||
use bollard::image::ListImagesOptions;
|
||||
|
||||
let Ok(docker) = get_docker() else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// `reference` matches against `repo:tag`, so this asks the daemon for
|
||||
// exactly the shape [`rollback_tag`] produces and nothing else.
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"reference".to_string(),
|
||||
vec!["triple-c-snapshot-*:pre-migration-*".to_string()],
|
||||
)]);
|
||||
|
||||
let images = match docker
|
||||
.list_images(Some(ListImagesOptions {
|
||||
all: false,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(images) => images,
|
||||
Err(e) => {
|
||||
log::warn!("Could not list rollback pins: {}", e);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut reaped = 0usize;
|
||||
|
||||
for summary in images {
|
||||
for reference in &summary.repo_tags {
|
||||
let Some((project_id, tag)) = parse_snapshot_reference(reference) else {
|
||||
continue;
|
||||
};
|
||||
// Filesystem presence, not `load`: a record we cannot parse must
|
||||
// still count as "somebody may want this back".
|
||||
let has_record =
|
||||
crate::storage::migration_store::has_record(&project_id).unwrap_or(true);
|
||||
if has_record {
|
||||
// Owned again (or still owned): throw away any grace clock a
|
||||
// previous pass started, so a pin that loses its record twice
|
||||
// gets a fresh fourteen days rather than inheriting a stale one.
|
||||
crate::storage::migration_store::clear_ownerless(&project_id, &tag);
|
||||
continue;
|
||||
}
|
||||
// Only a *well-formed* pin gets a marker written for it — a tag
|
||||
// that is not one of ours is left entirely alone, files included.
|
||||
if parse_rollback_tag(&tag).is_none() {
|
||||
continue;
|
||||
}
|
||||
// Records the first sighting when there is none, which is why this
|
||||
// returns `None` on that pass and the pin survives it.
|
||||
//
|
||||
// A `save` can land between the `has_record` above and this write,
|
||||
// which would plant a tombstone dated *now* behind a perfectly
|
||||
// valid record — invisible until that record is legitimately lost,
|
||||
// at which point the pin is already past its grace period and is
|
||||
// reaped on the first check. `note_ownerless_since` re-asks
|
||||
// `has_record` after the write and removes the marker again; the
|
||||
// reasoning for why that closes the window is on it.
|
||||
let ownerless_since =
|
||||
crate::storage::migration_store::note_ownerless_since(&project_id, &tag, &now);
|
||||
if !pin_is_reapable(&tag, has_record, ownerless_since, &now) {
|
||||
continue;
|
||||
}
|
||||
match untag_image(reference).await {
|
||||
Ok(()) => {
|
||||
crate::storage::migration_store::clear_ownerless(&project_id, &tag);
|
||||
log::info!(
|
||||
"Dropped stale rollback pin {} ({:.2} GB) — no migration record has claimed it since {}, more than {} days",
|
||||
reference,
|
||||
summary.size as f64 / 1_073_741_824.0,
|
||||
ownerless_since
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
STALE_PIN_MAX_AGE_DAYS,
|
||||
);
|
||||
reaped += 1;
|
||||
}
|
||||
Err(e) => log::warn!("Could not drop stale rollback pin {}: {}", reference, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reaped
|
||||
}
|
||||
|
||||
/// Split `repo:tag` into its parts, defaulting the tag to `latest`.
|
||||
pub fn split_image_ref(image: &str) -> (String, String) {
|
||||
match image.rsplit_once(':') {
|
||||
@@ -1008,6 +1377,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// The mount filter and the migration's exclusion list must agree.
|
||||
///
|
||||
/// `project_path_mounts` skips a row with an empty `host_path` so a legacy
|
||||
/// row cannot brick the create. That makes `/workspace/<name>` ordinary
|
||||
/// writable-layer content rather than a bind mount — and if this function
|
||||
/// still excluded it, `compute_verbatim_paths` would skip staging it and
|
||||
/// the container swap would destroy whatever is there. A migration eating a
|
||||
/// directory is the quietest kind of data loss there is.
|
||||
#[test]
|
||||
fn an_unmountable_row_is_not_excluded_from_the_migration_payload() {
|
||||
let paths = vec![
|
||||
ProjectPath { host_path: "/home/u/code".into(), mount_name: "code".into() },
|
||||
// Legacy shapes that `project_path_mounts` skips.
|
||||
ProjectPath { host_path: "".into(), mount_name: "data".into() },
|
||||
ProjectPath { host_path: "/home/u/x".into(), mount_name: " ".into() },
|
||||
];
|
||||
let excluded = bind_mount_exclusions(&paths);
|
||||
assert_eq!(
|
||||
excluded,
|
||||
vec!["/workspace/code".to_string()],
|
||||
"only rows that are actually mounted may be excluded from staging"
|
||||
);
|
||||
}
|
||||
use super::*;
|
||||
use crate::models::{
|
||||
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
||||
@@ -1620,4 +2013,137 @@ mod tests {
|
||||
assert_eq!(shell_single_quote("/opt/a'b"), r#"'/opt/a'\''b'"#);
|
||||
}
|
||||
|
||||
|
||||
// ── Stale rollback pins (A5) ─────────────────────────────────────────────
|
||||
|
||||
fn at(y: i32, m: u32, d: u32) -> chrono::DateTime<chrono::Utc> {
|
||||
chrono::NaiveDate::from_ymd_opt(y, m, d)
|
||||
.unwrap()
|
||||
.and_hms_opt(12, 0, 0)
|
||||
.unwrap()
|
||||
.and_utc()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rollback_tag_round_trips_through_its_parser() {
|
||||
let made = at(2026, 3, 14);
|
||||
assert_eq!(parse_rollback_tag(&rollback_tag(&made)), Some(made));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_real_rollback_tag_parses() {
|
||||
assert_eq!(parse_rollback_tag("latest"), None);
|
||||
assert_eq!(parse_rollback_tag("pre-migration-"), None);
|
||||
// Looks like ours but carries no timestamp we produced. Guessing here
|
||||
// would mean deleting the only copy of somebody's system layer.
|
||||
assert_eq!(parse_rollback_tag("pre-migration-keepme"), None);
|
||||
assert_eq!(parse_rollback_tag("pre-migration-20260231-000000"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_reference_yields_its_project_id() {
|
||||
assert_eq!(
|
||||
parse_snapshot_reference("triple-c-snapshot-abc-123:pre-migration-20260101-101500"),
|
||||
Some(("abc-123".to_string(), "pre-migration-20260101-101500".to_string()))
|
||||
);
|
||||
// Not ours: a base image, and a repo that merely shares a prefix.
|
||||
assert_eq!(parse_snapshot_reference("triple-c-sandbox:latest"), None);
|
||||
assert_eq!(parse_snapshot_reference("triple-c-snapshot-:latest"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_awaiting_confirmation_is_never_reaped_at_any_age() {
|
||||
// The one rule that cannot bend: while a migration record exists, this
|
||||
// image is the only copy of the rollback target and the user has not
|
||||
// yet said they are happy on the new base.
|
||||
let ancient = rollback_tag(&at(2020, 1, 1));
|
||||
assert!(!pin_is_reapable(
|
||||
&ancient,
|
||||
true,
|
||||
Some(at(2020, 1, 1)),
|
||||
&at(2026, 8, 23)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclaimed_pin_is_reaped_only_once_it_is_old() {
|
||||
let tag = rollback_tag(&at(2026, 8, 1));
|
||||
// The clock runs from when the record went missing, which here is well
|
||||
// after the migration started.
|
||||
let lost = at(2026, 8, 10);
|
||||
assert!(!pin_is_reapable(&tag, false, Some(lost), &at(2026, 8, 11)));
|
||||
assert!(!pin_is_reapable(
|
||||
&tag,
|
||||
false,
|
||||
Some(lost),
|
||||
&(lost + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS) - chrono::Duration::seconds(1))
|
||||
));
|
||||
assert!(pin_is_reapable(
|
||||
&tag,
|
||||
false,
|
||||
Some(lost),
|
||||
&(lost + chrono::Duration::days(STALE_PIN_MAX_AGE_DAYS))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grace_period_runs_from_the_lost_record_not_from_the_tag() {
|
||||
// The bug this replaced, stated as a test. A migration parked at
|
||||
// `awaiting-confirmation` for a month — supported, that is what
|
||||
// `keep_rollback` is for — whose record is then lost had a
|
||||
// month-old tag, so the old rule made its pin reapable on the very
|
||||
// next app start with the startup sweep deleting the image two lines
|
||||
// later. Zero grace, on the one case the fourteen days exist for.
|
||||
let started = at(2026, 6, 1);
|
||||
let tag = rollback_tag(&started);
|
||||
let record_lost = at(2026, 7, 1);
|
||||
let noticed_immediately_after = record_lost + chrono::Duration::minutes(5);
|
||||
assert!(
|
||||
!pin_is_reapable(&tag, false, Some(record_lost), ¬iced_immediately_after),
|
||||
"a tag a month old must still get its full grace period once orphaned"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsighted_pin_is_never_reaped_on_the_pass_that_first_sees_it() {
|
||||
// `None` means no reaper has recorded a sighting. Treating that as
|
||||
// "sighted now" would be harmless; treating it as "sighted long ago"
|
||||
// would not, and neither is what it means — the marker is written on
|
||||
// this pass and the pin becomes reapable fourteen days later.
|
||||
let tag = rollback_tag(&at(2020, 1, 1));
|
||||
assert!(!pin_is_reapable(&tag, false, None, &at(2026, 8, 23)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clock_that_ran_backwards_neither_reaps_nor_strands() {
|
||||
// A marker dated after `now`: the host clock was fast and got
|
||||
// corrected, or the data directory came from another machine. A
|
||||
// negative elapsed time must read as "not yet", not as a huge age.
|
||||
let tag = rollback_tag(&at(2026, 1, 1));
|
||||
let marker = at(2026, 9, 1);
|
||||
assert!(!pin_is_reapable(&tag, false, Some(marker), &at(2026, 8, 1)));
|
||||
// The other direction is bounded by the marker rather than by the tag:
|
||||
// a wildly future `now` can only expire a clock that was actually
|
||||
// started, and a pin with no marker (the case above) still cannot be
|
||||
// reaped at all — which is what stops a fast host clock from making
|
||||
// *every* pin on the daemon instantly collectable.
|
||||
assert!(pin_is_reapable(
|
||||
&tag,
|
||||
false,
|
||||
Some(at(2026, 8, 20)),
|
||||
&at(2030, 1, 1)
|
||||
));
|
||||
assert!(!pin_is_reapable(&tag, false, None, &at(2030, 1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_we_cannot_date_is_left_alone() {
|
||||
// Even with an ancient ownerless marker sitting beside it: a tag that
|
||||
// merely *starts* `pre-migration-` is somebody's deliberate pin, and
|
||||
// the reaper never writes a marker for one in the first place.
|
||||
let ancient = Some(at(2020, 1, 1));
|
||||
let now = at(2026, 8, 23);
|
||||
assert!(!pin_is_reapable("pre-migration-handmade", false, ancient, &now));
|
||||
assert!(!pin_is_reapable("latest", false, ancient, &now));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ mod docker;
|
||||
mod install_helper;
|
||||
mod logging;
|
||||
mod models;
|
||||
mod project_lock;
|
||||
mod storage;
|
||||
pub mod web_terminal;
|
||||
|
||||
@@ -28,6 +29,21 @@ pub struct AppState {
|
||||
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||
pub lifecycle: Arc<Lifecycle>,
|
||||
/// The file `preview_settings_import` last decrypted successfully, held
|
||||
/// so `apply_settings_import` can re-read and re-decrypt the same file
|
||||
/// without the frontend ever passing a host path back to Rust as an
|
||||
/// argument — see the doc comment on `commands::settings_export_commands`
|
||||
/// for why that direction specifically is the one this app treats as
|
||||
/// dangerous. Deliberately re-decrypted rather than cached in plaintext:
|
||||
/// nothing here holds a decrypted secret in memory for longer than one
|
||||
/// command's execution.
|
||||
///
|
||||
/// Also pins a hash of the file's ciphertext at preview time, so
|
||||
/// `apply_settings_import` can refuse to proceed if the file on disk
|
||||
/// changed underneath the pending import — otherwise confirming a
|
||||
/// preview is not actually binding on what gets applied.
|
||||
pub pending_settings_import:
|
||||
Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -212,7 +228,6 @@ pub fn run() {
|
||||
let lifecycle_setup = lifecycle.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(AppState {
|
||||
@@ -222,6 +237,7 @@ pub fn run() {
|
||||
auth_bridge,
|
||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
lifecycle,
|
||||
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
})
|
||||
.setup(move |app| {
|
||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||
@@ -235,6 +251,40 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Startup disk housekeeping ────────────────────────────────
|
||||
// Until now the only sweep ran *after* a recreation, so a user who
|
||||
// simply stopped launching a project kept its orphaned snapshot
|
||||
// layers forever, and anything a crash left behind (a probe
|
||||
// container pinning a base image, a rollback pin whose migration
|
||||
// record is gone) had no path back at all. All of it is
|
||||
// read-mostly and finishes in well under a second on an idle daemon,
|
||||
// but they are detached anyway: housekeeping must never delay the
|
||||
// window appearing, and a daemon that is not running yet is a
|
||||
// logged warning rather than a failed start.
|
||||
//
|
||||
// Ordering matters. Probes are removed first because a probe holds
|
||||
// an image open and the sweep will not force; pins are untagged
|
||||
// second so the images they were holding are dangling by the time
|
||||
// the sweep lists them; the sweep runs last and collects both.
|
||||
let projects_store_for_cleanup = projects_store_setup.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
crate::docker::reap_probe_containers().await;
|
||||
let reaped = crate::docker::reap_stale_migration_pins().await;
|
||||
if reaped > 0 {
|
||||
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
|
||||
}
|
||||
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
|
||||
// A container/image/volume `remove_project` could not delete
|
||||
// is recorded rather than lost — see triple-c#31 — and this is
|
||||
// the only place anything ever retries it. Takes the store so
|
||||
// it can refuse to touch a project that turns out to still be
|
||||
// live — see the long comment on the function itself.
|
||||
crate::commands::project_commands::retry_pending_cleanup_logged(
|
||||
&projects_store_for_cleanup,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
// Auto-start web terminal server if enabled in settings
|
||||
let settings = settings_store_setup.get();
|
||||
if settings.web_terminal.enabled {
|
||||
@@ -411,7 +461,6 @@ pub fn run() {
|
||||
commands::docker_commands::check_image_exists,
|
||||
commands::docker_commands::build_image,
|
||||
commands::docker_commands::get_container_info,
|
||||
commands::docker_commands::list_sibling_containers,
|
||||
// Projects
|
||||
commands::project_commands::list_projects,
|
||||
commands::project_commands::add_project,
|
||||
@@ -452,6 +501,7 @@ pub fn run() {
|
||||
commands::auth_token_commands::cancel_claude_token,
|
||||
commands::auth_token_commands::has_claude_token,
|
||||
commands::auth_token_commands::clear_claude_token,
|
||||
commands::auth_token_commands::sweep_claude_token_snapshots,
|
||||
// Settings
|
||||
commands::settings_commands::get_settings,
|
||||
commands::settings_commands::update_settings,
|
||||
@@ -460,6 +510,10 @@ pub fn run() {
|
||||
commands::settings_commands::inspect_ca_cert_path,
|
||||
commands::settings_commands::list_aws_profiles,
|
||||
commands::settings_commands::detect_host_timezone,
|
||||
// Settings export/import
|
||||
commands::settings_export_commands::export_settings,
|
||||
commands::settings_export_commands::preview_settings_import,
|
||||
commands::settings_export_commands::apply_settings_import,
|
||||
// Terminal
|
||||
commands::terminal_commands::open_terminal_session,
|
||||
commands::terminal_commands::terminal_input,
|
||||
@@ -472,9 +526,12 @@ pub fn run() {
|
||||
commands::terminal_commands::stop_audio_bridge,
|
||||
// Files
|
||||
commands::file_commands::list_container_files,
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::download_container_backup,
|
||||
commands::file_commands::upload_file_to_container,
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::upload_files_to_container,
|
||||
commands::file_commands::read_container_file,
|
||||
commands::file_commands::rename_container_path,
|
||||
commands::file_commands::create_container_directory,
|
||||
// AWS
|
||||
commands::aws_commands::aws_sso_refresh,
|
||||
// Updates
|
||||
@@ -652,4 +709,244 @@ mod tests {
|
||||
lifecycle.settle_startup_tasks().await;
|
||||
assert!(started.elapsed() <= STARTUP_CANCEL_BUDGET + Duration::from_secs(1));
|
||||
}
|
||||
|
||||
/// The capability file is the app's entire IPC attack surface, and it is
|
||||
/// data — nothing in `cargo test` reads it, so a widened grant lands with a
|
||||
/// green suite. This is what noticing looks like.
|
||||
///
|
||||
/// It exists because `core:default` was granted for months. That alias
|
||||
/// pulls in `core:image:default` → `allow-from-path`, which is an
|
||||
/// unconditional `std::fs::read` of any host path with no scope check, and
|
||||
/// nothing in the frontend has ever imported `@tauri-apps/api/image`.
|
||||
/// Every `#[tauri::command]` is registered, and every registration names a
|
||||
/// command that exists.
|
||||
///
|
||||
/// This is the shape of the bug that caused the original OAuth-callback
|
||||
/// complaint: `set_auth_bridge_enabled` existed, worked, and had a typed
|
||||
/// frontend wrapper — with **zero call sites**. The switch the docs told
|
||||
/// users to flip was never wired to anything, so the bridge stayed off and
|
||||
/// every login callback was refused. Nothing failed; the feature was simply
|
||||
/// absent, and no test noticed because both halves compiled.
|
||||
///
|
||||
/// The reverse direction matters too, and for a sharper reason: a command
|
||||
/// that is registered but reachable from nowhere is still IPC surface a
|
||||
/// compromised webview can call. `list_sibling_containers` — which returned
|
||||
/// every container on the daemon, including the user's unrelated work —
|
||||
/// sat in exactly that state, and this test is what found it. It has since
|
||||
/// been removed at all four levels: registration, command, docker helper,
|
||||
/// and the frontend wrapper and type.
|
||||
///
|
||||
/// So this asserts the two lists agree, and leaves *deciding* what belongs
|
||||
/// on them to a human. It cannot see frontend call sites; `tsc` and the
|
||||
/// vitest suite cover that side.
|
||||
#[test]
|
||||
fn every_command_is_registered_exactly_once() {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let mut defined: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
// Walk the source tree for the command attribute and take the `fn` name
|
||||
// that follows.
|
||||
//
|
||||
// The first version of this matched `line.trim() == "#[tauri::command]"`
|
||||
// exactly and broke on the first non-`#` line. An audit got five real,
|
||||
// compiling, unregistered commands past it — `#[tauri::command(async)]`,
|
||||
// `#[tauri::command(rename_all = "snake_case")]`, a trailing comment,
|
||||
// spaces in the path, and a bare `#[command]` after `use tauri::command`
|
||||
// — plus `pub(crate) fn` and a `///` line between attribute and `fn`.
|
||||
// Every one of those is a command the frontend could not call, which is
|
||||
// the bug this test exists for, and the test stayed green.
|
||||
//
|
||||
// The asymmetry matters: confusion on the *definition* side is a silent
|
||||
// pass, while on the *registration* side it fails loudly against
|
||||
// legitimate code — and rustc already covers that direction. So this
|
||||
// errs toward over-matching definitions.
|
||||
fn collect(dir: &std::path::Path, out: &mut BTreeSet<String>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let t = line.trim();
|
||||
// `#[tauri::command]`, `#[tauri::command(async)]`,
|
||||
// `#[tauri :: command]`, a bare `#[command]` under
|
||||
// `use tauri::command`, and any of those with a
|
||||
// trailing comment.
|
||||
let attr = t.strip_prefix("#[").map(|a| {
|
||||
a.split(']').next().unwrap_or("").replace(' ', "")
|
||||
});
|
||||
let is_command_attr = attr.is_some_and(|a| {
|
||||
a == "command" || a == "tauri::command"
|
||||
|| a.starts_with("command(")
|
||||
|| a.starts_with("tauri::command(")
|
||||
});
|
||||
if !is_command_attr {
|
||||
continue;
|
||||
}
|
||||
// Skip further attributes and doc comments rather than
|
||||
// giving up at the first line that is not an attribute.
|
||||
for next in lines.iter().skip(i + 1) {
|
||||
let t = next.trim();
|
||||
if t.starts_with('#') || t.starts_with("//") || t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Any visibility, then `fn` or `async fn`.
|
||||
let after_vis = t
|
||||
.strip_prefix("pub(crate) ")
|
||||
.or_else(|| t.strip_prefix("pub(super) "))
|
||||
.or_else(|| t.strip_prefix("pub(in crate) "))
|
||||
.or_else(|| t.strip_prefix("pub "))
|
||||
.unwrap_or(t);
|
||||
let after_async =
|
||||
after_vis.strip_prefix("async ").unwrap_or(after_vis);
|
||||
if let Some(rest) = after_async.strip_prefix("fn ") {
|
||||
if let Some(name) = rest.split(['(', '<']).next() {
|
||||
out.insert(name.trim().to_string());
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(
|
||||
std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
|
||||
&mut defined,
|
||||
);
|
||||
|
||||
// The registration list, read from this file rather than from a macro
|
||||
// expansion so the test does not depend on `generate_handler!`'s shape.
|
||||
let this = include_str!("lib.rs");
|
||||
let handler = this
|
||||
.split_once("generate_handler![")
|
||||
.and_then(|(_, rest)| rest.split_once("])"))
|
||||
.map(|(inside, _)| inside)
|
||||
.expect("lib.rs should contain a generate_handler! list");
|
||||
// Line-based, not `split(',')`: the list is grouped under `// Docker`
|
||||
// style comments, and splitting on commas glues each comment to the
|
||||
// command that follows it. A `starts_with("//")` filter then drops that
|
||||
// command — silently, and once per group.
|
||||
let registered: BTreeSet<String> = handler
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||
.filter_map(|l| {
|
||||
l.trim_end_matches(',')
|
||||
.rsplit("::")
|
||||
.next()
|
||||
.map(|n| n.trim().to_string())
|
||||
})
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!defined.is_empty() && !registered.is_empty(),
|
||||
"the scan found nothing — it has stopped testing anything (defined={}, registered={})",
|
||||
defined.len(),
|
||||
registered.len()
|
||||
);
|
||||
|
||||
let unregistered: Vec<&String> = defined.difference(®istered).collect();
|
||||
assert!(
|
||||
unregistered.is_empty(),
|
||||
"these commands exist but are not registered, so the frontend cannot call them: {:?}",
|
||||
unregistered
|
||||
);
|
||||
|
||||
let undefined: Vec<&String> = registered.difference(&defined).collect();
|
||||
assert!(
|
||||
undefined.is_empty(),
|
||||
"these are registered but no `#[tauri::command]` defines them: {:?}",
|
||||
undefined
|
||||
);
|
||||
|
||||
// "exactly once" was in this test's name and not in its body: both
|
||||
// sides were sets, so registering the same command twice in a
|
||||
// hand-maintained 118-line list compiled, warned about nothing, and
|
||||
// passed here.
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
let mut duplicated: Vec<&str> = Vec::new();
|
||||
for line in handler
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||
{
|
||||
if let Some(name) = line.trim_end_matches(',').rsplit("::").next() {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if seen.contains(&name) {
|
||||
duplicated.push(name);
|
||||
} else {
|
||||
seen.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
duplicated.is_empty(),
|
||||
"these are registered more than once: {:?}",
|
||||
duplicated
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_capability_grants_are_the_ones_that_were_reviewed() {
|
||||
let raw = include_str!("../capabilities/default.json");
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(raw).expect("capabilities/default.json must parse");
|
||||
let listed: Vec<String> = parsed["permissions"]
|
||||
.as_array()
|
||||
.expect("a `permissions` array")
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
// A scoped grant is an object; its identifier is what matters here.
|
||||
serde_json::Value::Object(o) => o["identifier"]
|
||||
.as_str()
|
||||
.expect("a scoped grant needs an identifier")
|
||||
.to_string(),
|
||||
other => other.as_str().expect("a grant is a string or an object").to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut sorted = listed.clone();
|
||||
sorted.sort();
|
||||
let mut expected = vec![
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"opener:allow-open-url",
|
||||
];
|
||||
expected.sort();
|
||||
assert_eq!(
|
||||
sorted, expected,
|
||||
"the capability set changed. That is allowed — but it is the IPC \
|
||||
surface a compromised webview can call, so update this list \
|
||||
deliberately rather than to make the test pass."
|
||||
);
|
||||
|
||||
// Belt and braces: the `*:default` aliases are the specific trap here,
|
||||
// because they expand to a set the file never spells out. `store:*` in
|
||||
// particular was an arbitrary host-file read/write primitive.
|
||||
for grant in &listed {
|
||||
assert!(
|
||||
!grant.ends_with(":default"),
|
||||
"{} is an alias — it expands to permissions this file does not \
|
||||
name. Enumerate them instead.",
|
||||
grant
|
||||
);
|
||||
assert!(
|
||||
!grant.starts_with("store:"),
|
||||
"store:* is `PathBuf::push` against AppData, which an absolute \
|
||||
path discards: an arbitrary host-file read/write."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The level the dispatch is built with, and the level restored by hand if
|
||||
/// installing it fails — see the failure branch in [`init`] for why that
|
||||
/// matters more than it looks.
|
||||
const LOG_LEVEL: log::LevelFilter = log::LevelFilter::Info;
|
||||
|
||||
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
|
||||
fn log_dir() -> Option<PathBuf> {
|
||||
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
|
||||
@@ -33,7 +38,7 @@ pub fn init() {
|
||||
message
|
||||
))
|
||||
})
|
||||
.level(log::LevelFilter::Info)
|
||||
.level(LOG_LEVEL)
|
||||
.chain(std::io::stderr());
|
||||
|
||||
if let Some((_path, file)) = &log_file_path {
|
||||
@@ -41,7 +46,28 @@ pub fn init() {
|
||||
}
|
||||
|
||||
if let Err(e) = dispatch.apply() {
|
||||
eprintln!("Failed to initialise logger: {}", e);
|
||||
// H2's other half. `fern::Dispatch::apply` calls `log::set_boxed_logger`
|
||||
// and only then `log::set_max_level`, so a failure returns with the
|
||||
// global filter still at its default, `LevelFilter::Off`. That is not
|
||||
// merely "no log output": every `log::info!(…)` expands to
|
||||
// `if Info <= max_level() { … }`, so at `Off` the macro never evaluates
|
||||
// its own arguments. Anything a call site put in an argument list —
|
||||
// a function call, an `await`, a side effect — silently stops
|
||||
// happening, app-wide, because a logger could not be installed.
|
||||
//
|
||||
// Call sites must not put effects in log arguments (see the
|
||||
// pre-migration scrub in `migration_commands.rs`), but "the whole
|
||||
// program's log macros are dead and nothing said so" is its own
|
||||
// hazard, so the level this dispatch was configured with is restored
|
||||
// by hand. Nothing is listening — `log`'s default logger is a no-op —
|
||||
// but the macros evaluate, and the one thing that *is* guaranteed to
|
||||
// reach the user, the stderr line below, says what happened.
|
||||
eprintln!(
|
||||
"Failed to initialise logger: {}. Log output is disabled for this run; \
|
||||
log macros still evaluate their arguments.",
|
||||
e
|
||||
);
|
||||
log::set_max_level(LOG_LEVEL);
|
||||
}
|
||||
|
||||
// Install a panic hook that writes to the log file so crashes are captured.
|
||||
@@ -71,3 +97,40 @@ pub fn init() {
|
||||
log::info!("Logging to {}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_logger_that_could_not_be_installed_still_leaves_the_macros_evaluating() {
|
||||
// H2: `log::info!(…)` expands to `if Info <= max_level() { … }`, so at
|
||||
// `LevelFilter::Off` the arguments are never evaluated. `fern` returns
|
||||
// before `set_max_level` when `apply()` fails, which leaves exactly
|
||||
// that state — and a call site that folded an effect into an argument
|
||||
// list then stops performing it, app-wide, because a log file could not
|
||||
// be opened. The failure branch restores the level for that reason.
|
||||
//
|
||||
// Asserted on the level itself rather than by driving `init`, which
|
||||
// installs a process-global logger and a panic hook and can only run
|
||||
// once per process.
|
||||
assert_ne!(LOG_LEVEL, log::LevelFilter::Off);
|
||||
|
||||
// The property that makes the above worth asserting, demonstrated
|
||||
// against the macro itself: a side effect in an argument list runs only
|
||||
// while the level admits the record.
|
||||
let mut ran = false;
|
||||
let effect = |v: &mut bool| {
|
||||
*v = true;
|
||||
0
|
||||
};
|
||||
let previous = log::max_level();
|
||||
log::set_max_level(log::LevelFilter::Off);
|
||||
log::info!("{}", effect(&mut ran));
|
||||
assert!(!ran, "the premise is wrong: arguments evaluated at LevelFilter::Off");
|
||||
log::set_max_level(LOG_LEVEL);
|
||||
log::info!("{}", effect(&mut ran));
|
||||
assert!(ran, "arguments did not evaluate at the level this module configures");
|
||||
log::set_max_level(previous);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,54 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
||||
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
||||
/// under Wayland, printing `Could not create default EGL display:
|
||||
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
|
||||
/// code and killing the webview before Triple-C's own logging even starts —
|
||||
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||
///
|
||||
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
|
||||
/// variable is exported into an XWayland client's environment too, so a
|
||||
/// gate on it wouldn't even cleanly separate "Wayland" from "X11" — and
|
||||
/// there is no reliable heuristic at all for the actual variable that
|
||||
/// matters, which Mesa/driver/compositor combination is affected. This is
|
||||
/// the blunt instrument, chosen deliberately because the fallback is a real
|
||||
/// trade, not a free one: the terminal's `@xterm/addon-webgl` renderer
|
||||
/// (`TerminalView.tsx`) is the one surface in this app actually asking for
|
||||
/// GPU compositing, and it degrades to xterm's canvas renderer under this
|
||||
/// setting — slower on very heavy output, but the addon's own construction
|
||||
/// is already wrapped in a fallback (`WebGL not available` is a handled
|
||||
/// case, not a crash), so this is a real but graceful downgrade, traded
|
||||
/// against a startup abort that has no fallback at all.
|
||||
///
|
||||
/// Must be set before `triple_c_lib::run()` — GTK/WebKitGTK reads it at
|
||||
/// their own init time, which happens inside the Tauri builder that
|
||||
/// function calls into, not at binary load.
|
||||
///
|
||||
/// A user who has already set this themselves is left alone. 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() {
|
||||
#[cfg(target_os = "linux")]
|
||||
apply_webkit_wayland_workaround();
|
||||
|
||||
triple_c_lib::run()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod container_config;
|
||||
pub mod app_settings;
|
||||
pub mod gateway_settings;
|
||||
pub mod migration;
|
||||
pub mod settings_export;
|
||||
pub mod update_info;
|
||||
|
||||
pub use project::*;
|
||||
@@ -10,4 +11,5 @@ pub use container_config::*;
|
||||
pub use app_settings::*;
|
||||
pub use gateway_settings::*;
|
||||
pub use migration::*;
|
||||
pub use settings_export::*;
|
||||
pub use update_info::*;
|
||||
|
||||
@@ -8,6 +8,100 @@ pub struct EnvVar {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Whether `key` is a name a shell will read back as an ordinary variable:
|
||||
/// `[A-Za-z_][A-Za-z0-9_]*`.
|
||||
///
|
||||
/// ## Why a charset rule, and not just the reserved-name list
|
||||
///
|
||||
/// `docker::container::is_reserved_env_key` answers a different question — "is
|
||||
/// this one of the names Triple-C manages itself" — and nothing anywhere asked
|
||||
/// what the *characters* were. A key is joined into `KEY=VALUE` and handed to
|
||||
/// the daemon, which puts it in the container's environment verbatim, so a name
|
||||
/// that is not an identifier travels through unchallenged.
|
||||
///
|
||||
/// The one that matters is `BASH_FUNC_name%%`, bash's wire format for an
|
||||
/// exported shell function: bash imports those at startup and the *body* is the
|
||||
/// value. Today that is latent rather than live — the image's `/bin/sh` is
|
||||
/// dash, which does not import them, and an auditor confirmed the vector fires
|
||||
/// under `bash -c` and not under `sh -c` in the shipped image. But the
|
||||
/// pre-commit scrub runs `/bin/sh -c` **as root**, `/bin/sh` is whatever
|
||||
/// `ubuntu:24.04` points it at, and nothing pins that. One base-image change,
|
||||
/// or one call site spelled `bash`, turns a stored project setting into root
|
||||
/// code execution inside the container at commit time.
|
||||
///
|
||||
/// So the rule is the shape of the thing rather than a list of the names that
|
||||
/// are known to be dangerous: `IFS`, `LD_PRELOAD` and `PATH` are all perfectly
|
||||
/// good identifiers and are the user's business, while nothing legitimate needs
|
||||
/// a `%`, a `(` or a space in an environment variable name.
|
||||
///
|
||||
/// The key is judged **trimmed**, because that is what `create_container` sends
|
||||
/// — ` FOO ` already reaches the container as `FOO`, and refusing it here would
|
||||
/// break a setting that works.
|
||||
pub fn is_valid_env_key(key: &str) -> bool {
|
||||
let mut chars = key.trim().chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// Validate a custom environment variable list that is about to be stored,
|
||||
/// admitting the entries it is already stored with.
|
||||
///
|
||||
/// Same shape, and the same reasoning, as
|
||||
/// `commands::project_commands::validate_project_paths_update`: nothing ever
|
||||
/// checked these keys, so `projects.json` and `settings.json` in the field can
|
||||
/// hold whatever was typed. Holding every save to the new rule would make such
|
||||
/// a project unsavable *entirely* — `update_project` is the single command
|
||||
/// behind the whole Config tab — and would buy nothing, because the stored key
|
||||
/// is already being handed to every container that starts. An entry carried
|
||||
/// over verbatim is admitted; a new or edited one is held to the rule, which is
|
||||
/// what keeps the escalation closed, since escalation means *introducing* a bad
|
||||
/// key through this command.
|
||||
///
|
||||
/// Counted rather than set-tested, for the same reason as the folder rows: a
|
||||
/// second copy of an existing entry is a new entry.
|
||||
///
|
||||
/// The blank entry is not a violation. "+ Add variable" appends
|
||||
/// `{key: "", value: ""}` and saves the list immediately, so refusing it would
|
||||
/// turn the button itself into an error toast; `create_container` skips an
|
||||
/// empty key, so it reaches nothing.
|
||||
pub fn validate_env_vars_update(stored: &[EnvVar], incoming: &[EnvVar]) -> Result<(), String> {
|
||||
// An entry with no key is the placeholder, whatever is in its value:
|
||||
// `create_container` skips it, so it reaches nothing and there is nothing
|
||||
// to refuse. The editor saves on every blur, and typing the value before
|
||||
// the name is an ordinary way to fill a row in.
|
||||
let is_blank = |v: &EnvVar| v.key.trim().is_empty();
|
||||
|
||||
let mut carried: std::collections::HashMap<(&str, &str), usize> =
|
||||
std::collections::HashMap::new();
|
||||
for v in stored.iter().filter(|v| !is_blank(v)) {
|
||||
*carried
|
||||
.entry((v.key.as_str(), v.value.as_str()))
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
for v in incoming.iter().filter(|v| !is_blank(v)) {
|
||||
match carried.get_mut(&(v.key.as_str(), v.value.as_str())) {
|
||||
Some(remaining) if *remaining > 0 => {
|
||||
*remaining -= 1;
|
||||
}
|
||||
_ => {
|
||||
if !is_valid_env_key(&v.key) {
|
||||
return Err(format!(
|
||||
"'{}' is not a usable environment variable name. Use a letter or \
|
||||
underscore followed by letters, digits or underscores.",
|
||||
v.key
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ProjectPath {
|
||||
pub host_path: String,
|
||||
@@ -85,31 +179,141 @@ impl PermissionMode {
|
||||
/// Settings for Claude Code CLI behavior inside the container.
|
||||
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
#[serde(from = "StoredClaudeCodeSettings")]
|
||||
/// Every field is three-state, and the third state is load-bearing.
|
||||
///
|
||||
/// `None` means "not set at this level". For a *project* that is "inherit
|
||||
/// whatever the global settings say"; for the *global* settings it is "leave
|
||||
/// Claude Code's own default alone". `Some(false)` is a deliberate off, which
|
||||
/// is what lets a project turn a globally-enabled setting back off — with a
|
||||
/// plain `bool` there is no value that can express that, which is why these
|
||||
/// were widened from `bool`.
|
||||
pub struct ClaudeCodeSettings {
|
||||
/// TUI rendering mode: None = default, Some("fullscreen") = flicker-free alt-screen
|
||||
#[serde(default)]
|
||||
/// TUI renderer. `None` leaves settings.json's `tui` key unset, which is
|
||||
/// what lets Claude Code pick the renderer itself; `Some("default")` pins
|
||||
/// the classic main-screen renderer and `Some("fullscreen")` the alt-screen
|
||||
/// one. All three are distinct — "let it choose" is not "classic".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tui_mode: Option<String>,
|
||||
/// Effort level: None = default, Some("low"|"medium"|"high")
|
||||
#[serde(default)]
|
||||
/// Saved `/effort` level: `None` = unset, otherwise one of
|
||||
/// `"low" | "medium" | "high" | "xhigh"`. Written to settings.json as
|
||||
/// `effortLevel` (**not** `effort`, which Claude Code has never read).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<String>,
|
||||
/// Disable auto-scroll in fullscreen TUI mode
|
||||
#[serde(default)]
|
||||
pub auto_scroll_disabled: bool,
|
||||
/// Enable focus mode (collapsed tool output)
|
||||
#[serde(default)]
|
||||
pub focus_mode: bool,
|
||||
/// Disable auto-scroll in fullscreen TUI mode. Held in the *disabled* sense
|
||||
/// because Claude Code's `autoScrollEnabled` defaults to `true`, so the
|
||||
/// zero value of this field has to mean "leave it on".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_scroll_disabled: Option<bool>,
|
||||
/// Collapse tool output to one-line summaries. Written to settings.json as
|
||||
/// `viewMode: "focus"`; there is no `focusMode` key in Claude Code.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub focus_mode: Option<bool>,
|
||||
/// Show thinking summaries in responses
|
||||
#[serde(default)]
|
||||
pub show_thinking_summaries: bool,
|
||||
/// Enable session recap when returning to a session
|
||||
#[serde(default)]
|
||||
pub enable_session_recap: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub show_thinking_summaries: Option<bool>,
|
||||
/// Turn the session recap **off**.
|
||||
///
|
||||
/// Held in the disabled sense for the same reason as `auto_scroll_disabled`,
|
||||
/// and the rename from the old `enable_session_recap` is load-bearing rather
|
||||
/// than cosmetic. Claude Code's recap is on by default, so the old field was
|
||||
/// inverted: switching it on was a no-op and switching it off did nothing at
|
||||
/// all. Reusing the name with the opposite meaning would have read every
|
||||
/// stored `enable_session_recap: false` — which is what every project that
|
||||
/// never touched the control holds — as "the user turned the recap off" and
|
||||
/// silently disabled it for all of them. A new name lets the old key be
|
||||
/// ignored, which lands every existing project on the correct default.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub session_recap_disabled: Option<bool>,
|
||||
/// Strip credentials from subprocess environments
|
||||
#[serde(default)]
|
||||
pub env_scrub: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub env_scrub: Option<bool>,
|
||||
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_caching_1h: Option<bool>,
|
||||
}
|
||||
|
||||
/// `ClaudeCodeSettings` in every shape `projects.json` and `settings.json` can
|
||||
/// be holding, which is what [`ClaudeCodeSettings`] is actually deserialised
|
||||
/// through.
|
||||
///
|
||||
/// ## The upgrade this exists to survive
|
||||
///
|
||||
/// Before the widening, the five booleans were plain `bool`s with
|
||||
/// `#[serde(default)]` and no `skip_serializing_if`, so **every** settings
|
||||
/// object ever written carries an explicit `"env_scrub": false` — not because
|
||||
/// anyone chose it, but because that is what a `bool` serialises to. Under the
|
||||
/// old merge (`if p.x { true } else { g.x }`) that `false` carried no
|
||||
/// information at all: it was the only value an unset switch could produce, and
|
||||
/// the global always won.
|
||||
///
|
||||
/// Read as `Some(false)` by the new code it becomes a *deliberate off* that
|
||||
/// beats a global `Some(true)` — so upgrading silently turned five settings off
|
||||
/// for every project that had ever opened this editor, `env_scrub` ("strip
|
||||
/// credentials from subprocess environments") among them. There is no store
|
||||
/// migration anywhere: `projects_store` parses these structs directly.
|
||||
///
|
||||
/// ## How an old record is told apart from a new one
|
||||
///
|
||||
/// By `enable_session_recap`. It was in the struct from the day it existed and
|
||||
/// was a plain `bool`, so its key is present in every pre-widening record and
|
||||
/// in no other — the field was *renamed* to `session_recap_disabled` precisely
|
||||
/// so the old key could be ignored (see the doc on that field), and the new
|
||||
/// code has never written it. Its presence is therefore an exact statement that
|
||||
/// these bytes were written by a binary in which `false` meant "unset", and the
|
||||
/// booleans are read back that way: `true` is a real choice and survives,
|
||||
/// `false` becomes `None` and inherits again.
|
||||
///
|
||||
/// Nothing marks a *new* record, and nothing needs to: absent is `None` (the
|
||||
/// fields skip serialising when unset) and a present `false` is the deliberate
|
||||
/// off the widening was for. That is also what keeps a downgrade survivable —
|
||||
/// an older binary reads an absent key as `false` through its own
|
||||
/// `#[serde(default)]`, where a `null` would fail to parse and take the whole
|
||||
/// of `projects.json` down with it, since `ProjectsStore` parses all-or-nothing
|
||||
/// and starts empty on an error.
|
||||
#[derive(Deserialize)]
|
||||
struct StoredClaudeCodeSettings {
|
||||
#[serde(default)]
|
||||
pub prompt_caching_1h: bool,
|
||||
tui_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
effort: Option<String>,
|
||||
#[serde(default)]
|
||||
auto_scroll_disabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
focus_mode: Option<bool>,
|
||||
#[serde(default)]
|
||||
show_thinking_summaries: Option<bool>,
|
||||
#[serde(default)]
|
||||
session_recap_disabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
env_scrub: Option<bool>,
|
||||
#[serde(default)]
|
||||
prompt_caching_1h: Option<bool>,
|
||||
/// The pre-widening spelling of `session_recap_disabled`, and the *only*
|
||||
/// use of its value: presence dates the record. Its meaning was inverted
|
||||
/// and it never worked, so it is read for the marker and discarded.
|
||||
#[serde(default)]
|
||||
enable_session_recap: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<StoredClaudeCodeSettings> for ClaudeCodeSettings {
|
||||
fn from(stored: StoredClaudeCodeSettings) -> Self {
|
||||
let pre_widening = stored.enable_session_recap.is_some();
|
||||
// On a pre-widening record `false` is what an untouched switch wrote,
|
||||
// so it means "not set at this level" and must inherit. A `true` was a
|
||||
// real choice either way.
|
||||
let read = |v: Option<bool>| if pre_widening { v.filter(|on| *on) } else { v };
|
||||
ClaudeCodeSettings {
|
||||
tui_mode: stored.tui_mode,
|
||||
effort: stored.effort,
|
||||
auto_scroll_disabled: read(stored.auto_scroll_disabled),
|
||||
focus_mode: read(stored.focus_mode),
|
||||
show_thinking_summaries: read(stored.show_thinking_summaries),
|
||||
session_recap_disabled: read(stored.session_recap_disabled),
|
||||
env_scrub: read(stored.env_scrub),
|
||||
prompt_caching_1h: read(stored.prompt_caching_1h),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -145,6 +349,22 @@ pub struct Project {
|
||||
/// container-recreation label.
|
||||
#[serde(default)]
|
||||
pub browser_view_enabled: bool,
|
||||
/// Grant the container what a VPN client needs to build a tunnel:
|
||||
/// `CAP_NET_ADMIN`, the `/dev/net/tun` device, and the WireGuard
|
||||
/// `src_valid_mark` sysctl. Without all three a client (PIA, WireGuard,
|
||||
/// OpenVPN) installs and runs but its connection attempt hangs until it
|
||||
/// times out, because it cannot create the tunnel interface or touch the
|
||||
/// routing table.
|
||||
///
|
||||
/// Off by default and deliberately opt-in: `NET_ADMIN` lets anything in the
|
||||
/// container reconfigure its own network stack, which reaches further than
|
||||
/// it sounds — see `vpn_host_config` for what it does and does not confer.
|
||||
/// Unlike `auth_bridge_enabled` this *is*
|
||||
/// container state, so it carries a `triple-c.vpn-support` label and is
|
||||
/// compared in `container_needs_recreation` — capabilities and devices are
|
||||
/// fixed at creation and can only change by recreating the container.
|
||||
#[serde(default)]
|
||||
pub vpn_support_enabled: bool,
|
||||
/// Use the shared, long-lived Claude Code OAuth token (from
|
||||
/// `claude setup-token`, held in the OS keychain) for this project instead
|
||||
/// of requiring its own `claude login`. Only consulted when `backend` is
|
||||
@@ -202,6 +422,61 @@ pub enum ProjectStatus {
|
||||
Error,
|
||||
}
|
||||
|
||||
/// What `remove_project` could not delete, named so the UI can say so instead
|
||||
/// of reporting a clean removal that was not one.
|
||||
///
|
||||
/// The project record is dropped from `projects.json` regardless — see the
|
||||
/// long comment on `remove_project` for why refusing is not the answer — but
|
||||
/// anything named here is also written to a pending-cleanup record that
|
||||
/// startup housekeeping retries, so it stays reachable after the project it
|
||||
/// belonged to no longer exists.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectRemovalReport {
|
||||
/// The project's container, if it could not be removed. Named by its
|
||||
/// deterministic `triple-c-{id}` name (see `Project::container_name`),
|
||||
/// not the container id, since the id can be stale or absent and the
|
||||
/// name is what a later retry can still resolve.
|
||||
pub container: Option<String>,
|
||||
/// The `triple-c-snapshot-{id}` image, if it could not be removed.
|
||||
pub image: Option<String>,
|
||||
/// Named volumes (home, claude config) that could not be removed.
|
||||
pub volumes: Vec<String>,
|
||||
/// True once the leftovers above were durably recorded for automatic
|
||||
/// retry on the next launch. False means the pending-cleanup record
|
||||
/// itself could not be written — nothing will retry these, and the UI
|
||||
/// must say so rather than promising a retry that will not happen.
|
||||
/// Meaningless (and left at its default) when `is_clean()` is true.
|
||||
pub retry_scheduled: bool,
|
||||
}
|
||||
|
||||
impl ProjectRemovalReport {
|
||||
/// True when nothing was left behind.
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.container.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// What `rebuild_project_container` (Reset) produced: the project as it
|
||||
/// stands after restarting, and anything Reset could not clear.
|
||||
///
|
||||
/// Reset's contract is "back to a clean base image", so a leftover volume or
|
||||
/// image here is reused/rebuilt-from as-is by the container this creates —
|
||||
/// the opposite of what was asked for — and unlike [`ProjectRemovalReport`]
|
||||
/// there is no pending-cleanup record for either: the project id survives
|
||||
/// Reset, so a later Reset attempt can retry them itself.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectResetOutcome {
|
||||
pub project: Project,
|
||||
/// The `triple-c-snapshot-{id}` image, if Reset could not remove it. The
|
||||
/// more serious of the two leftovers here: the new container is created
|
||||
/// from this image whenever it exists, so a surviving image means Reset
|
||||
/// silently rebuilt the exact system layer it was asked to discard.
|
||||
pub leftover_image: Option<String>,
|
||||
/// Volumes that survived Reset and were mounted into the new container
|
||||
/// unchanged.
|
||||
pub leftover_volumes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Which AI model backend/provider the project uses.
|
||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||
@@ -366,6 +641,7 @@ impl Project {
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
browser_view_enabled: false,
|
||||
vpn_support_enabled: false,
|
||||
use_shared_auth_token: default_use_shared_auth_token(),
|
||||
full_permissions: false,
|
||||
permission_mode: None,
|
||||
@@ -424,3 +700,189 @@ impl Project {
|
||||
val
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── ProjectRemovalReport ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_report_is_clean_only_with_nothing_left_behind() {
|
||||
assert!(ProjectRemovalReport::default().is_clean());
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.container = Some("abc123".to_string());
|
||||
assert!(!r.is_clean(), "a leftover container must not read as clean");
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.image = Some("triple-c-snapshot-x:latest".to_string());
|
||||
assert!(!r.is_clean(), "a leftover image must not read as clean");
|
||||
|
||||
let mut r = ProjectRemovalReport::default();
|
||||
r.volumes.push("triple-c-home-x".to_string());
|
||||
assert!(!r.is_clean(), "a leftover volume must not read as clean");
|
||||
}
|
||||
|
||||
// ── Custom environment variable names ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn an_env_var_name_has_to_be_a_shell_identifier() {
|
||||
for ok in ["PATH", "_", "_x", "MY_VAR2", "a", " SPACED_BY_THE_EDITOR "] {
|
||||
assert!(is_valid_env_key(ok), "'{}' should be a usable name", ok);
|
||||
}
|
||||
for bad in [
|
||||
// bash's wire format for an exported shell function: the value is
|
||||
// the body, and a `bash` that imports it runs it. The scrub exec is
|
||||
// `/bin/sh -c` as root, and nothing pins `/bin/sh` to dash.
|
||||
"BASH_FUNC_stat%%",
|
||||
"BASH_FUNC_ls()",
|
||||
"MY VAR",
|
||||
"2FAST",
|
||||
"WITH-DASH",
|
||||
"WITH.DOT",
|
||||
"",
|
||||
" ",
|
||||
"$(id)",
|
||||
"A=B",
|
||||
] {
|
||||
assert!(!is_valid_env_key(bad), "'{}' should be refused", bad);
|
||||
}
|
||||
}
|
||||
|
||||
fn env(key: &str, value: &str) -> EnvVar {
|
||||
EnvVar { key: key.to_string(), value: value.to_string() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_env_var_name_cannot_be_introduced_but_a_stored_one_does_not_brick_the_editor() {
|
||||
let bad = [env("BASH_FUNC_stat%%", "() { id; }")];
|
||||
// Introducing it through the Config tab is the escalation.
|
||||
assert!(validate_env_vars_update(&[], &bad).is_err());
|
||||
// Already stored: it is handed to every container that starts whether
|
||||
// or not an unrelated save is allowed through, and refusing the save
|
||||
// would make every toggle on the Config tab fail.
|
||||
assert!(validate_env_vars_update(&bad, &bad).is_ok());
|
||||
// Editing its value is a new entry, and refused again.
|
||||
assert!(
|
||||
validate_env_vars_update(&bad, &[env("BASH_FUNC_stat%%", "() { rm -rf /; }")]).is_err()
|
||||
);
|
||||
// Fixing the name is what the message asks for, and it saves.
|
||||
assert!(validate_env_vars_update(&bad, &[env("STAT", "() { id; }")]).is_ok());
|
||||
// Dropping it entirely is always fine.
|
||||
assert!(validate_env_vars_update(&bad, &[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blank_row_the_add_button_saves_is_not_an_error() {
|
||||
// "+ Add variable" appends an empty entry and saves the list at once,
|
||||
// so this is the button, not an attempt at anything.
|
||||
assert!(validate_env_vars_update(&[], &[env("", "")]).is_ok());
|
||||
// Typing the value before the name is an ordinary way to fill it in,
|
||||
// and an entry with no name reaches no container either way.
|
||||
assert!(validate_env_vars_update(&[], &[env("", "value-first")]).is_ok());
|
||||
assert!(validate_env_vars_update(&[], &[env("GOOD", "v"), env("", "")]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_entry_may_be_kept_but_not_multiplied() {
|
||||
let stored = [env("BAD NAME", "v")];
|
||||
assert!(validate_env_vars_update(&stored, &stored).is_ok());
|
||||
// A second copy is a new entry, and held to the rule.
|
||||
assert!(
|
||||
validate_env_vars_update(&stored, &[env("BAD NAME", "v"), env("BAD NAME", "v")])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
// ── Claude Code settings written before the fields were widened ───────
|
||||
|
||||
/// `projects.json` exactly as the shipped `main` binary wrote it: the five
|
||||
/// booleans were plain `bool`s that always serialised, so every project
|
||||
/// that ever opened the editor carries `false` for the ones it never
|
||||
/// touched.
|
||||
const MAIN_SHAPE_PROJECT: &str = r#"{
|
||||
"id": "p1",
|
||||
"name": "demo",
|
||||
"paths": [{ "host_path": "/home/u/demo", "mount_name": "demo" }],
|
||||
"container_id": null,
|
||||
"status": "stopped",
|
||||
"backend": "anthropic",
|
||||
"bedrock_config": null,
|
||||
"ollama_config": null,
|
||||
"openai_compatible_config": null,
|
||||
"allow_docker_access": false,
|
||||
"ssh_key_path": null,
|
||||
"git_user_name": null,
|
||||
"git_user_email": null,
|
||||
"claude_code_settings": {
|
||||
"tui_mode": "fullscreen",
|
||||
"effort": null,
|
||||
"auto_scroll_disabled": false,
|
||||
"focus_mode": false,
|
||||
"show_thinking_summaries": false,
|
||||
"enable_session_recap": false,
|
||||
"env_scrub": false,
|
||||
"prompt_caching_1h": false
|
||||
},
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z"
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn a_setting_stored_as_false_by_the_old_binary_still_inherits_the_global() {
|
||||
let project: Project = serde_json::from_str(MAIN_SHAPE_PROJECT).unwrap();
|
||||
let stored = project.claude_code_settings.expect("settings should parse");
|
||||
|
||||
// Read verbatim these would be `Some(false)`, which under
|
||||
// `docker::container::merge_claude_code_settings` beats the global.
|
||||
assert_eq!(stored.env_scrub, None);
|
||||
assert_eq!(stored.auto_scroll_disabled, None);
|
||||
assert_eq!(stored.focus_mode, None);
|
||||
assert_eq!(stored.show_thinking_summaries, None);
|
||||
assert_eq!(stored.prompt_caching_1h, None);
|
||||
assert_eq!(stored.session_recap_disabled, None);
|
||||
// A value the user did choose is untouched.
|
||||
assert_eq!(stored.tui_mode.as_deref(), Some("fullscreen"));
|
||||
|
||||
// The merge rule itself, spelled the way
|
||||
// `merge_claude_code_settings` spells it. `main` resolved this with
|
||||
// `if p.env_scrub { true } else { g.env_scrub }`, i.e. the global won —
|
||||
// and it has to go on winning, because the user never turned this off.
|
||||
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
|
||||
assert_eq!(
|
||||
stored.env_scrub.or(global.env_scrub),
|
||||
Some(true),
|
||||
"upgrading silently turned off 'strip credentials from subprocess environments'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_chosen_in_the_new_editor_still_beats_a_global_on() {
|
||||
// Same record without the pre-widening key: this `false` is the
|
||||
// deliberate off the widening exists to make expressible.
|
||||
let json = r#"{ "env_scrub": false }"#;
|
||||
let chosen: ClaudeCodeSettings = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(chosen.env_scrub, Some(false));
|
||||
let global = ClaudeCodeSettings { env_scrub: Some(true), ..Default::default() };
|
||||
assert_eq!(chosen.env_scrub.or(global.env_scrub), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unset_setting_is_written_as_absent_rather_than_null() {
|
||||
// A downgrade parses these fields as plain `bool` with
|
||||
// `#[serde(default)]`: an absent key is `false`, a `null` is a parse
|
||||
// error — and `ProjectsStore` parses all-or-nothing, so one project
|
||||
// with one null empties the whole list and the next save persists that.
|
||||
let json = serde_json::to_string(&ClaudeCodeSettings::default()).unwrap();
|
||||
assert_eq!(json, "{}");
|
||||
assert!(!json.contains("null"));
|
||||
|
||||
let partial = ClaudeCodeSettings { env_scrub: Some(false), ..Default::default() };
|
||||
let json = serde_json::to_string(&partial).unwrap();
|
||||
assert_eq!(json, r#"{"env_scrub":false}"#);
|
||||
// And it reads back as what it is.
|
||||
let round_tripped: ClaudeCodeSettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(round_tripped, partial);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Settings export/import — see triple-c#35.
|
||||
//!
|
||||
//! `SettingsExportPayload` is the whole plaintext export before encryption
|
||||
//! and after decryption (see `storage::settings_crypto`). It bundles
|
||||
//! `AppSettings` — with one field carved out, see below — with the global
|
||||
//! secrets that live in the OS keychain instead: the shared Claude Code
|
||||
//! OAuth login and the model gateway's two keys. Per-project settings,
|
||||
//! per-project secrets, and anything living in a project's Docker volumes
|
||||
//! are deliberately out of scope: this exports the *host* environment, not
|
||||
//! any one project's.
|
||||
//!
|
||||
//! **`AppSettings` is not entirely the non-secret shape it looks like.**
|
||||
//! `WebTerminalSettings::access_token` is a live bearer credential for a
|
||||
//! server that binds every interface, stored as a plain field on the
|
||||
//! struct that is otherwise safe to treat as config. A review of this
|
||||
//! feature caught it: exporting `AppSettings` wholesale would have carried
|
||||
//! that token along as if it were as inert as a port number, and — worse —
|
||||
//! importing it would apply `web_terminal.enabled` and the token together
|
||||
//! with no more warning than any other setting, letting a crafted export
|
||||
//! silently stand up a LAN-listening terminal server with an
|
||||
//! attacker-known token on the next launch. `export_settings` /
|
||||
//! `apply_settings_import` blank this field out of the `settings` they
|
||||
//! read from and write to, and it travels only through
|
||||
//! [`ExportedSecrets::web_terminal_access_token`] instead, with the same
|
||||
//! "only overwrite what the import actually has" treatment as the other
|
||||
//! three secrets.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{AppSettings, ImageSource};
|
||||
|
||||
/// Bumped when the shape of [`SettingsExportPayload`] changes in a way that
|
||||
/// isn't just an additive, `#[serde(default)]`-covered field — e.g. if a
|
||||
/// field is ever removed or its meaning changes. `apply_settings_import`
|
||||
/// checks this before touching anything.
|
||||
pub const SETTINGS_EXPORT_FORMAT_VERSION: u32 = 1;
|
||||
|
||||
/// The global secrets bundled into an export. Deliberately a separate struct
|
||||
/// from `AppSettings`: these live in the OS keychain, never in
|
||||
/// `settings.json`, and — outside of this export/import flow — the values
|
||||
/// themselves never cross into the frontend; see the doc comments on
|
||||
/// `storage::secure::get_gateway_api_key` and
|
||||
/// `commands::settings_export_commands` for why that boundary matters here
|
||||
/// too.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ExportedSecrets {
|
||||
#[serde(default)]
|
||||
pub claude_oauth_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gateway_api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gateway_master_key: Option<String>,
|
||||
/// See the module doc comment — this is `AppSettings::web_terminal
|
||||
/// .access_token`, carved out because it is a live bearer credential,
|
||||
/// not config, despite living on a struct that is otherwise safe to
|
||||
/// export wholesale.
|
||||
#[serde(default)]
|
||||
pub web_terminal_access_token: Option<String>,
|
||||
}
|
||||
|
||||
impl ExportedSecrets {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
let blank = |s: &Option<String>| s.as_deref().is_none_or(|v| v.trim().is_empty());
|
||||
blank(&self.claude_oauth_token)
|
||||
&& blank(&self.gateway_api_key)
|
||||
&& blank(&self.gateway_master_key)
|
||||
&& blank(&self.web_terminal_access_token)
|
||||
}
|
||||
}
|
||||
|
||||
/// What `apply_settings_import` hands back: the settings that were actually
|
||||
/// saved, plus a human-readable note for each keychain secret this import
|
||||
/// carried but could not be restored. A keychain write failing partway
|
||||
/// through must not read as unqualified success just because the settings
|
||||
/// half of the import went through.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsImportOutcome {
|
||||
pub settings: AppSettings,
|
||||
#[serde(default)]
|
||||
pub secret_restore_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The full plaintext payload — this is what gets encrypted on export and
|
||||
/// what decryption recovers on import. Never written to disk unencrypted;
|
||||
/// see `storage::settings_crypto`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsExportPayload {
|
||||
pub format_version: u32,
|
||||
/// RFC3339. Purely informational — shown in the import preview so a user
|
||||
/// picking between a few old export files has something to go on.
|
||||
pub exported_at: String,
|
||||
/// The exporting app's `CARGO_PKG_VERSION`. Also informational: every
|
||||
/// field below already round-trips through `#[serde(default)]`-covered
|
||||
/// `AppSettings`, so an older or newer export still deserializes; this is
|
||||
/// for a human to notice "this is from a much older version" if an import
|
||||
/// ever looks wrong, not something the code branches on.
|
||||
pub app_version: String,
|
||||
pub settings: AppSettings,
|
||||
#[serde(default)]
|
||||
pub secrets: ExportedSecrets,
|
||||
}
|
||||
|
||||
/// What `preview_settings_import` hands the frontend before anything is
|
||||
/// applied — counts and presence flags only, **never** a secret value itself,
|
||||
/// so this type is safe to return across the IPC boundary and render
|
||||
/// directly. The confirmation UI is built from this.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsImportPreview {
|
||||
pub exported_at: String,
|
||||
pub app_version: String,
|
||||
pub custom_env_var_count: usize,
|
||||
pub gateway_model_count: usize,
|
||||
pub has_claude_code_settings: bool,
|
||||
pub has_claude_oauth_token: bool,
|
||||
pub has_gateway_api_key: bool,
|
||||
pub has_gateway_master_key: bool,
|
||||
pub has_web_terminal_access_token: bool,
|
||||
/// Whether the imported settings turn the web terminal on. Named
|
||||
/// separately from the token above: `enabled` and the token are two
|
||||
/// different fields, either can be true without the other, and
|
||||
/// "this import turns on a service that listens on your network" is
|
||||
/// exactly the kind of change a wholesale settings replace must not
|
||||
/// bury in a generic "settings replaced" line — see the module doc
|
||||
/// comment on why this field exists at all.
|
||||
pub enables_web_terminal: bool,
|
||||
/// Non-blank custom base URLs the import would set, so a redirect of
|
||||
/// model traffic to somewhere other than the usual provider is visible
|
||||
/// at import time rather than discovered later. These are endpoints, not
|
||||
/// secrets — safe to show verbatim, unlike everything above.
|
||||
#[serde(default)]
|
||||
pub ollama_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub llamacpp_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub openai_compatible_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gateway_api_base: Option<String>,
|
||||
/// Whether the import sets a custom Docker image, and its name if so —
|
||||
/// disclosed for the same reason as the base URLs above, and arguably
|
||||
/// more sharply: this is the image *every* project container is created
|
||||
/// from (`models::container_config::resolve_image_name`), so a crafted
|
||||
/// export pointing it at an attacker-controlled image is a path to
|
||||
/// running arbitrary code with whatever a project's containers are
|
||||
/// allowed to reach (the Docker socket, an SSH key, project files) —
|
||||
/// not merely a redirected API endpoint.
|
||||
#[serde(default)]
|
||||
pub image_source: ImageSource,
|
||||
#[serde(default)]
|
||||
pub custom_image_name: Option<String>,
|
||||
}
|
||||
|
||||
/// A cap on how much of a decrypted, not-yet-trusted string gets echoed back
|
||||
/// into a preview a user reads and a UI renders without truncation of its
|
||||
/// own. Applied to every field above that carries free-form text straight
|
||||
/// from the import file rather than a count or a boolean — a base URL or an
|
||||
/// image name a hostile export author controls has had no validation done
|
||||
/// on it yet at preview time, and nothing stops it from being pathological
|
||||
/// (embedded control characters, or long enough to blow out the confirmation
|
||||
/// dialog and push the security warnings below it off screen).
|
||||
const MAX_PREVIEW_STRING_LEN: usize = 100;
|
||||
|
||||
fn sanitize_for_preview(value: &str) -> String {
|
||||
let cleaned: String = value.chars().filter(|c| !c.is_control()).collect();
|
||||
let trimmed = cleaned.trim();
|
||||
if trimmed.chars().count() > MAX_PREVIEW_STRING_LEN {
|
||||
let truncated: String = trimmed.chars().take(MAX_PREVIEW_STRING_LEN).collect();
|
||||
format!("{}…", truncated)
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsImportPreview {
|
||||
pub fn from_payload(payload: &SettingsExportPayload) -> Self {
|
||||
let non_blank = |s: &Option<String>| s.as_deref().is_some_and(|v| !v.trim().is_empty());
|
||||
let sanitized_non_blank = |s: &Option<String>| {
|
||||
s.as_deref()
|
||||
.map(sanitize_for_preview)
|
||||
.filter(|v| !v.is_empty())
|
||||
};
|
||||
Self {
|
||||
exported_at: payload.exported_at.clone(),
|
||||
app_version: payload.app_version.clone(),
|
||||
custom_env_var_count: payload.settings.global_custom_env_vars.len(),
|
||||
gateway_model_count: payload.settings.gateway.models.len(),
|
||||
has_claude_code_settings: payload.settings.global_claude_code_settings.is_some(),
|
||||
has_claude_oauth_token: non_blank(&payload.secrets.claude_oauth_token),
|
||||
has_gateway_api_key: non_blank(&payload.secrets.gateway_api_key),
|
||||
has_gateway_master_key: non_blank(&payload.secrets.gateway_master_key),
|
||||
has_web_terminal_access_token: non_blank(&payload.secrets.web_terminal_access_token),
|
||||
enables_web_terminal: payload.settings.web_terminal.enabled,
|
||||
ollama_base_url: sanitized_non_blank(&payload.settings.global_ollama.base_url),
|
||||
llamacpp_base_url: sanitized_non_blank(&payload.settings.global_llamacpp.base_url),
|
||||
openai_compatible_base_url: sanitized_non_blank(
|
||||
&payload.settings.global_openai_compatible.base_url,
|
||||
),
|
||||
gateway_api_base: sanitized_non_blank(&payload.settings.gateway.api_base),
|
||||
image_source: payload.settings.image_source.clone(),
|
||||
custom_image_name: sanitized_non_blank(&payload.settings.custom_image_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::AppSettings;
|
||||
|
||||
fn payload_with(secrets: ExportedSecrets) -> SettingsExportPayload {
|
||||
let settings = AppSettings {
|
||||
global_custom_env_vars: vec![
|
||||
crate::models::EnvVar {
|
||||
key: "A".to_string(),
|
||||
value: "1".to_string(),
|
||||
},
|
||||
crate::models::EnvVar {
|
||||
key: "B".to_string(),
|
||||
value: "2".to_string(),
|
||||
},
|
||||
],
|
||||
..AppSettings::default()
|
||||
};
|
||||
SettingsExportPayload {
|
||||
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
||||
app_version: "0.4.14".to_string(),
|
||||
settings,
|
||||
secrets,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preview_never_carries_a_secret_value() {
|
||||
let payload = payload_with(ExportedSecrets {
|
||||
claude_oauth_token: Some("sk-super-secret-token".to_string()),
|
||||
gateway_api_key: Some("sk-another-secret".to_string()),
|
||||
gateway_master_key: Some("sk-triple-c-yet-another".to_string()),
|
||||
web_terminal_access_token: Some("wt-super-secret-token".to_string()),
|
||||
});
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
let serialized = serde_json::to_string(&preview).unwrap();
|
||||
|
||||
assert!(!serialized.contains("sk-super-secret-token"));
|
||||
assert!(!serialized.contains("sk-another-secret"));
|
||||
assert!(!serialized.contains("sk-triple-c-yet-another"));
|
||||
assert!(!serialized.contains("wt-super-secret-token"));
|
||||
assert!(preview.has_claude_oauth_token);
|
||||
assert!(preview.has_gateway_api_key);
|
||||
assert!(preview.has_gateway_master_key);
|
||||
assert!(preview.has_web_terminal_access_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_secret_reads_as_absent_in_the_preview() {
|
||||
// A keychain entry that exists but holds only whitespace must not
|
||||
// read as "present" — same "blank counts as absent" rule the
|
||||
// keychain layer itself applies when storing these.
|
||||
let payload = payload_with(ExportedSecrets {
|
||||
claude_oauth_token: Some(" ".to_string()),
|
||||
gateway_api_key: None,
|
||||
gateway_master_key: None,
|
||||
web_terminal_access_token: Some(" ".to_string()),
|
||||
});
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert!(!preview.has_claude_oauth_token);
|
||||
assert!(!preview.has_gateway_api_key);
|
||||
assert!(!preview.has_gateway_master_key);
|
||||
assert!(!preview.has_web_terminal_access_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_the_web_terminal_is_surfaced_regardless_of_whether_a_token_came_with_it() {
|
||||
// `enabled` and the token are independent fields — a crafted export
|
||||
// could set one without the other, and both are worth a user's
|
||||
// attention: this is the field that exists specifically so "this
|
||||
// import turns on a service that listens on your network" cannot
|
||||
// hide inside a generic "settings replaced" summary.
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
payload.settings.web_terminal.enabled = true;
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert!(preview.enables_web_terminal);
|
||||
assert!(!preview.has_web_terminal_access_token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_base_urls_are_surfaced_but_blank_ones_read_as_absent() {
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
payload.settings.global_ollama.base_url = Some("http://attacker.example:11434".to_string());
|
||||
payload.settings.global_llamacpp.base_url = Some(" ".to_string());
|
||||
payload.settings.gateway.api_base = Some("https://gateway.example/v1".to_string());
|
||||
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(
|
||||
preview.ollama_base_url.as_deref(),
|
||||
Some("http://attacker.example:11434")
|
||||
);
|
||||
assert_eq!(preview.llamacpp_base_url, None);
|
||||
assert_eq!(preview.openai_compatible_base_url, None);
|
||||
assert_eq!(
|
||||
preview.gateway_api_base.as_deref(),
|
||||
Some("https://gateway.example/v1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_reflect_the_real_settings() {
|
||||
let payload = payload_with(ExportedSecrets::default());
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(preview.custom_env_var_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_secrets_bundle_reports_itself_as_empty() {
|
||||
assert!(ExportedSecrets::default().is_empty());
|
||||
assert!(!ExportedSecrets {
|
||||
claude_oauth_token: Some("x".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_secrets_bundle_holding_only_whitespace_still_reports_itself_as_empty() {
|
||||
// Matches the "blank counts as absent" rule every other consumer of
|
||||
// these fields applies (`has_claude_oauth_token` and friends above) —
|
||||
// a keychain entry that exists but holds only whitespace carries
|
||||
// nothing usable, so the export-time "nothing to export" log line
|
||||
// must still fire for it.
|
||||
assert!(ExportedSecrets {
|
||||
claude_oauth_token: Some(" ".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_docker_image_is_surfaced() {
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
payload.settings.image_source = crate::models::ImageSource::Custom;
|
||||
payload.settings.custom_image_name = Some("ghcr.io/attacker/triple-c:latest".to_string());
|
||||
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
assert_eq!(preview.image_source, crate::models::ImageSource::Custom);
|
||||
assert_eq!(
|
||||
preview.custom_image_name.as_deref(),
|
||||
Some("ghcr.io/attacker/triple-c:latest")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_strings_are_stripped_of_control_characters_and_capped_in_length() {
|
||||
let mut payload = payload_with(ExportedSecrets::default());
|
||||
payload.settings.global_ollama.base_url =
|
||||
Some(format!("http://example.test/{}\u{0007}bell", "x".repeat(200)));
|
||||
|
||||
let preview = SettingsImportPreview::from_payload(&payload);
|
||||
let shown = preview.ollama_base_url.expect("non-blank base url");
|
||||
assert!(!shown.contains('\u{0007}'), "control character leaked into the preview");
|
||||
// +1 for the trailing ellipsis appended when truncated.
|
||||
assert!(
|
||||
shown.chars().count() <= MAX_PREVIEW_STRING_LEN + 1,
|
||||
"preview string was not capped: {} chars",
|
||||
shown.chars().count()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,24 @@ pub struct GitHubRelease {
|
||||
pub body: String,
|
||||
pub assets: Vec<GitHubAsset>,
|
||||
pub published_at: String,
|
||||
/// Whether GitHub itself has this release marked as a prerelease.
|
||||
/// `#[serde(default)]` rather than required: every response GitHub sends
|
||||
/// carries this, but nothing here should refuse to parse the rest of a
|
||||
/// release over one missing field. Defaults to `false` (offered) rather
|
||||
/// than `true` (excluded) — a missing field only happens if GitHub's API
|
||||
/// shape changes, and "API changed, therefore updates silently stop
|
||||
/// working forever" is the worse failure of the two.
|
||||
///
|
||||
/// `build-app.yml`'s own mirror never publishes a prerelease, but
|
||||
/// `.gitea/workflows/backfill-releases.yml` forwards every Gitea release
|
||||
/// unfiltered, `prerelease` included. A preview release's `preview-<sha>`
|
||||
/// tag already fails semver parsing on its own, so this field is not what
|
||||
/// stops *that* case — it is what stops the case tag-parsing can't catch:
|
||||
/// a normally-tagged release (`v0.4.13`) that someone marks as a
|
||||
/// prerelease on Gitea (a hotfix candidate, an RC) and a backfill then
|
||||
/// mirrors as-is. Real defence for that case, not a no-op.
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
}
|
||||
|
||||
/// GitHub API asset response (internal).
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Per-project mutual exclusion for everything that rewrites a project's
|
||||
//! container or its snapshot image.
|
||||
//!
|
||||
//! ## Why polling was not enough
|
||||
//!
|
||||
//! Until this module existed the app had exactly one mutual-exclusion
|
||||
//! primitive — the `ACTIVE_MIGRATIONS` set behind
|
||||
//! `migration_commands::is_migrating` — and it was **one-way**. A migration
|
||||
//! took a guard for its whole run; everything else merely *asked once, at
|
||||
//! entry*, whether a migration was in flight and then proceeded with no claim
|
||||
//! of its own. Two non-migration operations on the same project could not see
|
||||
//! each other at all, and a migration could start underneath one that was
|
||||
//! already halfway through.
|
||||
//!
|
||||
//! That is not a theoretical gap. Compaction resolves
|
||||
//! `triple-c-snapshot-{id}:latest` when its build starts and commits back over
|
||||
//! that same tag minutes later, and the Settings panel is a sidebar rather than
|
||||
//! a modal — so Project Home stays live with Start, Stop, Reset and Migrate all
|
||||
//! clickable while a compaction runs. Three interleavings were reproduced:
|
||||
//!
|
||||
//! * Compaction commits `flat(A)` over `:latest` after a migration has already
|
||||
//! moved that tag to a new lineage. The migration is silently reverted, the
|
||||
//! config replay lands twice, and the migration record says
|
||||
//! `awaiting-confirmation` against a base the tag no longer points at.
|
||||
//! * Compaction resolves A, the user starts the project and works for an hour,
|
||||
//! a recreate commits D over `:latest`, and the compaction then overwrites it
|
||||
//! with `flat(A)` — orphaning an hour of system-layer work while reporting
|
||||
//! success and a byte saving.
|
||||
//! * Compaction resurrects the system layer a Reset had just destroyed.
|
||||
//!
|
||||
//! Every one of those is "two writers of `:latest`, neither holding anything".
|
||||
//! So this registry replaces the polling with an actual claim: an operation
|
||||
//! **acquires** a [`ProjectGuard`] and holds it for its whole run, and a second
|
||||
//! operation on the same project is refused with a message naming the holder.
|
||||
//!
|
||||
//! ## What this does NOT protect against, stated plainly
|
||||
//!
|
||||
//! **This is in-process state.** Two copies of the app pointed at the same
|
||||
//! Docker daemon share nothing here: instance A's compaction and instance B's
|
||||
//! migration will both acquire happily and then race exactly as before.
|
||||
//! `reap_probe_containers` and the `triple-c-compact-*` / `triple-c-scrub-*`
|
||||
//! sweeps are worse than that — they are daemon-wide force-removals driven by
|
||||
//! a name or a label, so instance B can destroy a container instance A is
|
||||
//! mid-commit against.
|
||||
//!
|
||||
//! A daemon-visible lock was considered and rejected for now, and the reasoning
|
||||
//! is recorded here so it is not re-derived from scratch:
|
||||
//!
|
||||
//! * A **lock container** would work — container names are unique daemon-wide
|
||||
//! and `create` fails atomically on a name conflict — but a container has to
|
||||
//! be created *from an image*, and that pins the image. A lock on
|
||||
//! `triple-c-snapshot-{id}` would block the very `rmi`/sweep paths it guards,
|
||||
//! and a leaked lock container would pin multiple gigabytes forever.
|
||||
//! * A **named volume** is not usable: `create_volume` on an existing name
|
||||
//! returns the existing volume rather than failing, so it cannot be a
|
||||
//! test-and-set.
|
||||
//! * A **label on the snapshot image** is not atomic — read/modify/commit has
|
||||
//! the same race it would be trying to close.
|
||||
//!
|
||||
//! So the cross-process case is **documented, not solved**. What this module
|
||||
//! does do about it is bound the damage: [`any_held_excluding`] lets the daemon-wide
|
||||
//! reapers skip work while this process is mid-operation, and the reapers
|
||||
//! themselves gained age gates so a young container belonging to somebody else
|
||||
//! is left alone (see `docker::migration::reap_probe_containers`).
|
||||
//!
|
||||
//! ## Refuse, do not queue
|
||||
//!
|
||||
//! [`try_acquire`] never waits. Every caller is a user-initiated action behind
|
||||
//! a button, and a button that blocks for the four minutes a compaction takes
|
||||
//! is worse than one that says what is running. The refusal string is written
|
||||
//! for the user and names the holder.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// The operations that claim a project.
|
||||
///
|
||||
/// One variant per *class of writer*, not per command: `Recreate` covers Start
|
||||
/// as well, because Start's create-and-commit path is the same writer of
|
||||
/// `triple-c-snapshot-{id}:latest` that a recreate is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProjectOp {
|
||||
/// `migrate_project_to_base`, `resume_migration`, `rollback_migration`,
|
||||
/// `confirm_migration`.
|
||||
Migration,
|
||||
/// `disk::compact_snapshot` — the long one, and the reason this exists.
|
||||
///
|
||||
/// Not constructed on this branch: the Disk panel and its compaction were
|
||||
/// held back for separate hardening and live on `hold/disk-and-dragout`.
|
||||
/// The variant stays because this registry is the thing that made those
|
||||
/// operations safe to re-land, and a re-land that had to re-derive the
|
||||
/// claim classes would be re-deriving the bug.
|
||||
#[allow(dead_code)]
|
||||
Compaction,
|
||||
/// Start / stop / recreate. Anything in `start_project_container`'s path.
|
||||
Recreate,
|
||||
/// `rebuild_project_container` — deletes both volumes and the snapshot.
|
||||
Reset,
|
||||
/// `disk::destroy` — a volume, a snapshot image, or a rollback pin.
|
||||
Destroy,
|
||||
/// `disk::clear_caches` — an exec into the live container. It does not
|
||||
/// write `:latest`, but it must not run while the container is being
|
||||
/// removed out from under it.
|
||||
///
|
||||
/// Not constructed on this branch, for the same reason as
|
||||
/// [`ProjectOp::Compaction`].
|
||||
#[allow(dead_code)]
|
||||
CacheClear,
|
||||
/// `container::scrub_secrets_from_snapshots` — the third writer of
|
||||
/// `triple-c-snapshot-{id}:latest`, reached from `clear_claude_token`. It
|
||||
/// creates a scratch container from the snapshot and commits back over the
|
||||
/// same tag, so it is the same read-modify-write shape as a compaction and
|
||||
/// loses the same race: any `:latest` move landing between its create and
|
||||
/// its commit is overwritten by an image derived from the pre-read state.
|
||||
SecretScrub,
|
||||
}
|
||||
|
||||
impl ProjectOp {
|
||||
/// What is happening, phrased for the message a user reads.
|
||||
pub fn describe(self) -> &'static str {
|
||||
match self {
|
||||
ProjectOp::Migration => "A container base update is running for this project",
|
||||
ProjectOp::Compaction => "This project's snapshot is being compacted",
|
||||
ProjectOp::Recreate => "This project's container is being started or recreated",
|
||||
ProjectOp::Reset => "This project is being reset",
|
||||
ProjectOp::Destroy => "Something of this project's is being deleted",
|
||||
ProjectOp::CacheClear => "This project's caches are being cleared",
|
||||
ProjectOp::SecretScrub => "A revoked credential is being removed from this project's snapshot",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the *refused* caller was trying to do, for the tail of the message.
|
||||
fn blocked_action(self) -> &'static str {
|
||||
match self {
|
||||
ProjectOp::Migration => "starting a base update",
|
||||
ProjectOp::Compaction => "compacting its snapshot",
|
||||
ProjectOp::Recreate => "starting or recreating its container",
|
||||
ProjectOp::Reset => "resetting it",
|
||||
ProjectOp::Destroy => "deleting anything of its",
|
||||
ProjectOp::CacheClear => "clearing its caches",
|
||||
ProjectOp::SecretScrub => "removing a credential from its snapshot",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Project id → the operation currently holding it.
|
||||
///
|
||||
/// A `std::sync::Mutex` rather than a `tokio` one on purpose: it is only ever
|
||||
/// held for the length of a `HashMap` insert or remove, never across an await,
|
||||
/// and [`is_held_by`] has to be callable from the synchronous helpers in
|
||||
/// `disk.rs` that already ask this question.
|
||||
static HOLDERS: OnceLock<Mutex<HashMap<String, ProjectOp>>> = OnceLock::new();
|
||||
|
||||
fn holders() -> &'static Mutex<HashMap<String, ProjectOp>> {
|
||||
HOLDERS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// A claim on one project, released on drop.
|
||||
///
|
||||
/// RAII rather than an explicit release for the reason [`ProjectOp::Migration`]'s
|
||||
/// predecessor already learned: a plain release statement is skipped by an
|
||||
/// early `?`, by a panic, and by the future simply being dropped. A guard is
|
||||
/// not.
|
||||
/// Dropping this releases the claim, so a caller that discards it has taken no
|
||||
/// lock at all — `let _ = try_acquire(...)` drops immediately and reads as
|
||||
/// success. `#[must_use]` makes that a compile warning rather than a race.
|
||||
#[must_use = "the claim is released as soon as this guard is dropped; bind it for the whole operation"]
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectGuard {
|
||||
project_id: String,
|
||||
}
|
||||
|
||||
impl Drop for ProjectGuard {
|
||||
fn drop(&mut self) {
|
||||
// `into_inner` on a poisoned lock: a panic while some other thread held
|
||||
// this map for the duration of one insert cannot have left it
|
||||
// inconsistent, and refusing to release afterwards would strand the
|
||||
// project as permanently busy.
|
||||
holders()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.remove(&self.project_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim a project for `op`, or say who has it.
|
||||
///
|
||||
/// The error is user-facing copy, not a debug string — it goes straight back
|
||||
/// over IPC to a toast.
|
||||
pub fn try_acquire(project_id: &str, op: ProjectOp) -> Result<ProjectGuard, String> {
|
||||
let mut map = holders().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(holder) = map.get(project_id).copied() {
|
||||
return Err(format!(
|
||||
"{}. Wait for it to finish before {}.",
|
||||
holder.describe(),
|
||||
op.blocked_action()
|
||||
));
|
||||
}
|
||||
map.insert(project_id.to_string(), op);
|
||||
Ok(ProjectGuard {
|
||||
project_id: project_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Which operation holds this project, if any.
|
||||
pub fn held(project_id: &str) -> Option<ProjectOp> {
|
||||
holders()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(project_id)
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Whether this project is held by exactly `op`.
|
||||
///
|
||||
/// `migration_commands::is_migrating` is this, specialised — which is the whole
|
||||
/// point of folding `ACTIVE_MIGRATIONS` into this registry: there is now one
|
||||
/// answer to "is something happening to this project", not two that can
|
||||
/// disagree.
|
||||
pub fn is_held_by(project_id: &str, op: ProjectOp) -> bool {
|
||||
held(project_id) == Some(op)
|
||||
}
|
||||
|
||||
/// Whether any project **other than** `exclude_project_id` is currently held by
|
||||
/// `op`. Pass an empty id to ask about every project.
|
||||
///
|
||||
/// Used by the daemon-wide reapers, which cannot tell which project a
|
||||
/// `triple-c-compact-*` container belongs to — the name carries a random uuid,
|
||||
/// not a project id — so "is this process compacting anything right now" is the
|
||||
/// only in-process question they can ask before force-removing one. The
|
||||
/// exclusion is for the reaper that runs *inside* a compaction, which is
|
||||
/// already holding a claim of its own and would otherwise see it and skip.
|
||||
///
|
||||
/// No production caller on this branch: the compaction reaper it was written
|
||||
/// for went to `hold/disk-and-dragout` with the rest of the Disk panel. Kept
|
||||
/// (and still tested) because it is the only bound this module offers on the
|
||||
/// cross-process case documented above.
|
||||
#[allow(dead_code)]
|
||||
pub fn any_held_excluding(op: ProjectOp, exclude_project_id: &str) -> bool {
|
||||
holders()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.any(|(project_id, held)| *held == op && project_id != exclude_project_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Ids are namespaced per test: the registry is process-global, and
|
||||
/// `cargo test` runs these on several threads at once.
|
||||
fn id(name: &str) -> String {
|
||||
format!("project-lock-test-{}", name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_acquire_on_the_same_project_is_refused() {
|
||||
let p = id("second-acquire");
|
||||
let first = try_acquire(&p, ProjectOp::Compaction).expect("first claim");
|
||||
let second = try_acquire(&p, ProjectOp::Recreate);
|
||||
let err = second.expect_err("a second claim must be refused, not queued");
|
||||
// The refusal has to name the holder — "busy" alone leaves the user
|
||||
// with nothing to wait for.
|
||||
assert!(err.contains("snapshot is being compacted"), "{}", err);
|
||||
assert!(err.contains("starting or recreating"), "{}", err);
|
||||
drop(first);
|
||||
// And it has to be retakeable the moment the holder goes away. Bound
|
||||
// rather than discarded: `#[must_use]` is what stops a real caller
|
||||
// writing `try_acquire(...)` and believing it holds something.
|
||||
let retaken = try_acquire(&p, ProjectOp::Recreate).expect("released on drop");
|
||||
drop(retaken);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guard_releases_on_an_early_return() {
|
||||
let p = id("early-return");
|
||||
fn bails(project_id: &str) -> Result<(), String> {
|
||||
let _guard = try_acquire(project_id, ProjectOp::Reset)?;
|
||||
Err("something failed".to_string())
|
||||
}
|
||||
assert!(bails(&p).is_err());
|
||||
assert_eq!(held(&p), None, "an early `?` must not strand the claim");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guard_releases_on_a_panic() {
|
||||
let p = id("panic");
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let _guard = try_acquire(&id("panic"), ProjectOp::Migration).unwrap();
|
||||
panic!("boom");
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(held(&p), None, "a panic must not strand the claim either");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_projects_do_not_block_each_other() {
|
||||
let a = id("independent-a");
|
||||
let b = id("independent-b");
|
||||
let _one = try_acquire(&a, ProjectOp::Compaction).expect("a");
|
||||
let _two = try_acquire(&b, ProjectOp::Compaction).expect("b");
|
||||
assert!(is_held_by(&a, ProjectOp::Compaction));
|
||||
assert!(is_held_by(&b, ProjectOp::Compaction));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_held_by_distinguishes_the_operation() {
|
||||
let p = id("which-op");
|
||||
let _guard = try_acquire(&p, ProjectOp::Compaction).unwrap();
|
||||
assert!(is_held_by(&p, ProjectOp::Compaction));
|
||||
assert!(
|
||||
!is_held_by(&p, ProjectOp::Migration),
|
||||
"a compaction is not a migration — `is_migrating` is built on this"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_held_sees_across_projects() {
|
||||
let p = id("any-held");
|
||||
assert!(!any_held_excluding(ProjectOp::Destroy, ""));
|
||||
let _guard = try_acquire(&p, ProjectOp::Destroy).unwrap();
|
||||
assert!(any_held_excluding(ProjectOp::Destroy, ""));
|
||||
// …and a holder can ask the question without its own claim answering
|
||||
// it, which is what lets a compaction sweep leftovers before it starts.
|
||||
assert!(!any_held_excluding(ProjectOp::Destroy, &p));
|
||||
}
|
||||
|
||||
/// Concurrency, not just sequencing: N threads racing for one project must
|
||||
/// produce exactly one winner.
|
||||
#[test]
|
||||
fn exactly_one_of_many_racing_threads_wins() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
let p = id("race");
|
||||
let start = Arc::new(std::sync::Barrier::new(8));
|
||||
// The second barrier is what makes this deterministic rather than
|
||||
// merely likely: no winner releases until every thread has had its
|
||||
// turn, so "only one got in" cannot be an artefact of a loser arriving
|
||||
// after the winner already left.
|
||||
let attempted = Arc::new(std::sync::Barrier::new(8));
|
||||
let won = Arc::new(AtomicUsize::new(0));
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let start = Arc::clone(&start);
|
||||
let attempted = Arc::clone(&attempted);
|
||||
let won = Arc::clone(&won);
|
||||
let p = p.clone();
|
||||
handles.push(std::thread::spawn(move || {
|
||||
start.wait();
|
||||
let claim = try_acquire(&p, ProjectOp::Compaction);
|
||||
if claim.is_ok() {
|
||||
won.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
attempted.wait();
|
||||
drop(claim);
|
||||
}));
|
||||
}
|
||||
for handle in handles {
|
||||
handle.join().unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
won.load(Ordering::SeqCst),
|
||||
1,
|
||||
"eight threads raced for one project and more than one got in"
|
||||
);
|
||||
assert_eq!(held(&p), None);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,29 @@ fn sanitize(project_id: &str) -> String {
|
||||
/// Read a project's migration state. `Ok(None)` means no migration is in
|
||||
/// flight; an unparseable file is treated the same way (and logged) rather than
|
||||
/// blocking every future migration on a corrupt record.
|
||||
///
|
||||
/// **A corrupt record is copied aside and left in place.** An earlier version
|
||||
/// *renamed* it to `.bak`, on the reasoning that a file nothing can parse
|
||||
/// should stop making the project look busy. That destroyed the one signal
|
||||
/// [`has_record`] exists to carry. The chain, in order:
|
||||
///
|
||||
/// 1. The rename makes the file vanish, so `has_record` — pure filesystem
|
||||
/// presence — flips to false.
|
||||
/// 2. `reconcile_migration` calls this, gets `Ok(None)`, and returns. An
|
||||
/// in-flight or interrupted migration becomes invisible: no resume offer, no
|
||||
/// rollback offer, and the phase is never normalised.
|
||||
/// 3. Both pin reapers use `has_record` as their conservative guard, so the
|
||||
/// project's `:pre-migration-*` tag — the only copy of its pre-migration
|
||||
/// system layer — is now "ownerless" to both of them, and the startup sweep
|
||||
/// turns the untag into a deletion.
|
||||
///
|
||||
/// A record that cannot be parsed is exactly the case where the *most*
|
||||
/// conservative answer is wanted, not the least. So the bytes are copied to a
|
||||
/// **uniquely named** backup (a fixed `.bak` meant a second corruption silently
|
||||
/// overwrote the first, and nothing ever read either back) and the original
|
||||
/// stays where it is. The pin it describes then ages out through the ownerless
|
||||
/// tombstone in `docker::migration::reap_stale_migration_pins` rather than
|
||||
/// being reaped on the next app start.
|
||||
pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||||
let path = state_path(project_id)?;
|
||||
if !path.exists() {
|
||||
@@ -59,27 +82,326 @@ pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||||
match serde_json::from_str::<MigrationState>(&data) {
|
||||
Ok(state) => Ok(Some(state)),
|
||||
Err(e) => {
|
||||
// Three outcomes, and they must not be conflated: a copy was made,
|
||||
// a copy was deliberately not made, or a copy failed. The previous
|
||||
// version folded "already kept enough" into `Ok(())` and then told
|
||||
// the user "a copy was kept at <path>" — naming a file that was
|
||||
// never created. A message that invents a backup is worse than no
|
||||
// message, because it is what someone reads before going to look
|
||||
// for their data.
|
||||
let backup = corrupt_backup_path(&path, &chrono::Utc::now());
|
||||
let kept = if backup.exists() {
|
||||
Kept::AlreadyThere
|
||||
} else if corrupt_backups_full(&path) {
|
||||
Kept::EnoughAlready(MAX_CORRUPT_BACKUPS)
|
||||
} else {
|
||||
match fs::copy(&path, &backup) {
|
||||
Ok(_) => Kept::Copied,
|
||||
Err(e) => Kept::Failed(e.to_string()),
|
||||
}
|
||||
};
|
||||
log::error!(
|
||||
"Failed to parse migration state for project {}: {} — treating as absent",
|
||||
"Failed to parse migration state for project {}: {} — treating as absent, but \
|
||||
the record is left in place so `has_record` still protects its rollback pin{}",
|
||||
project_id,
|
||||
e
|
||||
e,
|
||||
match kept {
|
||||
Kept::Copied | Kept::AlreadyThere =>
|
||||
format!(" (a copy is at {})", backup.display()),
|
||||
// The earliest copies are the ones worth having, so the cap
|
||||
// keeps those and drops this one. Say so, rather than
|
||||
// implying a file exists.
|
||||
Kept::EnoughAlready(n) => format!(
|
||||
" (no copy kept — {} earlier copies of this record are already saved \
|
||||
alongside it)",
|
||||
n
|
||||
),
|
||||
Kept::Failed(ref e) => format!(" (could not keep a copy: {})", e),
|
||||
}
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically write a project's migration state.
|
||||
/// What [`load`] did about a copy of an unparseable record, so the log line can
|
||||
/// tell the truth about whether a file exists.
|
||||
enum Kept {
|
||||
Copied,
|
||||
/// This exact second's copy was already on disk.
|
||||
AlreadyThere,
|
||||
/// The cap is reached; the earlier copies are kept and this one is not.
|
||||
EnoughAlready(usize),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Where a copy of an unparseable record is kept.
|
||||
///
|
||||
/// Timestamped rather than a fixed `.bak`: a second corruption used to
|
||||
/// overwrite the first, so the one case where the user's bytes matter most was
|
||||
/// the case where they were most likely to be gone.
|
||||
fn corrupt_backup_path(path: &std::path::Path, now: &chrono::DateTime<chrono::Utc>) -> PathBuf {
|
||||
path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
|
||||
}
|
||||
|
||||
/// How many timestamped copies of one project's corrupt record are kept.
|
||||
///
|
||||
/// Timestamping fixed the "second corruption overwrote the first" bug and
|
||||
/// introduced its opposite: [`load`] runs on every reconcile, every survey and
|
||||
/// every reaper pass, so a record that is *persistently* unparseable — the
|
||||
/// normal case, since nothing repairs it — mints a new copy every time the
|
||||
/// clock's second changes. Nothing ever reads them back and nothing ever
|
||||
/// removed them.
|
||||
///
|
||||
/// Four is enough for the only use there is: a human looking at what the file
|
||||
/// held. See [`corrupt_backups_full`] for why the cap is applied before the
|
||||
/// copy rather than by pruning after it.
|
||||
const MAX_CORRUPT_BACKUPS: usize = 4;
|
||||
|
||||
/// Whether [`MAX_CORRUPT_BACKUPS`] copies of this record already exist.
|
||||
///
|
||||
/// Asked *before* the copy rather than pruning after it, so the cap is not
|
||||
/// implemented by writing a file and deleting it again on every pass — and so
|
||||
/// the copies that survive are the oldest, which are the ones taken closest to
|
||||
/// whatever produced the corruption.
|
||||
///
|
||||
/// A directory that cannot be listed answers "not full": failing open here
|
||||
/// costs at most one extra file, and failing closed would drop the very first
|
||||
/// copy of a record nothing else has kept.
|
||||
fn corrupt_backups_full(path: &std::path::Path) -> bool {
|
||||
let (Some(dir), Some(stem)) = (path.parent(), path.file_stem()) else {
|
||||
return false;
|
||||
};
|
||||
// `{stem}.json.corrupt-` — the same shape `corrupt_backup_path` builds, so
|
||||
// this can never match another project's copies or an unrelated `.bak`.
|
||||
let prefix = format!("{}.json.corrupt-", stem.to_string_lossy());
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
name.starts_with(&prefix) && name.ends_with(".bak")
|
||||
})
|
||||
.count()
|
||||
>= MAX_CORRUPT_BACKUPS
|
||||
}
|
||||
|
||||
/// Whether a project has a migration record on disk *at all*, without parsing
|
||||
/// it.
|
||||
///
|
||||
/// The pin reaper needs "is this project's rollback image still somebody's only
|
||||
/// copy?" and must answer it conservatively. [`load`] cannot be used for that
|
||||
/// question on its own — it deliberately reports a corrupt record as absent —
|
||||
/// so this asks the filesystem instead. `load` moving a corrupt record aside is
|
||||
/// what keeps the two answers from disagreeing forever.
|
||||
pub fn has_record(project_id: &str) -> Result<bool, String> {
|
||||
Ok(state_path(project_id)?.exists())
|
||||
}
|
||||
|
||||
/// Atomically **and durably** write a project's migration state.
|
||||
///
|
||||
/// Write-temp-then-rename alone is only half of it, and the missing half is the
|
||||
/// half this record exists for. `fs::write` returns once the bytes are in the
|
||||
/// page cache; a rename over them is atomic *with respect to other readers*,
|
||||
/// not with respect to power loss. Losing power in that window leaves the
|
||||
/// rename applied and the data not yet written — i.e. a 0-byte or truncated
|
||||
/// `{id}.json` — which is precisely the corrupt-record case above, produced by
|
||||
/// the code whose job is to make that case impossible.
|
||||
///
|
||||
/// So: fsync the file before the rename, and fsync the *directory* after it,
|
||||
/// because the rename itself is directory metadata and is not durable until the
|
||||
/// directory is synced. A sync that fails is reported rather than swallowed —
|
||||
/// this is the crash record, and "probably written" is not a state it may be
|
||||
/// in.
|
||||
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
|
||||
let path = state_path(project_id)?;
|
||||
let data = serde_json::to_string_pretty(state)
|
||||
.map_err(|e| format!("Failed to serialize migration state: {}", e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, data).map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file = fs::File::create(&tmp)
|
||||
.map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||
file.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write migration state: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to flush migration state to disk: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp, &path).map_err(|e| format!("Failed to commit migration state: {}", e))?;
|
||||
sync_dir(&path);
|
||||
// A project with a record is not ownerless, whatever a reaper concluded
|
||||
// before this write — so the grace clock is thrown away rather than left to
|
||||
// expire against a pin that now has an owner again.
|
||||
clear_ownerless_for_project(project_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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 returns an error for the attempt, so a failure
|
||||
/// is logged rather than propagated. The file's own `sync_all` above is the
|
||||
/// part that carries the data, and it is not best effort.
|
||||
fn sync_dir(path: &std::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 migrations directory {}: {} — the record itself was flushed",
|
||||
dir.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ownerless-pin tombstones
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Marker recording **when a rollback pin was first seen with no record behind
|
||||
/// it**.
|
||||
///
|
||||
/// ## Why the grace period cannot be measured from the tag
|
||||
///
|
||||
/// `docker::migration::pin_is_reapable` used to date a pin from the timestamp
|
||||
/// encoded in `pre-migration-<YYYYmmdd-HHMMSS>` — i.e. from when the migration
|
||||
/// *started*. That is the wrong epoch by a whole feature. A migration is
|
||||
/// allowed to sit at `awaiting-confirmation` indefinitely; `keep_rollback`
|
||||
/// exists precisely so a user can run on the new base for a month before
|
||||
/// deciding. If that project's record is then lost — a corrupt file, a deleted
|
||||
/// state file, a half-restored data directory — the pin is fourteen days old on
|
||||
/// the very first check, so it is untagged on the next app start and the
|
||||
/// startup sweep deletes the image two lines later. The fourteen-day grace
|
||||
/// period the constant promises is zero in the only situation it was written
|
||||
/// for.
|
||||
///
|
||||
/// The clock has to start when the *claim* was lost, and nothing on the daemon
|
||||
/// records that moment. So it is written down here, the first time a reaper
|
||||
/// notices, and the age is measured from the marker.
|
||||
///
|
||||
/// One file per `(project_id, tag)` in the migrations directory, holding an
|
||||
/// RFC3339 instant. Tiny, and losing one costs a fresh fourteen days rather
|
||||
/// than a deletion — the failure direction that keeps somebody's only rollback
|
||||
/// copy.
|
||||
fn ownerless_marker_path(project_id: &str, tag: &str) -> Result<PathBuf, String> {
|
||||
Ok(migrations_dir()?.join(format!(
|
||||
"{}.{}.ownerless",
|
||||
sanitize(project_id),
|
||||
sanitize(tag)
|
||||
)))
|
||||
}
|
||||
|
||||
/// Read the first-observed instant for a pin, creating the marker if this is
|
||||
/// the first sighting. Returns `None` when the clock has not started yet.
|
||||
///
|
||||
/// **Clock skew is handled here rather than at the comparison.** A host clock
|
||||
/// that was running fast when the marker was written leaves a timestamp in the
|
||||
/// future; measured naively that is a negative age, which a `num_days() >= 14`
|
||||
/// test reads as "never reapable" — a pin that can never be collected, forever.
|
||||
/// A marker dated after `now` is therefore rewritten to `now`, restarting the
|
||||
/// grace period. The other direction — a clock jumping forward — cannot shorten
|
||||
/// the period below what has actually elapsed on the *marker's* terms, because
|
||||
/// there is nothing to compare against but wall time; what it cannot do any
|
||||
/// more is make every pin instantly reapable, which dating from the tag did.
|
||||
///
|
||||
/// ## Why the write re-checks `has_record`
|
||||
///
|
||||
/// Both reapers ask [`has_record`] and only call this when the answer is no,
|
||||
/// which leaves a window: a [`save`] landing between the two runs its
|
||||
/// `clear_ownerless_for_project` against a marker that does not exist yet, and
|
||||
/// this then plants one — dated *now* — behind a perfectly valid record. The
|
||||
/// marker is invisible while the record stands, so nothing notices. It only
|
||||
/// matters later, if that record is legitimately lost: the pin is then already
|
||||
/// fourteen days ownerless on its very first check and is reaped with **zero**
|
||||
/// grace, which is the exact failure the tombstone exists to prevent.
|
||||
///
|
||||
/// So the write is followed by a second `has_record`, and a marker that turns
|
||||
/// out to sit behind a record is removed again. The two orderings that remain
|
||||
/// are both safe: a `save` completing *after* this re-check clears the marker
|
||||
/// itself, and one completing before it is what the re-check sees.
|
||||
pub fn note_ownerless_since(
|
||||
project_id: &str,
|
||||
tag: &str,
|
||||
now: &chrono::DateTime<chrono::Utc>,
|
||||
) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
let path = ownerless_marker_path(project_id, tag).ok()?;
|
||||
let existing = fs::read_to_string(&path).ok().and_then(|raw| {
|
||||
chrono::DateTime::parse_from_rfc3339(raw.trim())
|
||||
.ok()
|
||||
.map(|t| t.with_timezone(&chrono::Utc))
|
||||
});
|
||||
match existing {
|
||||
Some(seen) if seen <= *now => Some(seen),
|
||||
// Absent, unparseable, or dated in the future: (re)start the clock.
|
||||
_ => {
|
||||
if let Err(e) = fs::write(&path, now.to_rfc3339()) {
|
||||
log::warn!(
|
||||
"Could not record that rollback pin {}:{} is ownerless: {} — its grace \
|
||||
period restarts on the next check",
|
||||
project_id,
|
||||
tag,
|
||||
e
|
||||
);
|
||||
return None;
|
||||
}
|
||||
// A record that appeared while this was being written owns the pin,
|
||||
// and a tombstone behind an owned pin is a fourteen-day head start
|
||||
// on reaping it the moment that record is next lost.
|
||||
if has_record(project_id).unwrap_or(false) {
|
||||
log::debug!(
|
||||
"A migration record for {} appeared while marking {} ownerless; \
|
||||
the marker was dropped again",
|
||||
project_id,
|
||||
tag
|
||||
);
|
||||
clear_ownerless(project_id, tag);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a pin's ownerless marker. Missing is success.
|
||||
///
|
||||
/// Called when the pin is untagged, and when a record reappears for the
|
||||
/// project — a re-migrated project must not inherit the previous run's clock.
|
||||
pub fn clear_ownerless(project_id: &str, tag: &str) {
|
||||
let Ok(path) = ownerless_marker_path(project_id, tag) else {
|
||||
return;
|
||||
};
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => log::warn!("Could not remove {}: {}", path.display(), e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every ownerless marker belonging to one project.
|
||||
///
|
||||
/// A project that has a record again is by definition not ownerless, whatever
|
||||
/// a reaper concluded before.
|
||||
pub fn clear_ownerless_for_project(project_id: &str) {
|
||||
let Ok(dir) = migrations_dir() else {
|
||||
return;
|
||||
};
|
||||
let prefix = format!("{}.", sanitize(project_id));
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with(&prefix) && name.ends_with(".ownerless") {
|
||||
let _ = fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a project's migration state file. Missing is success.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
let path = state_path(project_id)?;
|
||||
@@ -104,6 +426,39 @@ pub fn clear_staging(project_id: &str) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn corrupt_copies_of_one_record_are_capped() {
|
||||
// `load` runs on every reconcile, every survey and every reaper pass,
|
||||
// and nothing repairs an unparseable record — so a persistently corrupt
|
||||
// one minted a new timestamped copy every time the clock's second
|
||||
// changed, and nothing ever removed them.
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-corrupt-cap-{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("temp dir");
|
||||
let record = dir.join("some-project.json");
|
||||
|
||||
assert!(!corrupt_backups_full(&record), "an empty directory is not full");
|
||||
for n in 0..MAX_CORRUPT_BACKUPS {
|
||||
fs::write(
|
||||
dir.join(format!("some-project.json.corrupt-2026010{}-000000.bak", n)),
|
||||
"x",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(corrupt_backups_full(&record));
|
||||
|
||||
// Another project's copies, and an unrelated `.bak`, are not this
|
||||
// record's — the prefix is the whole point of the naming.
|
||||
let other = dir.join("other-project.json");
|
||||
assert!(!corrupt_backups_full(&other));
|
||||
fs::write(dir.join("some-project.json.bak"), "x").unwrap();
|
||||
assert!(!corrupt_backups_full(&other));
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_migrations_directory() {
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
@@ -115,4 +470,43 @@ mod tests {
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
|
||||
/// The log line must not name a backup that was never written.
|
||||
///
|
||||
/// `load` runs on every reconcile, every survey and every reaper pass, so a
|
||||
/// persistently corrupt record hits the `MAX_CORRUPT_BACKUPS` cap within
|
||||
/// seconds. The previous code folded "already kept enough" into `Ok(())`
|
||||
/// and then reported " (a copy was kept at <path>)" — pointing at a file
|
||||
/// that does not exist. That is the message someone reads immediately
|
||||
/// before going to look for their data.
|
||||
#[test]
|
||||
fn the_corrupt_record_message_only_claims_a_copy_that_exists() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"tc-mstore-{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("p.json");
|
||||
std::fs::write(&path, b"{ not json").unwrap();
|
||||
|
||||
// Fill the cap with copies that really are on disk.
|
||||
for i in 0..MAX_CORRUPT_BACKUPS {
|
||||
let b = path.with_extension(format!("json.corrupt-2026010{}-000000.bak", i));
|
||||
std::fs::write(&b, b"{ not json").unwrap();
|
||||
}
|
||||
assert!(corrupt_backups_full(&path), "precondition: the cap is reached");
|
||||
|
||||
// With the cap reached, no new copy may be created — and that is the
|
||||
// state in which the old message lied.
|
||||
let before: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
|
||||
let fresh = corrupt_backup_path(&path, &chrono::Utc::now());
|
||||
assert!(
|
||||
!fresh.exists(),
|
||||
"the cap is reached, so this timestamped copy must not be written"
|
||||
);
|
||||
let after: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
|
||||
assert_eq!(before.len(), after.len(), "nothing new appeared on disk");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod migration_store;
|
||||
pub mod pending_cleanup;
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
pub mod settings_crypto;
|
||||
pub mod settings_store;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Host-side record of Docker resources `remove_project` could not delete.
|
||||
//!
|
||||
//! `remove_project` drops a project's id from `projects.json` unconditionally
|
||||
//! — see the comment on `ProjectRemovalReport` — so once that happens nothing
|
||||
//! in the app can name the leftover container, image or volume again by any
|
||||
//! path a user can reach. This is what keeps it reachable anyway: one JSON
|
||||
//! file per affected project under `<data_dir>/triple-c/pending-cleanup/`,
|
||||
//! written *before* the project record is dropped. Startup housekeeping
|
||||
//! retries every record on the next launch (see
|
||||
//! `commands::project_commands::retry_pending_cleanup_logged`) and deletes
|
||||
//! the ones that fully succeed.
|
||||
//!
|
||||
//! **This record is written in the same instant its record in `projects.json`
|
||||
//! is destroyed, and it is the only remaining handle on the leftover
|
||||
//! resource** — which is a stronger claim on durability than an ordinary
|
||||
//! write-temp-then-rename gives. `storage::migration_store::save` carries the
|
||||
//! same reasoning for the migration state file: `fs::write` returns once the
|
||||
//! bytes are in the page cache, and a rename over them is atomic with respect
|
||||
//! to other readers, not to power loss. A crash in that window leaves the
|
||||
//! rename applied and the data half-written, which [`list`] then treats as
|
||||
//! unparseable and skips — reproducing the exact bug this module exists to
|
||||
//! close, silently, with only a startup log line as evidence. So `save` here
|
||||
//! takes the same `File::create` → `write_all` → `sync_all` → `rename` →
|
||||
//! directory-sync shape `migration_store` does.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingCleanup {
|
||||
pub project_id: String,
|
||||
/// Kept only so a log line or a future UI can name the project without a
|
||||
/// second lookup — the project record itself is already gone by the time
|
||||
/// this is read back.
|
||||
pub project_name: String,
|
||||
/// The project's container, if it could not be removed. Named by its
|
||||
/// deterministic `triple-c-{id}` name rather than the (possibly stale)
|
||||
/// container id Docker handed out — Docker's remove-container API
|
||||
/// accepts either, and the name is the one identifier guaranteed to still
|
||||
/// resolve to the same container by the time a retry runs.
|
||||
pub container_id: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub volumes: Vec<String>,
|
||||
pub recorded_at: String,
|
||||
}
|
||||
|
||||
impl PendingCleanup {
|
||||
/// True once nothing named here still needs to be removed.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.container_id.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// `<data_dir>/triple-c/pending-cleanup`, created on demand.
|
||||
fn dir() -> Result<PathBuf, String> {
|
||||
let dir = dirs::data_dir()
|
||||
.ok_or_else(|| {
|
||||
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||
})?
|
||||
.join("triple-c")
|
||||
.join("pending-cleanup");
|
||||
fs::create_dir_all(&dir)
|
||||
.map_err(|e| format!("Failed to create pending-cleanup directory: {}", e))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||
/// the write anywhere but the pending-cleanup directory. Mirrors
|
||||
/// `storage::migration_store::sanitize`.
|
||||
fn sanitize(project_id: &str) -> String {
|
||||
project_id
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Write (or overwrite) a project's pending-cleanup record.
|
||||
pub fn save(record: &PendingCleanup) -> Result<(), String> {
|
||||
save_in(&dir()?, record)
|
||||
}
|
||||
|
||||
/// Remove a project's pending-cleanup record. Missing is success — this is
|
||||
/// how a fully-succeeded retry (or a record that never existed) is expressed.
|
||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||
clear_in(&dir()?, project_id)
|
||||
}
|
||||
|
||||
/// Every pending-cleanup record on disk. An unparseable file is logged and
|
||||
/// skipped rather than blocking every other project's retry — the same
|
||||
/// "one bad record can't wedge the rest" reasoning as the migration store.
|
||||
pub fn list() -> Vec<PendingCleanup> {
|
||||
let Ok(dir) = dir() else { return Vec::new() };
|
||||
list_in(&dir)
|
||||
}
|
||||
|
||||
fn path_in(dir: &Path, project_id: &str) -> PathBuf {
|
||||
dir.join(format!("{}.json", sanitize(project_id)))
|
||||
}
|
||||
|
||||
/// Durable write: fsync the file before the rename, and fsync the directory
|
||||
/// after it — see the module doc comment for why a plain
|
||||
/// write-temp-then-rename is not enough here. Mirrors
|
||||
/// `storage::migration_store::save`/`sync_dir`.
|
||||
fn save_in(dir: &Path, record: &PendingCleanup) -> Result<(), String> {
|
||||
let path = path_in(dir, &record.project_id);
|
||||
let data = serde_json::to_string_pretty(record)
|
||||
.map_err(|e| format!("Failed to serialize pending cleanup record: {}", e))?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file = fs::File::create(&tmp)
|
||||
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
|
||||
file.write_all(data.as_bytes())
|
||||
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
|
||||
file.sync_all()
|
||||
.map_err(|e| format!("Failed to flush pending cleanup record to disk: {}", e))?;
|
||||
}
|
||||
|
||||
fs::rename(&tmp, &path)
|
||||
.map_err(|e| format!("Failed to commit pending cleanup record: {}", e))?;
|
||||
sync_dir(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
||||
let path = path_in(dir, project_id);
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("Failed to remove pending cleanup record: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_in(dir: &Path) -> Vec<PendingCleanup> {
|
||||
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
|
||||
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let data = fs::read_to_string(&path).ok()?;
|
||||
match serde_json::from_str::<PendingCleanup>(&data) {
|
||||
Ok(record) => Some(record),
|
||||
Err(err) => {
|
||||
// Moved aside rather than left in place: a record nothing
|
||||
// ever repairs would otherwise warn on every single
|
||||
// startup forever, same as an ordinary `.json` file it
|
||||
// would keep looking like one to `list_in` on the next
|
||||
// call too. One aside-copy is enough here — this only
|
||||
// ever holds names to retry removing, not the class of
|
||||
// once-in-a-lifetime crash evidence `migration_store`
|
||||
// keeps multiple timestamped backups of.
|
||||
let corrupt = path.with_extension("json.corrupt");
|
||||
let moved = !corrupt.exists() && fs::rename(&path, &corrupt).is_ok();
|
||||
log::warn!(
|
||||
"Could not parse pending cleanup record {}: {}{}",
|
||||
path.display(),
|
||||
err,
|
||||
if moved {
|
||||
format!(" — moved aside to {}", corrupt.display())
|
||||
} else {
|
||||
" — leaving it in place".to_string()
|
||||
}
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// fsync the directory holding `path`, so a rename into it survives power
|
||||
/// loss. Best effort only on the platforms where it is meaningless: Windows
|
||||
/// has no directory handle to sync and errors on the attempt, so failure is
|
||||
/// logged rather than propagated — the file's own `sync_all` above is what
|
||||
/// carries the data. Mirrors `storage::migration_store::sync_dir`, which is
|
||||
/// private to that module, so this is a small deliberate duplicate rather
|
||||
/// than a shared dependency between two otherwise-independent stores.
|
||||
fn sync_dir(path: &Path) {
|
||||
let Some(dir) = path.parent() else { return };
|
||||
match fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||
Ok(()) => {}
|
||||
Err(e) => log::debug!(
|
||||
"Could not fsync the pending-cleanup directory {}: {} — the record itself was flushed",
|
||||
dir.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-pending-cleanup-{}-{}",
|
||||
name,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn record(project_id: &str) -> PendingCleanup {
|
||||
PendingCleanup {
|
||||
project_id: project_id.to_string(),
|
||||
project_name: "Some Project".to_string(),
|
||||
container_id: Some("triple-c-abc".to_string()),
|
||||
image: Some("triple-c-snapshot-abc:latest".to_string()),
|
||||
volumes: vec!["triple-c-home-abc".to_string()],
|
||||
recorded_at: "2026-08-25T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_ids_cannot_escape_the_pending_cleanup_directory() {
|
||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||
assert_eq!(sanitize("a/b"), "a_b");
|
||||
assert_eq!(
|
||||
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_empty_reflects_whatever_still_needs_removing() {
|
||||
let mut r = record("p1");
|
||||
assert!(!r.is_empty());
|
||||
|
||||
r.container_id = None;
|
||||
r.image = None;
|
||||
assert!(!r.is_empty(), "a leftover volume alone still counts");
|
||||
|
||||
r.volumes.clear();
|
||||
assert!(r.is_empty());
|
||||
}
|
||||
|
||||
/// Exercises the real `save_in`/`list_in`/`clear_in` — not a
|
||||
/// re-implementation of their bodies — against a temp directory standing
|
||||
/// in for `dir()`.
|
||||
#[test]
|
||||
fn a_saved_record_round_trips_and_clearing_removes_it() {
|
||||
let dir = temp_dir("roundtrip");
|
||||
let rec = record("proj-1");
|
||||
|
||||
save_in(&dir, &rec).expect("save");
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-1");
|
||||
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
|
||||
|
||||
clear_in(&dir, "proj-1").expect("clear");
|
||||
assert!(list_in(&dir).is_empty());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A second `save` for the same project overwrites rather than appending
|
||||
/// — a retry that narrows the leftovers must not leave the old, wider
|
||||
/// record behind it.
|
||||
#[test]
|
||||
fn saving_the_same_project_twice_overwrites_not_appends() {
|
||||
let dir = temp_dir("overwrite");
|
||||
let mut rec = record("proj-1");
|
||||
save_in(&dir, &rec).expect("save");
|
||||
|
||||
rec.container_id = None;
|
||||
rec.image = None;
|
||||
save_in(&dir, &rec).expect("save again");
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1, "one file per project, not one per save");
|
||||
assert!(found[0].container_id.is_none());
|
||||
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A record that fails to parse must not poison the rest of the listing.
|
||||
#[test]
|
||||
fn an_unparseable_record_is_skipped_not_fatal() {
|
||||
let dir = temp_dir("corrupt");
|
||||
fs::write(dir.join("bad.json"), "{ not json").unwrap();
|
||||
save_in(&dir, &record("proj-2")).expect("save");
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-2");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A record that fails to parse is moved aside once, rather than left in
|
||||
/// place to be re-warned about — and re-warned about — on every future
|
||||
/// launch forever.
|
||||
#[test]
|
||||
fn an_unparseable_record_is_moved_aside_exactly_once() {
|
||||
let dir = temp_dir("corrupt-aside");
|
||||
let bad = dir.join("bad.json");
|
||||
fs::write(&bad, "{ not json").unwrap();
|
||||
|
||||
list_in(&dir);
|
||||
assert!(!bad.exists(), "the bad file should have been moved aside");
|
||||
let corrupt = dir.join("bad.json.corrupt");
|
||||
assert!(corrupt.exists(), "and the moved copy should be at .json.corrupt");
|
||||
|
||||
// A second pass must not warn about `bad.json` again — it is gone —
|
||||
// and must not choke on `.json.corrupt` already being there.
|
||||
assert!(list_in(&dir).is_empty());
|
||||
assert!(corrupt.exists(), "the aside copy is not itself deleted");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// `list_in` must not pick up the `.json.tmp` staging file `save_in`
|
||||
/// leaves behind if a crash lands between the write and the rename — the
|
||||
/// whole point of the temp-then-rename dance is that only the renamed
|
||||
/// file is ever a complete record.
|
||||
#[test]
|
||||
fn a_leftover_tmp_file_is_not_listed() {
|
||||
let dir = temp_dir("tmp-leftover");
|
||||
fs::write(dir.join("proj-3.json.tmp"), "not a complete record").unwrap();
|
||||
assert!(list_in(&dir).is_empty());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Clearing by project id must remove exactly the file that id maps to
|
||||
/// under `sanitize`, and nothing else.
|
||||
#[test]
|
||||
fn clearing_one_project_does_not_touch_another() {
|
||||
let dir = temp_dir("clear-scoped");
|
||||
save_in(&dir, &record("proj-a")).unwrap();
|
||||
save_in(&dir, &record("proj-b")).unwrap();
|
||||
|
||||
clear_in(&dir, "proj-a").unwrap();
|
||||
|
||||
let found = list_in(&dir);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].project_id, "proj-b");
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,65 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::models::Project;
|
||||
|
||||
/// The sticky marker for `projects.json`: `projects.json.corrupt`, beside it.
|
||||
///
|
||||
/// Derived from the file rather than from `dirs::data_dir()` so the marker
|
||||
/// always lands in the directory the store is actually using — and so the
|
||||
/// writer can be tested against a temp directory.
|
||||
fn corrupt_marker_for(file_path: &Path) -> PathBuf {
|
||||
file_path.with_extension("json.corrupt")
|
||||
}
|
||||
|
||||
/// Keep the bytes of an unparseable `projects.json`, and record that it
|
||||
/// happened.
|
||||
///
|
||||
/// **The existing `.bak` is never overwritten.** A second corruption used to
|
||||
/// clobber the first, and the first is the valuable one: it was taken before
|
||||
/// the app rewrote the file with whatever it had in memory, so it is the only
|
||||
/// copy that can still hold the full project list. Later ones are copies of an
|
||||
/// already-degraded file and get a timestamped name.
|
||||
fn record_corrupt_load(file_path: &Path, now: &chrono::DateTime<chrono::Utc>) {
|
||||
let first = file_path.with_extension("json.bak");
|
||||
let backup = if first.exists() {
|
||||
file_path.with_extension(format!("json.corrupt-{}.bak", now.format("%Y%m%d-%H%M%S")))
|
||||
} else {
|
||||
first
|
||||
};
|
||||
if !backup.exists() {
|
||||
if let Err(e) = fs::copy(file_path, &backup) {
|
||||
log::error!("Failed to back up corrupted projects.json: {}", e);
|
||||
} else {
|
||||
log::error!(
|
||||
"A copy of the unreadable projects.json was kept at {}",
|
||||
backup.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sticky, and written even though nothing in the app reads it back on this
|
||||
// branch: the Disk panel's `project_store_trust` was the reader and went to
|
||||
// `hold/disk-and-dragout`. The marker stays because it is the only durable
|
||||
// record that a project list was lost — the in-memory symptom does not
|
||||
// survive the next save — and because re-deriving *when* it happened is
|
||||
// impossible after the fact.
|
||||
let marker = corrupt_marker_for(file_path);
|
||||
if marker.exists() {
|
||||
// The *first* corruption is the one that dates the loss.
|
||||
return;
|
||||
}
|
||||
if let Err(e) = fs::write(&marker, now.to_rfc3339()) {
|
||||
log::error!(
|
||||
"Could not record the corrupt projects.json load at {}: {} — nothing will be able to \
|
||||
tell later that the project list was incomplete",
|
||||
marker.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProjectsStore {
|
||||
projects: Mutex<Vec<Project>>,
|
||||
file_path: PathBuf,
|
||||
@@ -43,20 +99,14 @@ impl ProjectsStore {
|
||||
Ok(parsed) => (parsed, migrated),
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e);
|
||||
let backup = file_path.with_extension("json.bak");
|
||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
||||
log::error!("Failed to back up corrupted projects.json: {}", be);
|
||||
}
|
||||
record_corrupt_load(&file_path, &chrono::Utc::now());
|
||||
(Vec::new(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
|
||||
let backup = file_path.with_extension("json.bak");
|
||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
||||
log::error!("Failed to back up corrupted projects.json: {}", be);
|
||||
}
|
||||
record_corrupt_load(&file_path, &chrono::Utc::now());
|
||||
(Vec::new(), false)
|
||||
}
|
||||
}
|
||||
@@ -203,3 +253,89 @@ impl ProjectsStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"triple-c-store-{}-{}",
|
||||
tag,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_load_leaves_a_marker_the_next_write_cannot_erase() {
|
||||
// H-3, the whole chain in one test. `ProjectsStore::new()` swallows an
|
||||
// unparseable file into an empty list *without rewriting it*, and the
|
||||
// first `save()` after that — as little as `update_status()` — writes
|
||||
// `[{one project}]` over it. Everything the old guard keyed on ("the
|
||||
// list is empty and the file exists") is gone at that point, while
|
||||
// every *other* project's volumes are still on the daemon claimed by
|
||||
// nobody.
|
||||
let dir = temp_dir("corrupt");
|
||||
let file = dir.join("projects.json");
|
||||
fs::write(&file, "{ this is not a project list").unwrap();
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
record_corrupt_load(&file, &now);
|
||||
|
||||
let marker = corrupt_marker_for(&file);
|
||||
assert!(marker.exists(), "the corrupt load must be recorded on disk");
|
||||
assert_eq!(fs::read_to_string(&marker).unwrap(), now.to_rfc3339());
|
||||
assert!(
|
||||
dir.join("projects.json.bak").exists(),
|
||||
"the unreadable bytes must be kept"
|
||||
);
|
||||
|
||||
// The write that used to erase the evidence. The marker is a separate
|
||||
// file, so it does not care.
|
||||
fs::write(&file, r#"[{"id":"the-one-project-started-since"}]"#).unwrap();
|
||||
assert!(marker.exists());
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_corruption_keeps_the_first_copy_and_the_first_date() {
|
||||
// The `.bak` used to be a fixed name, so a second corruption clobbered
|
||||
// the first — and the first is the only copy taken before the app
|
||||
// rewrote the file with whatever it had in memory, i.e. the only one
|
||||
// that can still hold the full project list.
|
||||
let dir = temp_dir("second");
|
||||
let file = dir.join("projects.json");
|
||||
fs::write(&file, "original bytes").unwrap();
|
||||
let first = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc);
|
||||
record_corrupt_load(&file, &first);
|
||||
|
||||
fs::write(&file, "degraded bytes").unwrap();
|
||||
let second = chrono::DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Utc);
|
||||
record_corrupt_load(&file, &second);
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("projects.json.bak")).unwrap(),
|
||||
"original bytes",
|
||||
"the first copy must survive the second corruption"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("projects.json.corrupt-20260601-000000.bak")).unwrap(),
|
||||
"degraded bytes"
|
||||
);
|
||||
// And the marker still dates the loss from the first failure, which is
|
||||
// when the project list actually stopped being complete.
|
||||
assert_eq!(
|
||||
fs::read_to_string(corrupt_marker_for(&file)).unwrap(),
|
||||
first.to_rfc3339()
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,47 +26,122 @@ const CLAUDE_TOKEN_VERSION_SERVICE: &str = "triple-c-claude-oauth-token-version"
|
||||
/// Fixed account name used for every triple-c keychain entry.
|
||||
const KEYCHAIN_ACCOUNT: &str = "secret";
|
||||
|
||||
/// Every per-project secret this app stores, and therefore every one it has to
|
||||
/// be able to delete.
|
||||
///
|
||||
/// This list is the **only** definition. It used to exist twice — once
|
||||
/// implicitly, as whatever `store_secrets_for_project` happened to write, and
|
||||
/// once explicitly, as a literal array inside `delete_project_secrets` — and
|
||||
/// the two drifted: `openai-compatible-api-key` was added to the writer and
|
||||
/// never to the deleter, so removing a project left a live provider API key in
|
||||
/// the user's login keychain with nothing left in the app that referenced it,
|
||||
/// or would ever offer to clean it up.
|
||||
///
|
||||
/// Drift is now a compile-time-shaped error rather than a review-time one:
|
||||
/// [`project_secret_entry`] refuses a key that is not in this list, so a new
|
||||
/// secret cannot be stored until it has been added here, and adding it here is
|
||||
/// what makes [`delete_project_secrets`] cover it.
|
||||
pub const PROJECT_SECRET_KEYS: &[&str] = &[
|
||||
"git-token",
|
||||
"aws-access-key-id",
|
||||
"aws-secret-access-key",
|
||||
"aws-session-token",
|
||||
"aws-bearer-token",
|
||||
"openai-compatible-api-key",
|
||||
];
|
||||
|
||||
/// The keychain entry for one per-project secret, rejecting any key name not in
|
||||
/// [`PROJECT_SECRET_KEYS`]. See that constant for why the rejection matters.
|
||||
fn project_secret_entry(project_id: &str, key_name: &str) -> Result<keyring::Entry, String> {
|
||||
if !PROJECT_SECRET_KEYS.contains(&key_name) {
|
||||
return Err(format!(
|
||||
"Unknown project secret '{}'. Add it to PROJECT_SECRET_KEYS so project deletion \
|
||||
clears it too.",
|
||||
key_name
|
||||
));
|
||||
}
|
||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||
keyring::Entry::new(&service, KEYCHAIN_ACCOUNT).map_err(|e| format!("Keyring error: {}", e))
|
||||
}
|
||||
|
||||
/// Store a per-project secret in the OS keychain.
|
||||
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||
let entry = keyring::Entry::new(&service, "secret")
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
project_secret_entry(project_id, key_name)?
|
||||
.set_password(value)
|
||||
.map_err(|e| format!("Failed to store project secret '{}': {}", key_name, e))
|
||||
}
|
||||
|
||||
/// Retrieve a per-project secret from the OS keychain.
|
||||
pub fn get_project_secret(project_id: &str, key_name: &str) -> Result<Option<String>, String> {
|
||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||
let entry = keyring::Entry::new(&service, "secret")
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
match entry.get_password() {
|
||||
match project_secret_entry(project_id, key_name)?.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(format!("Failed to retrieve project secret '{}': {}", key_name, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all known secrets for a project from the OS keychain.
|
||||
/// Delete one per-project secret, treating "wasn't there" as success.
|
||||
pub fn delete_project_secret(project_id: &str, key_name: &str) -> Result<(), String> {
|
||||
match project_secret_entry(project_id, key_name)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(format!("Failed to delete project secret '{}': {}", key_name, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a per-project secret, or **clear** it when there is nothing to write.
|
||||
///
|
||||
/// This is the function every save path should call, and the reason it exists
|
||||
/// is that the obvious `if let Some(v) = … { store(v) }` is wrong. The editors
|
||||
/// in `components/projects/home/config/` send a blanked field as `null`
|
||||
/// (`AccessSection.tsx`: `save({ git_token: gitToken || null })`), so a `None`
|
||||
/// is a user asking for the secret to be *removed* — and skipping it left the
|
||||
/// old value in the keychain, where `load_secrets_for_project` read it straight
|
||||
/// back out and put it back on the project. Clearing a credential through the
|
||||
/// UI was therefore impossible: the field looked empty and the container kept
|
||||
/// getting the old token.
|
||||
///
|
||||
/// `Some("")` and `Some(" ")` are treated the same as `None` — a field the
|
||||
/// user emptied, whichever shape it arrives in — because a stored empty secret
|
||||
/// is not a secret, and `container_config` would inject it as an env var that
|
||||
/// overrides the unset case with a blank.
|
||||
// TODO(handoff): `commands/project_commands.rs::store_secrets_for_project` is
|
||||
// the one caller this is for, and it still uses the `if let Some(v) = … ` shape
|
||||
// that cannot clear anything. That file belongs to another change in this round,
|
||||
// so the switch is deliberately left to it; the six call sites there become
|
||||
// `store_or_clear_project_secret(&project.id, "<key>", field.as_deref())?`.
|
||||
#[allow(dead_code)]
|
||||
pub fn store_or_clear_project_secret(
|
||||
project_id: &str,
|
||||
key_name: &str,
|
||||
value: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
match secret_to_store(value) {
|
||||
Some(v) => store_project_secret(project_id, key_name, v),
|
||||
None => delete_project_secret(project_id, key_name),
|
||||
}
|
||||
}
|
||||
|
||||
/// The store-or-clear decision, split out so it can be tested without a
|
||||
/// keychain backend: `Some` means "write this", `None` means "remove whatever
|
||||
/// is there".
|
||||
#[allow(dead_code)]
|
||||
fn secret_to_store(value: Option<&str>) -> Option<&str> {
|
||||
match value.map(str::trim) {
|
||||
Some(v) if !v.is_empty() => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete every known secret for a project from the OS keychain.
|
||||
///
|
||||
/// Called when a project is removed, so it must cover [`PROJECT_SECRET_KEYS`]
|
||||
/// exhaustively — a key missed here outlives the project that explained it.
|
||||
/// One key failing does not stop the rest: a partial cleanup that keeps going
|
||||
/// leaves strictly fewer credentials behind than one that gives up.
|
||||
pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
|
||||
let secret_keys = [
|
||||
"git-token",
|
||||
"aws-access-key-id",
|
||||
"aws-secret-access-key",
|
||||
"aws-session-token",
|
||||
"aws-bearer-token",
|
||||
];
|
||||
for key_name in &secret_keys {
|
||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||
let entry = keyring::Entry::new(&service, "secret")
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) => {}
|
||||
Err(keyring::Error::NoEntry) => {}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to delete project secret '{}': {}", key_name, e);
|
||||
}
|
||||
for key_name in PROJECT_SECRET_KEYS {
|
||||
if let Err(e) = delete_project_secret(project_id, key_name) {
|
||||
log::warn!("Failed to delete project secret '{}': {}", key_name, e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -246,26 +321,125 @@ pub fn delete_gateway_api_key() -> Result<(), String> {
|
||||
/// only enforces auth when a master key is configured, so Triple-C always
|
||||
/// configures one.
|
||||
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
||||
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
|
||||
if !existing.trim().is_empty() {
|
||||
return Ok(existing);
|
||||
}
|
||||
if let Some(existing) = get_gateway_master_key()? {
|
||||
return Ok(existing);
|
||||
}
|
||||
regenerate_gateway_master_key()
|
||||
}
|
||||
|
||||
/// Read the gateway master key without minting one if none exists yet.
|
||||
/// Distinct from [`get_or_create_gateway_master_key`], which mints as a side
|
||||
/// effect the read half of that function must not have — settings export
|
||||
/// (triple-c#35) needs "is there one, and if so what is it", not "make sure
|
||||
/// one exists".
|
||||
pub fn get_gateway_master_key() -> Result<Option<String>, String> {
|
||||
Ok(read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")?
|
||||
.filter(|k| !k.trim().is_empty()))
|
||||
}
|
||||
|
||||
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
||||
/// previous value must be updated.
|
||||
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
||||
// LiteLLM requires the master key to start with `sk-`.
|
||||
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
||||
store_gateway_master_key(&key)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Store an exact given gateway master key, replacing any previous one.
|
||||
///
|
||||
/// Distinct from [`regenerate_gateway_master_key`], which always mints a
|
||||
/// fresh random value: this exists for settings import (triple-c#35), where
|
||||
/// restoring the *same* key an export captured is the point — projects on
|
||||
/// the destination machine may not exist yet, but a project migrated or
|
||||
/// re-added later that still has the old key pasted into its config must
|
||||
/// keep working against it. Blank input is rejected rather than silently
|
||||
/// stored, matching every other `store_*` function in this module.
|
||||
pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
|
||||
if key.trim().is_empty() {
|
||||
return Err("Refusing to store an empty gateway master key.".to_string());
|
||||
}
|
||||
|
||||
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||
entry
|
||||
.set_password(&key)
|
||||
.set_password(key.trim())
|
||||
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
||||
|
||||
bump_gateway_secret_version()?;
|
||||
Ok(key)
|
||||
bump_gateway_secret_version()
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The regression this list exists for. `openai-compatible-api-key` was
|
||||
/// written by `store_secrets_for_project` and missing from the delete list,
|
||||
/// so it survived project deletion.
|
||||
#[test]
|
||||
fn every_secret_the_app_writes_is_one_it_can_delete() {
|
||||
for key in [
|
||||
"git-token",
|
||||
"aws-access-key-id",
|
||||
"aws-secret-access-key",
|
||||
"aws-session-token",
|
||||
"aws-bearer-token",
|
||||
"openai-compatible-api-key",
|
||||
] {
|
||||
assert!(
|
||||
PROJECT_SECRET_KEYS.contains(&key),
|
||||
"{} is written by commands/project_commands.rs but would outlive the project",
|
||||
key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_key_list_has_no_duplicates() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for key in PROJECT_SECRET_KEYS {
|
||||
assert!(seen.insert(*key), "duplicate project secret key {}", key);
|
||||
}
|
||||
}
|
||||
|
||||
/// A key that is not in the list is refused *before* any keychain entry is
|
||||
/// constructed, which is what makes the list authoritative rather than
|
||||
/// advisory. Without this, a new secret can be stored under a name nothing
|
||||
/// ever deletes.
|
||||
#[test]
|
||||
fn an_unlisted_key_cannot_be_stored_at_all() {
|
||||
let err = store_project_secret("some-project", "brand-new-token", "value")
|
||||
.expect_err("an unlisted key must be refused");
|
||||
assert!(
|
||||
err.contains("PROJECT_SECRET_KEYS"),
|
||||
"the refusal should say how to fix it: {}",
|
||||
err
|
||||
);
|
||||
|
||||
let err = get_project_secret("some-project", "brand-new-token")
|
||||
.expect_err("an unlisted key must be refused on read too");
|
||||
assert!(err.contains("brand-new-token"), "{}", err);
|
||||
|
||||
let err = delete_project_secret("some-project", "brand-new-token")
|
||||
.expect_err("an unlisted key must be refused on delete too");
|
||||
assert!(err.contains("brand-new-token"), "{}", err);
|
||||
}
|
||||
|
||||
/// The blanked-field case. `AccessSection.tsx` sends `gitToken || null`, so
|
||||
/// a cleared field arrives as `None` — and before this existed, `None` was
|
||||
/// skipped and the old secret stayed in the keychain forever.
|
||||
#[test]
|
||||
fn a_blanked_field_clears_rather_than_being_skipped() {
|
||||
assert_eq!(secret_to_store(None), None);
|
||||
assert_eq!(secret_to_store(Some("")), None);
|
||||
assert_eq!(secret_to_store(Some(" \t\n")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_value_is_stored_trimmed() {
|
||||
assert_eq!(secret_to_store(Some("ghp_abc123")), Some("ghp_abc123"));
|
||||
// Pasted credentials routinely carry a trailing newline.
|
||||
assert_eq!(secret_to_store(Some(" ghp_abc123\n")), Some("ghp_abc123"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,78 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<!--
|
||||
This page is served by an axum server bound 0.0.0.0 (remote access is the
|
||||
feature) behind a permissive CORS layer, and it fronts a shell in a container.
|
||||
It gets a CSP of its own because nothing else gives it one: the app's
|
||||
`tauri.conf.json` CSP covers the desktop webview, never this document.
|
||||
|
||||
`default-src 'none'` is the base, so anything not named below is refused
|
||||
outright. What is named:
|
||||
script-src jsdelivr for the three xterm bundles, plus 'unsafe-inline' for
|
||||
this page's own inline <script>. Nonces/hashes were considered
|
||||
and rejected: the file is a static `include_str!()` asset, so a
|
||||
hash would have to be recomputed by hand on every edit to the
|
||||
script, and the failure mode of getting that wrong is a terminal
|
||||
that silently will not start.
|
||||
style-src the xterm stylesheet, this page's <style>, and the one inline
|
||||
`style=` attribute below (style attributes need 'unsafe-inline').
|
||||
connect-src the WebSocket back to this same server. `ws:`/`wss:` as schemes
|
||||
rather than an origin, because the host and port are whatever
|
||||
the user reached this page on and are not knowable at build time.
|
||||
form-action / base-uri / object-src / frame-ancestors — all 'none'. Note
|
||||
`frame-ancestors` is ignored in a <meta> CSP; it is here as a
|
||||
statement of intent, and the real protection would be a response
|
||||
header from `server.rs`.
|
||||
Deliberately absent: 'unsafe-eval', and any origin other than jsdelivr.
|
||||
-->
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'none';
|
||||
script-src 'unsafe-inline' https://cdn.jsdelivr.net;
|
||||
style-src 'unsafe-inline' https://cdn.jsdelivr.net;
|
||||
img-src 'self' data:;
|
||||
font-src 'self' data:;
|
||||
connect-src 'self' ws: wss:;
|
||||
form-action 'none';
|
||||
base-uri 'none';
|
||||
object-src 'none';
|
||||
frame-ancestors 'none';
|
||||
">
|
||||
<title>Triple-C Web Terminal</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||
<!--
|
||||
Subresource Integrity on every CDN asset.
|
||||
|
||||
Without it this page executes whatever jsdelivr returns, inside a document
|
||||
that holds the web terminal's access token and drives a shell in a container —
|
||||
an upstream compromise, a hijacked package version or a MITM on a phone's
|
||||
network is arbitrary code with that reach. The hashes below were computed
|
||||
from the exact bytes at these pinned versions. `crossorigin="anonymous"` is
|
||||
required for SRI to be checked on a cross-origin fetch.
|
||||
|
||||
Bumping a version means recomputing its hash:
|
||||
curl -sS <url> | openssl dgst -sha384 -binary | openssl base64 -A
|
||||
A mismatched hash blocks the asset, so a stale hash shows up immediately as a
|
||||
terminal that does not render — never as an unverified load.
|
||||
|
||||
These are still remote loads: the remote terminal does not work with no
|
||||
internet on the client side, and vendoring the ~300 KB of minified xterm into
|
||||
this file would fix that. It was not done here — SRI already closes the
|
||||
integrity half, which is the security half, and the availability half is a
|
||||
separate call about binary size and diff readability.
|
||||
-->
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"
|
||||
integrity="sha384-tStR1zLfWgsiXCF3IgfB3lBa8KmBe/lG287CL9WCeKgQYcp1bjb4/+mwN6oti4Co"
|
||||
crossorigin="anonymous">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"
|
||||
integrity="sha384-J4qzUjBl1FxyLsl/kQPQIOeINsmp17OHYXDOMpMxlKX53ZfYsL+aWHpgArvOuof9"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"
|
||||
integrity="sha384-XGqKrV8Jrukp1NITJbOEHwg01tNkuXr6uB6YEj69ebpYU3v7FvoGgEg23C1Gcehk"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"
|
||||
integrity="sha384-S1biLeI8L/bFduIVvCxbn/l4EtaG4nTqQjGF7qCYTbsGXGFe8KgIKXtw4+UWxprv"
|
||||
crossorigin="anonymous"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1b26;
|
||||
@@ -334,6 +401,9 @@
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||
enterkeyhint="send" inputmode="text">
|
||||
<button class="key-btn" id="btnEnter">Enter</button>
|
||||
<!-- A newline *without* submitting. There is no Shift on a phone keyboard,
|
||||
so the chord the desktop app binds needs a key of its own here. -->
|
||||
<button class="key-btn" id="btnNewline" title="Insert a newline without submitting (Shift+Enter)">↵+</button>
|
||||
<button class="key-btn" id="btnTab">Tab</button>
|
||||
<button class="key-btn" id="btnCtrlC">^C</button>
|
||||
</div>
|
||||
@@ -360,6 +430,19 @@
|
||||
const emptyState = document.getElementById('emptyState');
|
||||
const mobileInput = document.getElementById('mobileInput');
|
||||
const btnEnter = document.getElementById('btnEnter');
|
||||
const btnNewline = document.getElementById('btnNewline');
|
||||
|
||||
// Whether the *active* session understands ESC+CR as "insert a newline".
|
||||
//
|
||||
// Only Claude Code does. `bash -l` has no readline binding for `\e\r`, so
|
||||
// sending it there is a silent no-op — which is worse from the mobile bar
|
||||
// than from a hardware key, because the bar puts a dedicated button on
|
||||
// screen that appears to do nothing. The xterm key handler is already scoped
|
||||
// this way; these two paths were not.
|
||||
function activeSessionTakesEscCr() {
|
||||
const s = activeSessionId && sessions[activeSessionId];
|
||||
return !!s && s.type === 'claude';
|
||||
}
|
||||
const btnTab = document.getElementById('btnTab');
|
||||
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||
@@ -519,7 +602,7 @@
|
||||
updateProjectList(msg.projects);
|
||||
break;
|
||||
case 'opened':
|
||||
onSessionOpened(msg.session_id, msg.project_name);
|
||||
onSessionOpened(msg.session_id, msg.project_name, msg.session_type);
|
||||
break;
|
||||
case 'output':
|
||||
onSessionOutput(msg.session_id, msg.data);
|
||||
@@ -570,8 +653,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
function onSessionOpened(sessionId, projectName) {
|
||||
const sessionType = pendingSessionType || 'claude';
|
||||
function onSessionOpened(sessionId, projectName, serverSessionType) {
|
||||
// Prefer the type the *server* reports for this session. The old path read
|
||||
// a single `pendingSessionType` global set at request time, so opening two
|
||||
// sessions before the first reply landed swapped their labels — routine on
|
||||
// mobile, where nothing disables the buttons. That was cosmetic until
|
||||
// Shift+Enter became type-dependent: a Claude session labelled `shell`
|
||||
// sends a bare CR and submits a half-written prompt.
|
||||
//
|
||||
// The fallback keeps an older server working, and defaults to `claude`,
|
||||
// which is the safe direction — ESC+CR is an unbound no-op in bash, while
|
||||
// a bare CR in Claude Code loses the prompt.
|
||||
const sessionType = serverSessionType || pendingSessionType || 'claude';
|
||||
pendingSessionType = null;
|
||||
|
||||
// Create terminal
|
||||
@@ -647,6 +740,34 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Shift+Enter inserts a newline in Claude Code's prompt instead of
|
||||
// submitting it. xterm.js does not consult `shiftKey` for Enter, so
|
||||
// without this Shift+Enter is byte-identical to Enter.
|
||||
//
|
||||
// `\x1b\r` — ESC then CR — is what Claude Code parses as `return` with
|
||||
// meta, and it is the same sequence its own `/terminal-setup` installs for
|
||||
// VS Code, Cursor, Alacritty and Zed. Do not "simplify" it to `\n`: that
|
||||
// also works in Claude Code, but a shell would *run* the line, so the two
|
||||
// session types would diverge. Claude sessions only, for that reason —
|
||||
// `bash -l` has no readline binding for `\e\r`.
|
||||
term.attachCustomKeyEventHandler(e => {
|
||||
if (
|
||||
e.type === 'keydown' && e.key === 'Enter' && e.shiftKey &&
|
||||
!e.ctrlKey && !e.altKey && !e.metaKey && !e.isComposing &&
|
||||
sessionType === 'claude'
|
||||
) {
|
||||
sendTerminalInput('\x1b\r');
|
||||
// `preventDefault()` is what stops the submit, not the `return false`.
|
||||
// xterm's `_keyDown` returns before setting `_keyDownHandled`, so
|
||||
// `_keyPress` still fires and emits a bare CR for Enter — inserting the
|
||||
// newline and then submitting the prompt anyway. See the same comment
|
||||
// in TerminalView.tsx.
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Track scroll position for scroll-to-bottom button
|
||||
term.onScroll(() => updateScrollButton());
|
||||
|
||||
@@ -698,6 +819,7 @@
|
||||
switchToSession(remaining[remaining.length - 1]);
|
||||
} else {
|
||||
activeSessionId = null;
|
||||
syncNewlineButton();
|
||||
emptyState.style.display = '';
|
||||
}
|
||||
}
|
||||
@@ -724,6 +846,7 @@
|
||||
|
||||
function switchToSession(sessionId) {
|
||||
activeSessionId = sessionId;
|
||||
syncNewlineButton();
|
||||
|
||||
// Update tab styles
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
@@ -799,7 +922,11 @@
|
||||
sendTerminalInput(val);
|
||||
mobileInput.value = '';
|
||||
}
|
||||
sendTerminalInput('\r');
|
||||
// Shift+Enter is a newline, not a submit — same bytes, and the same
|
||||
// reasoning, as the terminal's own key handler above. A hardware
|
||||
// keyboard on a tablet is the only way to reach this; the phone case is
|
||||
// the dedicated newline button beside Enter.
|
||||
sendTerminalInput(e.shiftKey && activeSessionTakesEscCr() ? '\x1b\r' : '\r');
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
sendTerminalInput('\t');
|
||||
@@ -807,6 +934,27 @@
|
||||
});
|
||||
|
||||
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
|
||||
btnNewline.onclick = () => {
|
||||
if (!activeSessionTakesEscCr()) { mobileInput.focus(); return; }
|
||||
sendTerminalInput('\x1b\r');
|
||||
mobileInput.focus();
|
||||
};
|
||||
|
||||
// Keep the button's affordance honest: on a shell tab there is no byte that
|
||||
// means "newline without running the line", so the control is disabled
|
||||
// rather than left looking live.
|
||||
function syncNewlineButton() {
|
||||
const usable = activeSessionTakesEscCr();
|
||||
btnNewline.disabled = !usable;
|
||||
btnNewline.title = usable
|
||||
? 'Insert a newline without submitting (Shift+Enter)'
|
||||
: 'Only Claude sessions support this — a shell runs the line instead';
|
||||
}
|
||||
|
||||
// With no session open yet, `activeSessionTakesEscCr()` is already false —
|
||||
// but nothing had called this, so the button rendered live before the first
|
||||
// tab existed.
|
||||
syncNewlineButton();
|
||||
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
|
||||
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
||||
|
||||
|
||||
@@ -46,6 +46,16 @@ enum ServerMessage {
|
||||
Opened {
|
||||
session_id: String,
|
||||
project_name: String,
|
||||
/// Echoed back so the client can label the session from the reply
|
||||
/// rather than from a global set at request time.
|
||||
///
|
||||
/// Without it the client correlates through a single
|
||||
/// `pendingSessionType`, so opening two sessions before the first
|
||||
/// reply lands swaps their labels. That used to be cosmetic; it stopped
|
||||
/// being cosmetic when Shift+Enter became type-dependent, because a
|
||||
/// Claude session mislabelled as a shell now submits a half-written
|
||||
/// prompt instead of inserting a newline.
|
||||
session_type: String,
|
||||
},
|
||||
Output {
|
||||
session_id: String,
|
||||
@@ -319,6 +329,11 @@ async fn handle_open(
|
||||
let _ = out_tx.send(ServerMessage::Opened {
|
||||
session_id,
|
||||
project_name,
|
||||
// Derived from the same match that chose `cmd` above, not echoed from
|
||||
// the request: anything that is not exactly "bash" runs Claude, so
|
||||
// echoing the raw value would label an unrecognised string as its own
|
||||
// type and put the client back where it started.
|
||||
session_type: if session_type == Some("bash") { "bash" } else { "claude" }.to_string(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||
"productName": "Triple-C",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"identifier": "com.triple-c.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; frame-src http://127.0.0.1:47820 http://127.0.0.1:47821 http://127.0.0.1:47822 http://127.0.0.1:47823 http://127.0.0.1:47824 http://127.0.0.1:47825 http://127.0.0.1:47826 http://127.0.0.1:47827"
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost; frame-src http://127.0.0.1:47820 http://127.0.0.1:47821 http://127.0.0.1:47822 http://127.0.0.1:47823 http://127.0.0.1:47824 http://127.0.0.1:47825 http://127.0.0.1:47826 http://127.0.0.1:47827; form-action 'none'; base-uri 'none'; object-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
@@ -33,6 +33,7 @@
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.png"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import DockerInstallDialog from "./components/DockerInstallDialog";
|
||||
import ProjectHome from "./components/projects/home/ProjectHome";
|
||||
import AddProjectDialog from "./components/projects/AddProjectDialog";
|
||||
import ToastHost from "./components/ui/ToastHost";
|
||||
import { PaneVisibilityProvider } from "./components/ui/PaneVisibility";
|
||||
import StatusIndicator from "./components/ui/StatusIndicator";
|
||||
import Button from "./components/ui/Button";
|
||||
import { useDocker } from "./hooks/useDocker";
|
||||
@@ -128,19 +129,34 @@ export default function App() {
|
||||
<WelcomeScreen />
|
||||
) : (
|
||||
<div className="w-full h-full">
|
||||
{/* Every tab stays mounted and the inactive ones are merely
|
||||
`hidden`, which a dialog's portal to `document.body` does not
|
||||
inherit: a confirmation opened in one project stayed painted
|
||||
over whatever tab the user switched to, kept its focus trap,
|
||||
and — being a blocking overlay — refused every native file
|
||||
drop in the window. `PaneVisibilityProvider` is how a `Modal`
|
||||
inside a pane finds out the pane stepped aside. */}
|
||||
{homeProjectIds.map((projectId) => (
|
||||
<ProjectHome
|
||||
<PaneVisibilityProvider
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
active={activeTabKey === homeTabKey(projectId)}
|
||||
/>
|
||||
visible={activeTabKey === homeTabKey(projectId)}
|
||||
>
|
||||
<ProjectHome
|
||||
projectId={projectId}
|
||||
active={activeTabKey === homeTabKey(projectId)}
|
||||
/>
|
||||
</PaneVisibilityProvider>
|
||||
))}
|
||||
{sessions.map((session) => (
|
||||
<TerminalView
|
||||
<PaneVisibilityProvider
|
||||
key={session.id}
|
||||
sessionId={session.id}
|
||||
active={session.id === activeSessionId}
|
||||
/>
|
||||
visible={session.id === activeSessionId}
|
||||
>
|
||||
<TerminalView
|
||||
sessionId={session.id}
|
||||
active={session.id === activeSessionId}
|
||||
/>
|
||||
</PaneVisibilityProvider>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -156,6 +172,9 @@ export default function App() {
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg-primary)]/95 backdrop-blur-sm"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
/* Covers the whole window, so no pane underneath may accept a
|
||||
native file drop while it is up — see `lib/dropTarget.ts`. */
|
||||
data-blocks-drop="true"
|
||||
data-testid="shutdown-overlay"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 px-6 text-center">
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderMarkdown } from "./HelpDialog";
|
||||
|
||||
/**
|
||||
* `renderMarkdown` builds HTML by regex substitution and the result is handed
|
||||
* to `dangerouslySetInnerHTML`. Its input is the help document, which is
|
||||
* fetched from GitHub at runtime — remote, versioned by someone else, and not
|
||||
* something the app gets to trust. These tests pin the escaping.
|
||||
*/
|
||||
/**
|
||||
* Parse rendered HTML and return its first anchor, asserting that *no* element
|
||||
* anywhere in the output grew an attribute outside the allowed set. A broken
|
||||
* attribute value is only interesting if it becomes an attribute, so the check
|
||||
* has to run through a real parser rather than over the string.
|
||||
*/
|
||||
const ALLOWED_ATTRS = new Set(["class", "href", "target", "rel", "id"]);
|
||||
|
||||
function onlyAnchor(html: string): HTMLAnchorElement {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
for (const el of Array.from(doc.body.querySelectorAll("*"))) {
|
||||
for (const name of attrNames(el)) {
|
||||
expect(ALLOWED_ATTRS.has(name), `unexpected attribute ${name}`).toBe(true);
|
||||
}
|
||||
}
|
||||
const anchors = doc.querySelectorAll("a");
|
||||
expect(anchors.length).toBeGreaterThan(0);
|
||||
return anchors[0] as HTMLAnchorElement;
|
||||
}
|
||||
|
||||
/** Attribute names the parser actually saw on an element. */
|
||||
function attrNames(el: Element): string[] {
|
||||
return Array.from(el.attributes).map((a) => a.name);
|
||||
}
|
||||
|
||||
describe("renderMarkdown escaping", () => {
|
||||
it("escapes the quote characters an attribute value is delimited by", () => {
|
||||
const html = renderMarkdown('He said "hi" and it\'s fine.');
|
||||
expect(html).not.toMatch(/said "hi"/);
|
||||
expect(html).toContain(""hi"");
|
||||
expect(html).toContain("it's");
|
||||
});
|
||||
|
||||
it("does not let a link target break out of href=\"…\"", () => {
|
||||
// The sink: the URL capture is `[^)]+`, which includes `"` and spaces, and
|
||||
// the value lands directly inside `href="…"`. Asserted through the DOM,
|
||||
// not by string matching — the payload text legitimately survives *inside*
|
||||
// the attribute value; what must not happen is it becoming an attribute.
|
||||
const a = onlyAnchor(
|
||||
renderMarkdown(
|
||||
'[click](https://example.com/" onmouseover="steal() formaction="https://evil.example)',
|
||||
),
|
||||
);
|
||||
expect(attrNames(a)).toEqual(["class", "href", "target", "rel"]);
|
||||
expect(a.getAttribute("href")).toContain('" onmouseover="');
|
||||
});
|
||||
|
||||
it("does not let an in-document anchor break out of href=\"#…\"", () => {
|
||||
const a = onlyAnchor(
|
||||
renderMarkdown('[jump](#top" onfocus="steal() autofocus="x)'),
|
||||
);
|
||||
expect(attrNames(a)).toEqual(["class", "href"]);
|
||||
});
|
||||
|
||||
it("does not let a bare URL break out of href=\"…\"", () => {
|
||||
const a = onlyAnchor(
|
||||
renderMarkdown('See https://example.com/a"onmouseover="steal()\n'),
|
||||
);
|
||||
expect(attrNames(a)).toEqual(["class", "href", "target", "rel"]);
|
||||
});
|
||||
|
||||
it("still renders ordinary links intact", () => {
|
||||
const html = renderMarkdown("[docs](https://example.com/a?x=1&y=2)");
|
||||
// `&` was entity-escaped by the first pass and must not be escaped twice.
|
||||
expect(html).toContain('href="https://example.com/a?x=1&y=2"');
|
||||
expect(html).not.toContain("&amp;");
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
expect(html).toContain(">docs</a>");
|
||||
});
|
||||
|
||||
it("still renders an in-document anchor link intact", () => {
|
||||
const html = renderMarkdown("[jump](#getting-started)");
|
||||
expect(html).toContain('href="#getting-started"');
|
||||
});
|
||||
|
||||
it("keeps header slugs stable across the new quote escaping", () => {
|
||||
// The regression this guards: quotes now become entities *before*
|
||||
// `slugify` sees them, and an entity's letters would otherwise survive
|
||||
// into the id ("claude39s-setup"), silently breaking every
|
||||
// `[…](#claudes-setup)` in the document.
|
||||
expect(renderMarkdown("## Claude's setup")).toContain('id="claudes-setup"');
|
||||
expect(renderMarkdown('## The "safe" mode')).toContain('id="the-safe-mode"');
|
||||
});
|
||||
|
||||
it("still refuses to emit raw tags from the source document", () => {
|
||||
const html = renderMarkdown("<img src=x onerror=alert(1)>");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).toContain("<img");
|
||||
});
|
||||
});
|
||||
@@ -12,21 +12,67 @@ function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/<[^>]+>/g, "") // strip HTML tags (e.g. from inline code)
|
||||
// Quote characters are escaped to entities before this runs (see
|
||||
// `renderMarkdown`). Drop those two entities whole, so a header with an
|
||||
// apostrophe or a quote slugifies to what it did when the character was
|
||||
// simply stripped — otherwise every such anchor id silently changes and
|
||||
// the in-document links pointing at it stop resolving. `&`/`<`/
|
||||
// `>` are deliberately not in this list: they were already entities
|
||||
// before, so their existing (odd) slugs are the established ones.
|
||||
.replace(/"|'/g, "")
|
||||
.replace(/[^\w\s-]/g, "") // remove non-word chars except spaces/dashes
|
||||
.replace(/\s+/g, "-") // spaces to dashes
|
||||
.replace(/-+/g, "-") // collapse consecutive dashes
|
||||
.replace(/^-|-$/g, ""); // trim leading/trailing dashes
|
||||
}
|
||||
|
||||
/** Simple markdown-to-HTML converter for the help content. */
|
||||
function renderMarkdown(md: string): string {
|
||||
/**
|
||||
* Escape a captured markdown value that is about to be interpolated into an
|
||||
* HTML *attribute* value.
|
||||
*
|
||||
* `renderMarkdown` entity-escapes the whole document first, but that pass only
|
||||
* covered `&`, `<` and `>` — not the quote characters, which is all an
|
||||
* attribute value is delimited by. `[x](https://a" onload="…)` therefore closed
|
||||
* `href="` and started a new attribute, because the URL capture is `[^)]+` and
|
||||
* `"` is in `[^)]`. The document is remote GitHub markdown, so that capture is
|
||||
* not ours to trust.
|
||||
*
|
||||
* Only quotes are escaped here: `&`, `<` and `>` have already been converted by
|
||||
* the caller, and re-escaping the `&` would double-encode every `&` in a
|
||||
* query string.
|
||||
*/
|
||||
function attr(value: string): string {
|
||||
return value.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple markdown-to-HTML converter for the help content.
|
||||
*
|
||||
* Exported for `HelpDialog.test.tsx`: the output goes to
|
||||
* `dangerouslySetInnerHTML`, so the escaping rules below are security rules and
|
||||
* need to be asserted rather than assumed.
|
||||
*/
|
||||
export function renderMarkdown(md: string): string {
|
||||
let html = md;
|
||||
|
||||
// Normalize line endings
|
||||
html = html.replace(/\r\n/g, "\n");
|
||||
|
||||
// Escape HTML entities (but we'll re-introduce tags below)
|
||||
html = html.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
// Escape HTML entities (but we'll re-introduce tags below).
|
||||
//
|
||||
// The quote characters are part of this on purpose. Everything below builds
|
||||
// HTML by regex substitution, and several of those substitutions drop a
|
||||
// capture straight into an attribute value (`href="$2"`). Leaving `"` and `'`
|
||||
// live meant a link target could close the attribute and open another one —
|
||||
// in a document fetched from GitHub at runtime and handed to
|
||||
// `dangerouslySetInnerHTML`. Escaping here closes every such sink at the
|
||||
// source; `attr()` below is the belt to this pair of braces.
|
||||
html = html
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
// Fenced code blocks (```...```)
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||
@@ -84,13 +130,15 @@ function renderMarkdown(md: string): string {
|
||||
// Markdown-style anchor links [text](#anchor)
|
||||
html = html.replace(
|
||||
/\[([^\]]+)\]\(#([^)]+)\)/g,
|
||||
'<a class="help-link" href="#$2">$1</a>',
|
||||
(_m, text: string, anchor: string) =>
|
||||
`<a class="help-link" href="#${attr(anchor)}">${text}</a>`,
|
||||
);
|
||||
|
||||
// Markdown-style external links [text](url)
|
||||
html = html.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
|
||||
'<a class="help-link" href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||
(_m, text: string, url: string) =>
|
||||
`<a class="help-link" href="${attr(url)}" target="_blank" rel="noopener noreferrer">${text}</a>`,
|
||||
);
|
||||
|
||||
// Unordered list items (- ...)
|
||||
@@ -117,7 +165,8 @@ function renderMarkdown(md: string): string {
|
||||
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
|
||||
html = html.replace(
|
||||
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
|
||||
'<a class="help-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||
(_m, url: string) =>
|
||||
`<a class="help-link" href="${attr(url)}" target="_blank" rel="noopener noreferrer">${url}</a>`,
|
||||
);
|
||||
|
||||
// Wrap remaining loose text lines in paragraphs
|
||||
|
||||
@@ -127,6 +127,59 @@ describe("MainTabs reordering", () => {
|
||||
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
||||
});
|
||||
|
||||
it("does not let a drag select the tab's text", () => {
|
||||
// A pointer-driven drag is still a mouse drag as far as the browser is
|
||||
// concerned, so without this the label highlights blue while you move it.
|
||||
// The rename field is exempt — selecting there is the whole point.
|
||||
render(<MainTabs />);
|
||||
for (const tab of screen.getAllByRole("tab")) {
|
||||
expect(tab.className).toContain("select-none");
|
||||
}
|
||||
|
||||
fireEvent.doubleClick(screen.getAllByRole("tab")[1]);
|
||||
expect(screen.getByLabelText("Rename tab").className).toContain("select-text");
|
||||
});
|
||||
|
||||
it("shows the tab itself under the cursor while dragging", () => {
|
||||
// A dimmed source tab and a thin line do not read as "I am holding this
|
||||
// tab" — the dragged copy is what makes the gesture legible.
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||
|
||||
pointer(tabs[2], "pointerdown", 250);
|
||||
pointer(tabs[2], "pointermove", 120);
|
||||
|
||||
const ghost = screen.getByTestId("tab-drag-ghost");
|
||||
expect(ghost).toHaveTextContent("shell (bash)");
|
||||
expect(ghost).toHaveTextContent("▣");
|
||||
|
||||
pointer(tabs[2], "pointerup", 120);
|
||||
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||
});
|
||||
|
||||
it("carries the project name when a home tab is dragged", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
pointer(tabs[0], "pointerdown", 50);
|
||||
pointer(tabs[0], "pointermove", 250);
|
||||
|
||||
expect(screen.getByTestId("tab-drag-ghost")).toHaveTextContent("api-server");
|
||||
expect(screen.getByTestId("tab-drag-ghost")).toHaveTextContent("⌂");
|
||||
});
|
||||
|
||||
it("drops the dragged copy when the drag is abandoned", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
pointer(tabs[2], "pointerdown", 250);
|
||||
pointer(tabs[2], "pointermove", 10);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
|
||||
expect(screen.queryByTestId("tab-drag-ghost")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the drop marker only while a drag is under way", () => {
|
||||
render(<MainTabs />);
|
||||
const tabs = laidOut();
|
||||
|
||||
@@ -55,9 +55,21 @@ export default function MainTabs() {
|
||||
/** The tab being dragged, and the slot it would drop into. */
|
||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
/** Where the dragged tab is drawn, and how it looked when the drag started. */
|
||||
const [ghost, setGhost] = useState<{ x: number; y: number; label: string; icon: string } | null>(
|
||||
null,
|
||||
);
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
/** A press that has not yet moved far enough to be a drag. */
|
||||
const pending = useRef<{ key: string; startX: number; dragging: boolean } | null>(null);
|
||||
const pending = useRef<{
|
||||
key: string;
|
||||
startX: number;
|
||||
dragging: boolean;
|
||||
offsetX: number;
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
} | null>(null);
|
||||
const suppressClick = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,6 +99,7 @@ export default function MainTabs() {
|
||||
pending.current = null;
|
||||
setDragKey(null);
|
||||
setDropIndex(null);
|
||||
setGhost(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
@@ -165,16 +178,35 @@ export default function MainTabs() {
|
||||
};
|
||||
|
||||
const tabClass = (active: boolean, dragging: boolean) =>
|
||||
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
|
||||
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer select-none border-r border-[var(--border-color)] transition-colors ${
|
||||
active
|
||||
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
}${dragging ? " opacity-40" : ""}`;
|
||||
|
||||
/**
|
||||
* What a tab reads as, for the dragged copy. Same sources the tab itself
|
||||
* uses — a ghost showing a different name from the tab it came from would be
|
||||
* worse than no ghost.
|
||||
*/
|
||||
const tabLabel = (key: string): string => {
|
||||
if (isHomeTab(key)) {
|
||||
return projects.find((p) => p.id === tabKeyId(key))?.name ?? "";
|
||||
}
|
||||
const session = sessions.find((s) => s.id === tabKeyId(key));
|
||||
if (!session) return "";
|
||||
const custom = getCustomName(session.projectId, session.id);
|
||||
return custom
|
||||
? `${session.projectName}: ${custom}`
|
||||
: (session.sessionName ?? session.projectName) +
|
||||
(session.sessionType === "bash" ? " (bash)" : "");
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
pending.current = null;
|
||||
setDragKey(null);
|
||||
setDropIndex(null);
|
||||
setGhost(null);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -212,7 +244,19 @@ export default function MainTabs() {
|
||||
// rename input is up — that drag is a text selection.
|
||||
if (e.button !== 0 || renaming) return;
|
||||
if ((e.target as HTMLElement).closest("button, input")) return;
|
||||
pending.current = { key, startX: e.clientX, dragging: false };
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
pending.current = {
|
||||
key,
|
||||
startX: e.clientX,
|
||||
dragging: false,
|
||||
// Where inside the tab the pointer grabbed it, so the ghost sits under
|
||||
// the cursor exactly where the real tab was — the thing that makes a
|
||||
// drag feel like moving an object rather than nudging a setting.
|
||||
offsetX: e.clientX - rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
top: rect.top,
|
||||
};
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
},
|
||||
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -223,6 +267,12 @@ export default function MainTabs() {
|
||||
drag.dragging = true;
|
||||
setDragKey(drag.key);
|
||||
setDropIndex(dropIndexAt(e.clientX));
|
||||
setGhost({
|
||||
x: e.clientX - drag.offsetX,
|
||||
y: drag.top,
|
||||
label: tabLabel(drag.key),
|
||||
icon: isHomeTab(drag.key) ? "⌂" : "▣",
|
||||
});
|
||||
},
|
||||
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = pending.current;
|
||||
@@ -352,7 +402,7 @@ export default function MainTabs() {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
}}
|
||||
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||
className="max-w-[180px] px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[180px]" title={displayLabel}>
|
||||
@@ -408,6 +458,26 @@ export default function MainTabs() {
|
||||
the hand naturally goes to say "put it at the end". */}
|
||||
<div className="flex-1 self-stretch">{markerPending && dropMarker}</div>
|
||||
|
||||
{ghost && (
|
||||
// A copy of the tab, following the pointer. Without it the only
|
||||
// feedback is a dimmed source and a thin line, which reads as "some
|
||||
// setting changed" rather than "I am holding this tab".
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="tab-drag-ghost"
|
||||
className="fixed z-50 flex items-center gap-1.5 px-3 h-8 text-xs rounded-[var(--radius-control)] bg-[var(--bg-primary)] text-[var(--text-primary)] border border-[var(--accent)] pointer-events-none select-none"
|
||||
style={{
|
||||
left: ghost.x,
|
||||
top: ghost.y,
|
||||
boxShadow: "var(--shadow-overlay)",
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
<span className="text-[var(--text-secondary)]">{ghost.icon}</span>
|
||||
<span className="truncate max-w-[180px]">{ghost.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menu && (() => {
|
||||
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||
const hasCustom = session
|
||||
|
||||
@@ -23,6 +23,11 @@ export default function StatusBar({ stt }: Props) {
|
||||
}))
|
||||
);
|
||||
const running = projects.filter((p) => p.status === "running").length;
|
||||
// Only in a Claude tab: the chord is bound there and nowhere else, and a hint
|
||||
// for a key that does nothing is worse than no hint.
|
||||
const inClaudeSession = sessions.some(
|
||||
(s) => s.id === activeSessionId && s.sessionType === "claude",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs text-[var(--text-secondary)]">
|
||||
@@ -45,6 +50,14 @@ export default function StatusBar({ stt }: Props) {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!terminalHasSelection && inClaudeSession && (
|
||||
<>
|
||||
<span className="mx-2">|</span>
|
||||
<span title="Sends ESC+CR — the sequence Claude Code's own /terminal-setup installs. Alt+Enter does the same.">
|
||||
Shift+Enter: newline
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* Right-aligned controls: Jump to Current + STT mic */}
|
||||
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||
{activeSessionId && !terminalAtBottom && (
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ClaudeCodeSettingsEditor, { CLAUDE_CODE_DEFAULTS } from "./ClaudeCodeSettingsEditor";
|
||||
import type { ClaudeCodeSettings } from "../../lib/types";
|
||||
|
||||
function renderEditor(
|
||||
settings: ClaudeCodeSettings | null,
|
||||
scope: "global" | "project" = "global",
|
||||
) {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<ClaudeCodeSettingsEditor
|
||||
scope={scope}
|
||||
settings={settings}
|
||||
disabled={false}
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
return onSave;
|
||||
}
|
||||
|
||||
describe("ClaudeCodeSettingsEditor", () => {
|
||||
it("shows the two default-on settings as on for a project that never touched them", () => {
|
||||
// Claude Code's session recap and fullscreen auto-scroll are both on by
|
||||
// default, and the fields behind them store the *disabled* sense. A toggle
|
||||
// rendered straight from the field would tell every existing user their
|
||||
// recap is off.
|
||||
renderEditor(null);
|
||||
expect(screen.getByRole("switch", { name: "Session recap" })).toBeChecked();
|
||||
expect(screen.getByRole("switch", { name: "Auto-scroll" })).toBeChecked();
|
||||
expect(screen.getByRole("switch", { name: "Focus mode" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("stores the disabled sense when an inverted toggle is switched off", () => {
|
||||
const onSave = renderEditor(null);
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ session_recap_disabled: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses back to null once every setting is at its default again", () => {
|
||||
// `null` is what tells the backend this project adds nothing over the
|
||||
// global settings, so the round trip has to land exactly back on it.
|
||||
const onSave = renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true });
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||
expect(onSave).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("offers the classic renderer as a choice distinct from automatic", () => {
|
||||
// Leaving `tui` unset lets Claude Code pick; pinning "default" is a
|
||||
// different, and previously unreachable, instruction.
|
||||
const onSave = renderEditor(null);
|
||||
const tui = screen.getByLabelText("TUI mode");
|
||||
expect(
|
||||
Array.from(tui.querySelectorAll("option")).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["", "default", "fullscreen"]);
|
||||
fireEvent.change(tui, { target: { value: "default" } });
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tui_mode: "default" }));
|
||||
});
|
||||
|
||||
it("offers every effort level Claude Code accepts", () => {
|
||||
// Verified against the shipped `claude` binary's own schema rather than
|
||||
// inferred: low/medium/high/xhigh/max. `max` was missing until an audit
|
||||
// checked externally — which is the whole weakness of this test. It can
|
||||
// only prove the editor agrees with this list, never that the list is the
|
||||
// one Claude Code reads. The same blind spot is why `effort` and
|
||||
// `focusMode` were confidently wrong for months.
|
||||
renderEditor(null);
|
||||
expect(
|
||||
Array.from(
|
||||
screen.getByLabelText("Effort level").querySelectorAll("option"),
|
||||
).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["", "low", "medium", "high", "xhigh", "max"]);
|
||||
});
|
||||
|
||||
describe("project scope", () => {
|
||||
it("offers Global as a third state so a project can decline to have an opinion", () => {
|
||||
renderEditor(null, "project");
|
||||
const focus = screen.getByLabelText("Focus mode");
|
||||
expect(
|
||||
Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["global", "off", "on"]);
|
||||
expect((focus as HTMLSelectElement).value).toBe("global");
|
||||
});
|
||||
|
||||
it("stores a deliberate false so the project can turn a global On back off", () => {
|
||||
// The reason the field widened from boolean to boolean|null. Under the
|
||||
// old merge there was no project value that could produce this.
|
||||
const onSave = renderEditor(null, "project");
|
||||
fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ focus_mode: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collapse a deliberate off to null", () => {
|
||||
// `null` means inherit. Collapsing here would silently hand the setting
|
||||
// straight back to the global value the user just overrode.
|
||||
const onSave = renderEditor(null, "project");
|
||||
fireEvent.change(screen.getByLabelText("Focus mode"), { target: { value: "off" } });
|
||||
expect(onSave).not.toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("round-trips the inverted fields through the disabled sense", () => {
|
||||
// Session recap stores `session_recap_disabled`, so choosing "off" has to
|
||||
// store `true` and choosing "on" has to store `false`.
|
||||
const onSave = renderEditor(null, "project");
|
||||
const recap = screen.getByLabelText("Session recap");
|
||||
|
||||
fireEvent.change(recap, { target: { value: "off" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ session_recap_disabled: true }),
|
||||
);
|
||||
|
||||
fireEvent.change(recap, { target: { value: "on" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ session_recap_disabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a stored override rather than the inherited state", () => {
|
||||
renderEditor({ ...CLAUDE_CODE_DEFAULTS, session_recap_disabled: true }, "project");
|
||||
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe(
|
||||
"off",
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Auto-scroll is the second inverted field and had no project-scope test at
|
||||
* all — every assertion above rides on `session_recap_disabled`, so a
|
||||
* `BOOLEAN_FIELDS` entry that lost its `invert` flag would be caught for
|
||||
* one of the two and pass silently for the other. It is stored as
|
||||
* `auto_scroll_disabled`, so every value here reads back the other way up.
|
||||
*/
|
||||
describe("auto-scroll", () => {
|
||||
const AUTO = "Auto-scroll";
|
||||
|
||||
it("starts on Global, which is not the same as on", () => {
|
||||
// Claude Code scrolls by default, so an inheriting project *behaves*
|
||||
// as on — but it has taken no position, and rendering it as "On" would
|
||||
// make a later global change look like it had no effect.
|
||||
renderEditor(null, "project");
|
||||
expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("global");
|
||||
});
|
||||
|
||||
it("stores the disabled sense in both directions", () => {
|
||||
const onSave = renderEditor(null, "project");
|
||||
const auto = screen.getByLabelText(AUTO);
|
||||
|
||||
fireEvent.change(auto, { target: { value: "off" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auto_scroll_disabled: true }),
|
||||
);
|
||||
|
||||
fireEvent.change(auto, { target: { value: "on" } });
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ auto_scroll_disabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hands the setting back to the global level when Global is chosen", () => {
|
||||
// Back to no opinion, and with nothing else set that collapses the
|
||||
// whole object to `null` — the value that means "adds nothing over the
|
||||
// global settings".
|
||||
const onSave = renderEditor(
|
||||
{ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true },
|
||||
"project",
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(AUTO), { target: { value: "global" } });
|
||||
expect(onSave).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("reads a stored override back the right way up", () => {
|
||||
renderEditor({ ...CLAUDE_CODE_DEFAULTS, auto_scroll_disabled: true }, "project");
|
||||
expect((screen.getByLabelText(AUTO) as HTMLSelectElement).value).toBe("off");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The inverted fields store a *deviation*, so a stored `false` is the one
|
||||
* value that means "the user deliberately re-enabled the default". Nothing
|
||||
* asserted it: every existing test drives the `true` (turned off) direction
|
||||
* or the `null` (untouched) one, and both scopes would still read correctly
|
||||
* if the inversion were dropped from the `false` branch alone.
|
||||
*/
|
||||
describe.each([
|
||||
["session_recap_disabled", "Session recap"] as const,
|
||||
["auto_scroll_disabled", "Auto-scroll"] as const,
|
||||
])("a stored false on %s", (key, label) => {
|
||||
it("reads as On at project scope, not as Off", () => {
|
||||
renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false }, "project");
|
||||
expect((screen.getByLabelText(label) as HTMLSelectElement).value).toBe("on");
|
||||
});
|
||||
|
||||
it("reads as on at global scope, where the control is a switch", () => {
|
||||
renderEditor({ ...CLAUDE_CODE_DEFAULTS, [key]: false });
|
||||
expect(screen.getByRole("switch", { name: label })).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A settings object with nothing set at this level arrives as `{}`: the Rust
|
||||
* struct skips serialising a field it has no value for, which is what keeps
|
||||
* an older binary able to parse `projects.json` after a downgrade. It is also
|
||||
* the exact shape a project stored before the fields were widened is read
|
||||
* back as — every one of its `false`s meant "unset" — so reading absent as
|
||||
* "off" would show a switch the user never touched as a deliberate choice.
|
||||
*/
|
||||
it("reads an absent field as Global rather than as Off", () => {
|
||||
renderEditor({} as ClaudeCodeSettings, "project");
|
||||
expect((screen.getByLabelText("Env scrub") as HTMLSelectElement).value).toBe("global");
|
||||
expect((screen.getByLabelText("Session recap") as HTMLSelectElement).value).toBe("global");
|
||||
});
|
||||
|
||||
it("still collapses to null when an absent-field object is edited back", () => {
|
||||
const onSave = renderEditor({} as ClaudeCodeSettings, "global");
|
||||
// Off and straight back on: the round trip has to land on `null`, or an
|
||||
// untouched global stops being indistinguishable from one never opened.
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Session recap" }));
|
||||
expect(onSave).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -8,52 +8,91 @@ interface Props {
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
onSave: (settings: ClaudeCodeSettings | null) => Promise<unknown>;
|
||||
/**
|
||||
* `"project"` adds a third "Global" state to every switch, because a project
|
||||
* has somewhere to inherit *from*. The global editor has no such fallback —
|
||||
* unset there just means Claude Code's own default — so it stays a plain
|
||||
* on/off and never renders the extra choice.
|
||||
*/
|
||||
scope?: "global" | "project";
|
||||
}
|
||||
|
||||
export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
||||
tui_mode: null,
|
||||
effort: null,
|
||||
auto_scroll_disabled: false,
|
||||
focus_mode: false,
|
||||
show_thinking_summaries: false,
|
||||
enable_session_recap: false,
|
||||
env_scrub: false,
|
||||
prompt_caching_1h: false,
|
||||
auto_scroll_disabled: null,
|
||||
focus_mode: null,
|
||||
show_thinking_summaries: null,
|
||||
session_recap_disabled: null,
|
||||
env_scrub: null,
|
||||
prompt_caching_1h: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* "Nothing is set at this level", which is saved as `null` rather than as a
|
||||
* struct of nulls so that a project with no opinion is indistinguishable from
|
||||
* one that never opened this editor.
|
||||
*
|
||||
* Note `false` is *not* a default any more: it is a deliberate off that
|
||||
* overrides a global on, so a settings object holding one has to be persisted.
|
||||
*/
|
||||
function isAllDefaults(s: ClaudeCodeSettings): boolean {
|
||||
// `== null`, not `===`: an unset field is *absent* on the wire, not null.
|
||||
// The Rust struct skips serialising one it has no value for, so a project
|
||||
// whose stored settings were all "unset" arrives here as `{}` — and reading
|
||||
// that as "off" is exactly the mistake the three-state control exists to
|
||||
// avoid. See the note on `ClaudeCodeSettings` in `lib/types.ts`.
|
||||
return (
|
||||
s.tui_mode === null &&
|
||||
s.effort === null &&
|
||||
s.auto_scroll_disabled === false &&
|
||||
s.focus_mode === false &&
|
||||
s.show_thinking_summaries === false &&
|
||||
s.enable_session_recap === false &&
|
||||
s.env_scrub === false &&
|
||||
s.prompt_caching_1h === false
|
||||
s.tui_mode == null &&
|
||||
s.effort == null &&
|
||||
s.auto_scroll_disabled == null &&
|
||||
s.focus_mode == null &&
|
||||
s.show_thinking_summaries == null &&
|
||||
s.session_recap_disabled == null &&
|
||||
s.env_scrub == null &&
|
||||
s.prompt_caching_1h == null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two of Claude Code's settings are **on by default**, so the field behind them
|
||||
* stores the *disabled* sense (`auto_scroll_disabled`, `session_recap_disabled`)
|
||||
* — that is what makes an untouched project mean "leave Claude Code alone"
|
||||
* rather than "the user turned this off". `invert` is what lets those still
|
||||
* read as an ordinary on/off switch here: the toggle shows the feature's state,
|
||||
* the field stores the deviation from the default.
|
||||
*/
|
||||
const BOOLEAN_FIELDS: {
|
||||
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
|
||||
label: string;
|
||||
hint: string;
|
||||
invert?: boolean;
|
||||
}[] = [
|
||||
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
|
||||
{
|
||||
key: "focus_mode",
|
||||
label: "Focus mode",
|
||||
// It summarises tool *calls*, not all output — and it does nothing at all
|
||||
// unless the fullscreen renderer is on, which is a separate switch above.
|
||||
// Saying so here is cheaper than the user concluding the setting is broken,
|
||||
// which is the complaint that started this whole round of work.
|
||||
hint: "Summarises each tool call to one line, showing the last prompt and the final response. Needs TUI mode set to Fullscreen.",
|
||||
},
|
||||
{
|
||||
key: "show_thinking_summaries",
|
||||
label: "Thinking summaries",
|
||||
hint: "Shows Claude's thinking process as summaries.",
|
||||
},
|
||||
{
|
||||
key: "enable_session_recap",
|
||||
key: "session_recap_disabled",
|
||||
label: "Session recap",
|
||||
hint: "Provides context when returning to a session.",
|
||||
hint: "Shows a one-line recap when you return to the terminal after a few minutes away.",
|
||||
invert: true,
|
||||
},
|
||||
{
|
||||
key: "auto_scroll_disabled",
|
||||
label: "Auto-scroll disabled",
|
||||
hint: "Disables auto-scroll when in fullscreen TUI mode.",
|
||||
label: "Auto-scroll",
|
||||
hint: "Follows new output to the bottom in fullscreen rendering.",
|
||||
invert: true,
|
||||
},
|
||||
{
|
||||
key: "env_scrub",
|
||||
@@ -72,6 +111,7 @@ export default function ClaudeCodeSettingsEditor({
|
||||
disabled,
|
||||
disabledReason,
|
||||
onSave,
|
||||
scope = "global",
|
||||
}: Props) {
|
||||
const [local, setLocal] = useState<ClaudeCodeSettings>(
|
||||
settings ?? { ...CLAUDE_CODE_DEFAULTS },
|
||||
@@ -95,9 +135,16 @@ export default function ClaudeCodeSettingsEditor({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Three states, not two. Leaving `tui` unset is what lets Claude Code pick
|
||||
the renderer for itself, which is not the same as pinning the classic
|
||||
one — and the key is now always written (or explicitly deleted), so
|
||||
"Automatic" has to be selectable rather than merely being what you get
|
||||
when nothing is emitted.
|
||||
*/}
|
||||
<SwitchRow
|
||||
label="TUI mode"
|
||||
hint="Enables flicker-free alt-screen rendering."
|
||||
hint="Classic renders in your terminal's scrollback; fullscreen is the flicker-free alt-screen."
|
||||
control={
|
||||
<select
|
||||
value={local.tui_mode ?? ""}
|
||||
@@ -106,7 +153,8 @@ export default function ClaudeCodeSettingsEditor({
|
||||
disabled={disabled}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option value="">Automatic</option>
|
||||
<option value="default">Classic</option>
|
||||
<option value="fullscreen">Fullscreen</option>
|
||||
</select>
|
||||
}
|
||||
@@ -127,25 +175,79 @@ export default function ClaudeCodeSettingsEditor({
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="xhigh">Extra high</option>
|
||||
{/* `max` is accepted by the CLI and was missing here. Confirmed
|
||||
against the shipped claude binary's own schema, not just docs. */}
|
||||
<option value="max">Maximum</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
|
||||
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
|
||||
<SwitchRow
|
||||
key={key}
|
||||
label={label}
|
||||
hint={hint}
|
||||
control={
|
||||
<Toggle
|
||||
{BOOLEAN_FIELDS.map(({ key, label, hint, invert }) => {
|
||||
const stored = local[key];
|
||||
|
||||
if (scope === "global") {
|
||||
// No level above this one to inherit from, so "unset" and "off" are
|
||||
// the same instruction here and a plain switch is the honest control.
|
||||
// Unset therefore has to *display* as Claude Code's own default —
|
||||
// which for the two inverted fields is on, not off.
|
||||
const checked = invert ? stored !== true : stored === true;
|
||||
return (
|
||||
<SwitchRow
|
||||
key={key}
|
||||
label={label}
|
||||
checked={local[key]}
|
||||
disabled={disabled}
|
||||
onChange={(v) => apply({ [key]: v } as Partial<ClaudeCodeSettings>)}
|
||||
hint={hint}
|
||||
control={
|
||||
<Toggle
|
||||
label={label}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(v) => {
|
||||
// Collapse back to null at the default rather than storing
|
||||
// a redundant `false`, so an untouched global stays
|
||||
// indistinguishable from one that was never opened.
|
||||
const atDefault = invert ? v : !v;
|
||||
apply({
|
||||
[key]: atDefault ? null : invert ? !v : v,
|
||||
} as Partial<ClaudeCodeSettings>);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
}
|
||||
|
||||
// `stored` holds the deviation from Claude Code's default, so an
|
||||
// inverted field reads back the other way round — see BOOLEAN_FIELDS.
|
||||
const selected =
|
||||
stored == null ? "global" : (invert ? !stored : stored) ? "on" : "off";
|
||||
|
||||
return (
|
||||
<SwitchRow
|
||||
key={key}
|
||||
label={label}
|
||||
hint={hint}
|
||||
control={
|
||||
<select
|
||||
value={selected}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const choice = e.target.value;
|
||||
const next =
|
||||
choice === "global" ? null : invert ? choice === "off" : choice === "on";
|
||||
apply({ [key]: next } as Partial<ClaudeCodeSettings>);
|
||||
}}
|
||||
className={selectClass}
|
||||
>
|
||||
<option value="global">Global</option>
|
||||
<option value="off">Off</option>
|
||||
<option value="on">On</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,10 +28,24 @@ export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }:
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/*
|
||||
Everything remove_project() destroys, named. It removes the container,
|
||||
*both* named volumes (triple-c-home-{id} and triple-c-claude-config-{id}),
|
||||
the triple-c-snapshot-{id} image and the project's keychain secrets — so
|
||||
an accurate warning has to reach past "the config volume". The last
|
||||
sentence is the reassuring half and matters just as much: project folders
|
||||
are bind mounts from the host and nothing here touches them.
|
||||
*/}
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Are you sure you want to remove{" "}
|
||||
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
|
||||
delete the container, config volume, and stored credentials.
|
||||
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This deletes
|
||||
its container, both of its volumes and its saved container image — so the home
|
||||
directory, the Claude login and config, installed skills, session transcripts,
|
||||
scheduled tasks and any stored credentials all go with it.
|
||||
</p>
|
||||
<p className="mt-2 text-[13px] text-[var(--text-secondary)]">
|
||||
Your project folders on this machine are mounted in, not copied, and are left
|
||||
untouched.
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -43,26 +43,36 @@ export default function EnvVarsEditor({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* The row's widths live on wrapper divs, not on the inputs. `inputClass`
|
||||
carries `w-full`, and a width utility on the input itself does not beat
|
||||
it — class-attribute order is not what resolves the conflict, stylesheet
|
||||
order is. Sizing the key input directly left it asking for the whole row
|
||||
and collapsed the value input, whose `flex-1` basis of 0 gave it only the
|
||||
leftover space, to an unusable sliver. */}
|
||||
{vars.map((ev, i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<input
|
||||
value={ev.key}
|
||||
onChange={(e) => updateVar(i, "key", e.target.value)}
|
||||
onBlur={() => onSave(vars)}
|
||||
placeholder="KEY"
|
||||
aria-label={`Environment variable ${i + 1} name`}
|
||||
disabled={disabled}
|
||||
className={`w-2/5 ${monoInputClass}`}
|
||||
/>
|
||||
<input
|
||||
value={ev.value}
|
||||
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||
onBlur={() => onSave(vars)}
|
||||
placeholder="value"
|
||||
aria-label={`Environment variable ${i + 1} value`}
|
||||
disabled={disabled}
|
||||
className={`flex-1 ${monoInputClass}`}
|
||||
/>
|
||||
<div className="w-2/5 shrink-0">
|
||||
<input
|
||||
value={ev.key}
|
||||
onChange={(e) => updateVar(i, "key", e.target.value)}
|
||||
onBlur={() => onSave(vars)}
|
||||
placeholder="KEY"
|
||||
aria-label={`Environment variable ${i + 1} name`}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
value={ev.value}
|
||||
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||
onBlur={() => onSave(vars)}
|
||||
placeholder="value"
|
||||
aria-label={`Environment variable ${i + 1} value`}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import AutomationTab from "./AutomationTab";
|
||||
import type { Project, ScheduledTask } from "../../../lib/types";
|
||||
|
||||
const listScheduledTasks = vi.fn(async () => tasks);
|
||||
const getSchedulerNotifications = vi.fn(async () => []);
|
||||
const runScheduledTaskNow = vi.fn(async () => "started");
|
||||
const pushToast = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/tauri-commands", () => ({
|
||||
listScheduledTasks: () => listScheduledTasks(),
|
||||
getSchedulerNotifications: () => getSchedulerNotifications(),
|
||||
runScheduledTaskNow: (p: string, t: string) => runScheduledTaskNow(p, t),
|
||||
clearSchedulerNotifications: vi.fn(async () => {}),
|
||||
getScheduledTaskLog: vi.fn(async () => ""),
|
||||
removeScheduledTask: vi.fn(async () => {}),
|
||||
setScheduledTaskEnabled: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../../store/appState", () => ({
|
||||
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }),
|
||||
}));
|
||||
|
||||
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
|
||||
|
||||
const baseTask: ScheduledTask = {
|
||||
id: "a1b2c3d4",
|
||||
name: "nightly",
|
||||
prompt: "Run the suite",
|
||||
schedule: "0 3 * * *",
|
||||
task_type: "recurring",
|
||||
at: null,
|
||||
enabled: true,
|
||||
working_dir: "/workspace",
|
||||
created_at: null,
|
||||
last_run: null,
|
||||
next_run: null,
|
||||
running: false,
|
||||
running_since: null,
|
||||
};
|
||||
|
||||
let tasks: ScheduledTask[] = [];
|
||||
|
||||
async function renderTab() {
|
||||
render(<AutomationTab project={project} />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
tasks = [baseTask];
|
||||
listScheduledTasks.mockClear();
|
||||
runScheduledTaskNow.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("AutomationTab run state", () => {
|
||||
it("offers Run now for an idle task and says nothing about running", async () => {
|
||||
await renderTab();
|
||||
expect(screen.getByRole("button", { name: "Run now" })).toBeEnabled();
|
||||
expect(screen.queryByText(/Running/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a running task as running, with elapsed time, and blocks a second trigger", async () => {
|
||||
const startedSecondsAgo = new Date(Date.now() - 90_000).toISOString();
|
||||
tasks = [{ ...baseTask, running: true, running_since: startedSecondsAgo }];
|
||||
await renderTab();
|
||||
|
||||
// The whole point: a detached run is visible rather than silent.
|
||||
expect(screen.getByText(/Running for 1m/)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps polling after a trigger, so a run that has not registered yet still appears", async () => {
|
||||
await renderTab();
|
||||
const callsAfterLoad = listScheduledTasks.mock.calls.length;
|
||||
|
||||
// The runner needs a moment to write its state file; until then the task
|
||||
// still reads as idle, which is exactly the window that used to look dead.
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Run now" }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(runScheduledTaskNow).toHaveBeenCalledWith("p1", "a1b2c3d4");
|
||||
|
||||
tasks = [{ ...baseTask, running: true, running_since: new Date().toISOString() }];
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(listScheduledTasks.mock.calls.length).toBeGreaterThan(callsAfterLoad);
|
||||
expect(screen.getByRole("button", { name: "Running…" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("stops polling once nothing is running", async () => {
|
||||
await renderTab();
|
||||
// No trigger, nothing running: the interval must not be armed at all.
|
||||
const before = listScheduledTasks.mock.calls.length;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(listScheduledTasks.mock.calls.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import Toggle from "../../ui/Toggle";
|
||||
import Modal from "../../ui/Modal";
|
||||
import StatusIndicator from "../../ui/StatusIndicator";
|
||||
import TaskEditorModal from "./TaskEditorModal";
|
||||
import { formatAge } from "./format";
|
||||
import { formatAge, formatRunningFor } from "./format";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -59,6 +59,22 @@ export default function AutomationTab({ project }: Props) {
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
// A task in flight is the one state this view cannot sit still for: runs are
|
||||
// detached, so without polling "Run now" looks like it did nothing until the
|
||||
// user reaches for Refresh. Polling stops as soon as nothing is running.
|
||||
//
|
||||
// `justTriggered` covers the gap between firing a run and the runner writing
|
||||
// its state file — a second or two in which the task still reads as idle, and
|
||||
// where giving up on polling would reproduce the exact silence this fixes.
|
||||
const anyTaskRunning = tasks.some((t) => t.running);
|
||||
const [justTriggered, setJustTriggered] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
if (!anyTaskRunning && Date.now() - justTriggered > 20_000) return;
|
||||
const timer = setInterval(load, anyTaskRunning ? 5000 : 1500);
|
||||
return () => clearInterval(timer);
|
||||
}, [running, anyTaskRunning, justTriggered, load]);
|
||||
|
||||
const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => {
|
||||
setBusyTaskId(taskId);
|
||||
try {
|
||||
@@ -185,6 +201,12 @@ export default function AutomationTab({ project }: Props) {
|
||||
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
|
||||
{task.task_type}
|
||||
</span>
|
||||
{task.running && (
|
||||
<StatusIndicator
|
||||
tone="busy"
|
||||
label={`Running ${formatRunningFor(task.running_since) ?? ""}`.trim()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
||||
{task.at ?? task.schedule}
|
||||
@@ -202,14 +224,15 @@ export default function AutomationTab({ project }: Props) {
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
disabled={busyTaskId === task.id}
|
||||
disabled={busyTaskId === task.id || task.running}
|
||||
onClick={() =>
|
||||
withTask(task.id, "Run now", () =>
|
||||
runScheduledTaskNow(project.id, task.id),
|
||||
)
|
||||
withTask(task.id, "Run now", async () => {
|
||||
await runScheduledTaskNow(project.id, task.id);
|
||||
setJustTriggered(Date.now());
|
||||
})
|
||||
}
|
||||
>
|
||||
Run now
|
||||
{task.running ? "Running…" : "Run now"}
|
||||
</Button>
|
||||
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
|
||||
Edit
|
||||
|
||||
@@ -571,4 +571,57 @@ describe("BrowserTab", () => {
|
||||
// The view is still in the tab, where it was.
|
||||
expect(screen.getByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pins the pane's sandbox: same-origin kept, top-navigation never granted", async () => {
|
||||
await renderLive();
|
||||
const frame = await screen.findByTitle("Playwright browser view for api-server");
|
||||
const tokens = new Set(
|
||||
(frame.getAttribute("sandbox") ?? "").split(/\s+/).filter(Boolean),
|
||||
);
|
||||
|
||||
// What is framed here is served by a process *inside* the container, which
|
||||
// is the untrusted side of this app. A top-navigation grant would let that
|
||||
// page set `top.location` and steer the whole Triple-C app window away from
|
||||
// itself — a sandbox escape from the app's point of view — and
|
||||
// `allow-popups-to-escape-sandbox` is the same hole one step removed: it
|
||||
// hands a popup an entirely unsandboxed context. Checked token by token so
|
||||
// the failure names the one that was added.
|
||||
for (const forbidden of [
|
||||
"allow-top-navigation",
|
||||
"allow-top-navigation-by-user-activation",
|
||||
"allow-top-navigation-to-custom-protocols",
|
||||
"allow-popups-to-escape-sandbox",
|
||||
]) {
|
||||
expect(
|
||||
tokens.has(forbidden),
|
||||
`FORBIDDEN iframe sandbox token "${forbidden}" on the browser view pane. ` +
|
||||
"A page served from inside the container could then navigate the whole " +
|
||||
"Triple-C app window away from itself (or run a popup unsandboxed) — a " +
|
||||
"sandbox escape. Remove it from the iframe in BrowserTab.tsx.",
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// `allow-same-origin` must stay. The browser_view proxy's token gate
|
||||
// recognises the pane's own sub-resource requests by their `Origin`/
|
||||
// `Referer` header; dropping this token gives the frame an opaque origin,
|
||||
// which sends `null`, so the proxy refuses those requests and the pane
|
||||
// renders blank.
|
||||
expect(
|
||||
tokens.has("allow-same-origin"),
|
||||
'REQUIRED iframe sandbox token "allow-same-origin" is missing from the ' +
|
||||
"browser view pane. Without it the frame has an opaque origin and sends " +
|
||||
"`Origin: null`, which the browser_view proxy's token gate refuses — the " +
|
||||
"pane goes blank.",
|
||||
).toBe(true);
|
||||
|
||||
// And the exact set, so any *other* new grant is a deliberate edit here too.
|
||||
expect([...tokens].sort()).toEqual([
|
||||
"allow-downloads",
|
||||
"allow-forms",
|
||||
"allow-modals",
|
||||
"allow-popups",
|
||||
"allow-same-origin",
|
||||
"allow-scripts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -377,6 +377,15 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{progress && (
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)] min-w-0"
|
||||
aria-live="polite"
|
||||
>
|
||||
<StatusIndicator tone="busy" label="" />
|
||||
<span className="truncate">{progress}</span>
|
||||
</span>
|
||||
)}
|
||||
{live && poppedOut === true && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
Keep on top
|
||||
@@ -448,6 +457,35 @@ export default function BrowserTab({ project, active }: Props) {
|
||||
// host-side gate checks before anything reaches the container.
|
||||
src={status.url ?? undefined}
|
||||
title={`Playwright browser view for ${project.name}`}
|
||||
// What is framed here is served by a process inside the container,
|
||||
// which is the untrusted side of this app. Unsandboxed, it could
|
||||
// simply set `top.location` and navigate the *app's* webview
|
||||
// somewhere of its choosing — the frame is cross-origin, so it cannot
|
||||
// read the app, but steering the whole window is not something a
|
||||
// viewer pane should be able to do.
|
||||
//
|
||||
// The allowances are what the Playwright dashboard actually needs and
|
||||
// no more:
|
||||
// allow-scripts — it is an application, not a document.
|
||||
// allow-same-origin — it must reach its own WebSocket and assets,
|
||||
// and the host-side gate recognises the pane's
|
||||
// own sub-resource requests by their
|
||||
// `Origin`/`Referer`; an opaque origin would
|
||||
// send `null` and be refused. This does not
|
||||
// grant access to *this* app: 127.0.0.1:4782x
|
||||
// is a different origin from the app's.
|
||||
// allow-forms/-modals/-downloads/-popups — dashboard UI affordances
|
||||
// (trace download, confirm dialogs, opening a
|
||||
// page in a new window).
|
||||
//
|
||||
// Deliberately absent, and the point of the attribute:
|
||||
// `allow-top-navigation`, `allow-top-navigation-by-user-activation`
|
||||
// and `allow-popups-to-escape-sandbox`. Do not add them.
|
||||
//
|
||||
// No `referrerPolicy` either: the gate in `browser_view/proxy.rs`
|
||||
// reads the token out of a same-origin `Referer`, so stripping it
|
||||
// would break the pane.
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-downloads allow-popups"
|
||||
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
|
||||
/>
|
||||
) : live ? (
|
||||
|
||||
@@ -127,6 +127,46 @@ describe("ContainerMigrationBanner", () => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("speaks up when an unlabelled container could not be probed at all", () => {
|
||||
// The probe is the only signal a container with no lineage label has. If
|
||||
// it fails and the banner stays silent, that is indistinguishable from
|
||||
// "up to date" — the exact reading that let an out-of-date project go
|
||||
// unnoticed indefinitely.
|
||||
renderBanner(
|
||||
migration({
|
||||
staleness: {
|
||||
...FRESH,
|
||||
known: false,
|
||||
stale: false,
|
||||
probe_error: "output exceeded the inspection limit",
|
||||
},
|
||||
probeSettled: false,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Container base could not be checked/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/output exceeded the inspection limit/i),
|
||||
).toBeInTheDocument();
|
||||
// And it must not pose as a finding about the container itself.
|
||||
expect(
|
||||
screen.queryByText(/Container is missing things/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays quiet when a labelled container's probe fails but its lineage is current", () => {
|
||||
// `known` means the version comparison already answered the question, so
|
||||
// a failed probe is not grounds to raise anything.
|
||||
const { container } = renderBanner(
|
||||
migration({
|
||||
staleness: { ...FRESH, probe_error: "could not exec in the container" },
|
||||
probeSettled: false,
|
||||
}),
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("disables the action and explains why while the container is running", () => {
|
||||
renderBanner(migration({ staleness: STALE }), false);
|
||||
expect(
|
||||
|
||||
@@ -131,7 +131,15 @@ export default function ContainerMigrationBanner({
|
||||
const probeFoundGaps =
|
||||
!staleness.known &&
|
||||
(staleness.missing_features.length > 0 || staleness.missing_paths.length > 0);
|
||||
if (!staleness.stale && !probeFoundGaps) return null;
|
||||
|
||||
// The probe is the *only* signal a container with no lineage label has, so
|
||||
// when it fails there is nothing left to be quiet about. Staying silent here
|
||||
// is indistinguishable from "everything is fine" — and it is the likeliest
|
||||
// outcome for the oldest, largest projects, whose manifests are the ones apt
|
||||
// to exceed the inspection limit. Say that the check did not run instead.
|
||||
const probeUnavailable = !staleness.known && !!staleness.probe_error;
|
||||
|
||||
if (!staleness.stale && !probeFoundGaps && !probeUnavailable) return null;
|
||||
|
||||
const snapshot = formatSnapshotDate(staleness.snapshot_created_at);
|
||||
const features = joinFeatures(staleness.missing_features);
|
||||
@@ -139,16 +147,25 @@ export default function ContainerMigrationBanner({
|
||||
return (
|
||||
<section
|
||||
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
|
||||
aria-label="Container base is out of date"
|
||||
aria-label={
|
||||
probeUnavailable
|
||||
? "Container base could not be checked"
|
||||
: "Container base is out of date"
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<StatusIndicator
|
||||
tone="error"
|
||||
// A check that could not run is not a finding: it gets the
|
||||
// "unresolved" tone rather than the one that says something is
|
||||
// wrong with the container.
|
||||
tone={probeUnavailable ? "unknown" : "error"}
|
||||
label={
|
||||
staleness.known
|
||||
? "Container base is out of date"
|
||||
: "Container is missing things the current base ships"
|
||||
: probeUnavailable
|
||||
? "Container base could not be checked"
|
||||
: "Container is missing things the current base ships"
|
||||
}
|
||||
className="text-[13px] font-semibold"
|
||||
/>
|
||||
@@ -158,7 +175,9 @@ export default function ContainerMigrationBanner({
|
||||
? snapshot
|
||||
? `Running on a saved image from ${snapshot}.`
|
||||
: "Running on a saved image older than the current base."
|
||||
: "This container predates base-image tracking, so it was probed directly."}
|
||||
: probeUnavailable
|
||||
? "This container predates base-image tracking, so probing it is the only way to tell whether it is behind — and that did not complete."
|
||||
: "This container predates base-image tracking, so it was probed directly."}
|
||||
</p>
|
||||
|
||||
{staleness.missing_features.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FileEntry } from "../../../lib/types";
|
||||
import { readContainerFile } from "../../../lib/tauri-commands";
|
||||
import Button from "../../ui/Button";
|
||||
import Modal from "../../ui/Modal";
|
||||
import { formatBytes } from "./format";
|
||||
import {
|
||||
decodeBase64,
|
||||
imageMimeFor,
|
||||
looksBinary,
|
||||
previewKind,
|
||||
previewLimit,
|
||||
} from "./filePreview";
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
entry: FileEntry;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Preview =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
/** Too big to render whole — said so rather than shown as a half-file. */
|
||||
| { kind: "too-large" }
|
||||
| { kind: "text"; text: string; truncated: boolean; shownBytes: number; trueSize: number }
|
||||
| { kind: "image"; url: string }
|
||||
| { kind: "unsupported" };
|
||||
|
||||
/**
|
||||
* Read-only preview of one container file.
|
||||
*
|
||||
* Images are rendered from a `blob:` URL rather than a `data:` one — the object
|
||||
* URL is revocable (so the bytes are released the moment the modal closes) and
|
||||
* keeps a multi-megabyte base64 string out of the DOM. `blob:` is in the app's
|
||||
* `img-src` for exactly this; the asset protocol deliberately is not enabled.
|
||||
*/
|
||||
export default function FileViewerModal({ projectId, entry, onClose }: Props) {
|
||||
const [preview, setPreview] = useState<Preview>({ kind: "loading" });
|
||||
|
||||
/**
|
||||
* The object URL currently on screen.
|
||||
*
|
||||
* This used to be an effect-local variable revoked from the effect's own
|
||||
* cleanup, which runs *before* the replacement effect body — so switching
|
||||
* entries (or any re-run of the effect for the same entry) released the URL
|
||||
* the `<img>` was still pointing at, and a blank image was the result until
|
||||
* the new read landed. If the new read failed, it stayed blank. So the
|
||||
* hand-over is explicit instead: a URL is revoked only once its replacement
|
||||
* exists, and unmount is what releases the last one.
|
||||
*/
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
|
||||
/** Release the previous URL now that something else is on screen. */
|
||||
const replaceObjectUrl = (next: string | null) => {
|
||||
const previous = objectUrlRef.current;
|
||||
objectUrlRef.current = next;
|
||||
if (previous && previous !== next) URL.revokeObjectURL(previous);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const wantImage = previewKind(entry.name) === "image";
|
||||
const result = await readContainerFile(projectId, entry.path, previewLimit(entry.name));
|
||||
if (cancelled) return;
|
||||
|
||||
const bytes = decodeBase64(result.contents_base64);
|
||||
|
||||
if (wantImage) {
|
||||
// A truncated image is not a smaller image, it is a broken one.
|
||||
if (result.truncated) {
|
||||
setPreview({ kind: "too-large" });
|
||||
replaceObjectUrl(null);
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([bytes], { type: imageMimeFor(entry.name) ?? "image/png" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
// The replacement is in hand, so the previous one can go.
|
||||
setPreview({ kind: "image", url });
|
||||
replaceObjectUrl(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (looksBinary(bytes)) {
|
||||
setPreview({ kind: "unsupported" });
|
||||
replaceObjectUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPreview({
|
||||
kind: "text",
|
||||
text: new TextDecoder().decode(bytes),
|
||||
truncated: result.truncated,
|
||||
shownBytes: bytes.length,
|
||||
trueSize: result.size,
|
||||
});
|
||||
replaceObjectUrl(null);
|
||||
} catch (e) {
|
||||
if (!cancelled) setPreview({ kind: "error", message: String(e) });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, entry.name, entry.path]);
|
||||
|
||||
// The bytes are released when the dialog goes, which is the whole reason the
|
||||
// preview is a `blob:` URL rather than a `data:` one.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<Button size="md" variant="primary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={entry.name}
|
||||
description={`${entry.path} · ${formatBytes(entry.size)}`}
|
||||
onClose={onClose}
|
||||
footer={footer}
|
||||
widthClassName="w-[52rem]"
|
||||
>
|
||||
{preview.kind === "loading" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">Loading…</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "error" && (
|
||||
<p role="alert" className="text-[13px] text-[var(--error)]">
|
||||
{preview.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "too-large" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
This file is {formatBytes(entry.size)} — too large to preview in the app. Use
|
||||
“Save to host…” on its row to open it in a program that can, or read it from a
|
||||
terminal in the container.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "unsupported" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
There is no preview for this file type. Use “Save to host…” on its row to open it
|
||||
in a program that can, or read it from a terminal in the container.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview.kind === "text" && (
|
||||
<>
|
||||
{preview.truncated && (
|
||||
<p className="mb-2 text-xs text-[var(--warning)]">
|
||||
Showing the first {formatBytes(preview.shownBytes)} of {formatBytes(preview.trueSize)}.
|
||||
</p>
|
||||
)}
|
||||
{/* Focusable, and its own scroll container, because a megabyte of
|
||||
text in an unfocusable `<pre>` is reachable by mouse wheel and by
|
||||
nothing else — no PageDown, no arrows, no keyboard at all. A
|
||||
scrollable region needs an accessible name to be worth landing
|
||||
on, hence the role and label. No `focus:outline-none`: the global
|
||||
`:focus-visible` ring is what says where the caret went. */}
|
||||
<pre
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={`${entry.name} contents`}
|
||||
className="max-h-[60vh] overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-[var(--text-primary)]"
|
||||
>
|
||||
{preview.text}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
|
||||
{preview.kind === "image" && (
|
||||
<img src={preview.url} alt={entry.name} className="max-w-full mx-auto" />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
|
||||
import FilesTab from "./FilesTab";
|
||||
import type { FileContents, FileEntry, Project } from "../../../lib/types";
|
||||
|
||||
const listContainerFiles = vi.fn();
|
||||
const renameContainerPath = vi.fn(async () => "");
|
||||
const createContainerDirectory = vi.fn(async () => "");
|
||||
const readContainerFile = vi.fn();
|
||||
const uploadFilesToContainer = vi.fn();
|
||||
const downloadContainerFile = vi.fn();
|
||||
|
||||
vi.mock("../../../lib/tauri-commands", () => ({
|
||||
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
|
||||
renameContainerPath: (p: string, f: string, t: string) => renameContainerPath(p, f, t),
|
||||
createContainerDirectory: (p: string, parent: string, n: string) =>
|
||||
createContainerDirectory(p, parent, n),
|
||||
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
|
||||
uploadFilesToContainer: (p: string, dir: string) => uploadFilesToContainer(p, dir),
|
||||
downloadContainerFile: (p: string, path: string) => downloadContainerFile(p, path),
|
||||
}));
|
||||
|
||||
/** Transient failures land in `ToastHost`, not in an inline string. */
|
||||
const pushToast = vi.fn();
|
||||
vi.mock("../../../store/appState", () => ({
|
||||
useAppState: { getState: () => ({ pushToast }) },
|
||||
}));
|
||||
|
||||
const toastText = () =>
|
||||
pushToast.mock.calls
|
||||
.map(([toast]) => `${toast.kind}: ${toast.message} ${toast.detail ?? ""}`)
|
||||
.join("\n");
|
||||
|
||||
const project = { id: "p1", name: "api", status: "running" } as unknown as Project;
|
||||
|
||||
const entry = (name: string, extra: Partial<FileEntry> = {}): FileEntry => ({
|
||||
name,
|
||||
path: `/workspace/${name}`,
|
||||
is_directory: false,
|
||||
is_symlink: false,
|
||||
size: 12,
|
||||
modified: "2024-05-01 10:00:00",
|
||||
permissions: "644",
|
||||
...extra,
|
||||
});
|
||||
|
||||
const contents = (text: string, extra: Partial<FileContents> = {}): FileContents => ({
|
||||
contents_base64: btoa(text),
|
||||
truncated: false,
|
||||
size: text.length,
|
||||
...extra,
|
||||
});
|
||||
|
||||
async function renderTab() {
|
||||
const view = render(<FilesTab project={project} />);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Every row that is part of the grid's roving tabindex, in order. */
|
||||
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
|
||||
/** The rows that are actually tab stops. There must never be more than one. */
|
||||
const tabStops = () => gridRows().filter((r) => r.getAttribute("tabindex") === "0");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("src", { is_directory: true, path: "/workspace/src" }),
|
||||
entry("notes.txt"),
|
||||
]);
|
||||
// Not implemented in jsdom; the image preview needs both halves.
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock-url");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
describe("FilesTab listing", () => {
|
||||
it("lists /workspace once the container is running", async () => {
|
||||
await renderTab();
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace");
|
||||
expect(screen.getByText("notes.txt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says nothing about files while the container is stopped", async () => {
|
||||
render(<FilesTab project={{ ...project, status: "stopped" } as Project} />);
|
||||
expect(screen.getByText(/Start the container/)).toBeTruthy();
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels a symlink, which no longer masquerades as a plain file", async () => {
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("app", { is_directory: true, is_symlink: true }),
|
||||
]);
|
||||
await renderTab();
|
||||
expect(screen.getByTitle("Symbolic link")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab open semantics", () => {
|
||||
it("selects on a single click without navigating", async () => {
|
||||
await renderTab();
|
||||
listContainerFiles.mockClear();
|
||||
fireEvent.click(screen.getByText("src"));
|
||||
expect(listContainerFiles).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("src").closest("tr")?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("navigates a directory on double click", async () => {
|
||||
await renderTab();
|
||||
listContainerFiles.mockClear();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("src"));
|
||||
});
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/src");
|
||||
});
|
||||
|
||||
it("walks the rows with the arrow keys, which is what makes the grid role honest", async () => {
|
||||
await renderTab();
|
||||
const first = screen.getByText("src").closest("tr")!;
|
||||
first.focus();
|
||||
fireEvent.keyDown(first, { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(screen.getByText("notes.txt").closest("tr"));
|
||||
fireEvent.keyDown(document.activeElement!, { key: "ArrowUp" });
|
||||
expect(document.activeElement).toBe(first);
|
||||
});
|
||||
|
||||
it("opens a directory from the keyboard with Enter", async () => {
|
||||
await renderTab();
|
||||
listContainerFiles.mockClear();
|
||||
const row = screen.getByText("src").closest("tr")!;
|
||||
// Not a tab stop — `..` holds the grid's single one until the arrows move
|
||||
// it — but still focusable and still openable from the keyboard.
|
||||
expect(row.getAttribute("tabindex")).toBe("-1");
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(row, { key: "Enter" });
|
||||
});
|
||||
expect(listContainerFiles).toHaveBeenCalledWith("p1", "/workspace/src");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab viewer", () => {
|
||||
it("shows a text file's contents in a dialog", async () => {
|
||||
readContainerFile.mockResolvedValue(contents("hello from the container"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("notes.txt"));
|
||||
});
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(await screen.findByText("hello from the container")).toBeTruthy();
|
||||
// A text file gets the text-sized budget, not the image one.
|
||||
expect(readContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", 1024 * 1024);
|
||||
});
|
||||
|
||||
it("renders an image through a revocable blob URL, not a data URI", async () => {
|
||||
// `data:` is absent from the app's img-src on purpose; `blob:` is what was
|
||||
// added, and the object URL has to be released when the dialog closes.
|
||||
listContainerFiles.mockResolvedValue([entry("logo.png", { size: 4 })]);
|
||||
readContainerFile.mockResolvedValue(contents("\x89PNG"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("logo.png"));
|
||||
});
|
||||
const img = (await screen.findByAltText("logo.png")) as HTMLImageElement;
|
||||
expect(img.getAttribute("src")).toBe("blob:mock-url");
|
||||
expect(readContainerFile).toHaveBeenCalledWith("p1", "/workspace/logo.png", 5 * 1024 * 1024);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url"));
|
||||
});
|
||||
|
||||
it("refuses an oversized image rather than drawing a half-decoded one", async () => {
|
||||
listContainerFiles.mockResolvedValue([entry("huge.png", { size: 40 * 1024 * 1024 })]);
|
||||
readContainerFile.mockResolvedValue(contents("\x89PNG", { truncated: true, size: 40 * 1024 * 1024 }));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("huge.png"));
|
||||
});
|
||||
expect(await screen.findByText(/too large to preview/)).toBeTruthy();
|
||||
expect(screen.queryByAltText("huge.png")).toBeNull();
|
||||
// A refusal has to name the way out, and the way out is now the button on
|
||||
// the row rather than the `cat`-it-in-a-terminal workaround that existed
|
||||
// because the button did not.
|
||||
// Scoped to the modal: every file row also carries a "Save to host…"
|
||||
// button now, so an unscoped query matches the grid behind the overlay and
|
||||
// would pass with the refusal saying nothing at all.
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByText(/Save to host/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says so in words when only a prefix of a big text file came back", async () => {
|
||||
readContainerFile.mockResolvedValue(
|
||||
contents("first megabyte", { truncated: true, size: 5 * 1024 * 1024 }),
|
||||
);
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("notes.txt"));
|
||||
});
|
||||
expect(await screen.findByText(/Showing the first/)).toBeTruthy();
|
||||
expect(screen.getByText("first megabyte")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says there is no preview, and where to open the file instead", async () => {
|
||||
listContainerFiles.mockResolvedValue([entry("blob.bin")]);
|
||||
readContainerFile.mockResolvedValue(contents("a\x00b"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("blob.bin"));
|
||||
});
|
||||
expect(await screen.findByText(/no preview for this file type/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab rename", () => {
|
||||
it("commits an inline rename on Enter and re-lists", async () => {
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename — notes.txt" }));
|
||||
const input = screen.getByLabelText("New name for notes.txt") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "renamed.txt" } });
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
expect(renameContainerPath).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "renamed.txt");
|
||||
});
|
||||
|
||||
it("abandons the rename on Escape", async () => {
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename — notes.txt" }));
|
||||
const input = screen.getByLabelText("New name for notes.txt");
|
||||
fireEvent.change(input, { target: { value: "nope.txt" } });
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
});
|
||||
expect(renameContainerPath).not.toHaveBeenCalled();
|
||||
expect(screen.queryByLabelText("New name for notes.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts a rename from the keyboard with F2", async () => {
|
||||
await renderTab();
|
||||
const row = screen.getByText("notes.txt").closest("tr")!;
|
||||
fireEvent.keyDown(row, { key: "F2" });
|
||||
expect(screen.getByLabelText("New name for notes.txt")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reports a refused rename where it can be seen, not three hundred rows down", async () => {
|
||||
// The inline `error` div is the first child of the *scrolling* list, so
|
||||
// deep in a directory this used to be a rename box that stayed open and
|
||||
// said nothing. `ToastHost` is fixed, above the modal layer, and persists.
|
||||
renameContainerPath.mockRejectedValue("mv: cannot move '/etc/hosts': Permission denied");
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename — notes.txt" }));
|
||||
const input = screen.getByLabelText("New name for notes.txt");
|
||||
fireEvent.change(input, { target: { value: "x" } });
|
||||
await act(async () => {
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
expect(toastText()).toContain("Permission denied");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
// The editor stays open, because the rename did not happen.
|
||||
expect(screen.getByLabelText("New name for notes.txt")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab new folder", () => {
|
||||
it("creates a folder under the current directory", async () => {
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
|
||||
const input = screen.getByLabelText("New folder name");
|
||||
fireEvent.change(input, { target: { value: "assets" } });
|
||||
await act(async () => {
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
expect(createContainerDirectory).toHaveBeenCalledWith("p1", "/workspace", "assets");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab grid focus", () => {
|
||||
it("gives the grid exactly one tab stop and moves it with the arrows", async () => {
|
||||
// Every row used to be `tabIndex={0}`: a 400-entry directory was ~1200 tab
|
||||
// stops and Tab could not get out of the list.
|
||||
await renderTab();
|
||||
expect(gridRows()).toHaveLength(3); // .. , src, notes.txt
|
||||
expect(tabStops()).toHaveLength(1);
|
||||
expect(tabStops()[0].getAttribute("data-file-row")).toBe("..");
|
||||
|
||||
fireEvent.keyDown(tabStops()[0], { key: "ArrowDown" });
|
||||
expect(tabStops()).toHaveLength(1);
|
||||
expect(tabStops()[0].getAttribute("data-file-row")).toBe("src");
|
||||
expect(document.activeElement).toBe(tabStops()[0]);
|
||||
|
||||
fireEvent.keyDown(tabStops()[0], { key: "End" });
|
||||
expect(tabStops()[0].getAttribute("data-file-row")).toBe("notes.txt");
|
||||
fireEvent.keyDown(tabStops()[0], { key: "Home" });
|
||||
expect(tabStops()[0].getAttribute("data-file-row")).toBe("..");
|
||||
});
|
||||
|
||||
it("keeps focus inside the grid after Enter opens a directory", async () => {
|
||||
// Rows are keyed by name, so navigating unmounts the focused `<tr>` — and
|
||||
// nothing used to re-focus, which ejected the user to `<body>`.
|
||||
await renderTab();
|
||||
const row = screen.getByText("src").closest("tr")!;
|
||||
row.focus();
|
||||
listContainerFiles.mockResolvedValueOnce([
|
||||
entry("index.ts", { path: "/workspace/src/index.ts" }),
|
||||
]);
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(row, { key: "Enter" });
|
||||
});
|
||||
|
||||
expect(screen.getByText("index.ts")).toBeTruthy();
|
||||
expect(document.activeElement).not.toBe(document.body);
|
||||
expect((document.activeElement as HTMLElement).closest("tr[data-file-row]")).toBeTruthy();
|
||||
expect(tabStops()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("puts focus back on the row after a rename is abandoned", async () => {
|
||||
await renderTab();
|
||||
const row = screen.getByText("notes.txt").closest("tr")!;
|
||||
fireEvent.keyDown(row, { key: "F2" });
|
||||
const input = screen.getByLabelText("New name for notes.txt");
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
});
|
||||
expect(document.activeElement).toBe(
|
||||
gridRows().find((r) => r.getAttribute("data-file-row") === "notes.txt"),
|
||||
);
|
||||
});
|
||||
|
||||
it("follows a committed rename to the row's new name", async () => {
|
||||
// Explicit, because `clearAllMocks` clears calls but not implementations,
|
||||
// and an earlier test in this file leaves this one rejecting.
|
||||
renameContainerPath.mockResolvedValue("/workspace/renamed.txt");
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rename — notes.txt" }));
|
||||
const input = screen.getByLabelText("New name for notes.txt");
|
||||
fireEvent.change(input, { target: { value: "renamed.txt" } });
|
||||
listContainerFiles.mockResolvedValueOnce([
|
||||
entry("src", { is_directory: true, path: "/workspace/src" }),
|
||||
entry("renamed.txt"),
|
||||
]);
|
||||
await act(async () => {
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
expect(document.activeElement).toBe(
|
||||
gridRows().find((r) => r.getAttribute("data-file-row") === "renamed.txt"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FilesTab grid semantics", () => {
|
||||
it("names its columns", async () => {
|
||||
await renderTab();
|
||||
for (const name of ["Name", "Size", "Modified", "Actions"]) {
|
||||
expect(screen.getByRole("columnheader", { name })).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("says folder or file in words, not in hue and a hidden emoji", async () => {
|
||||
await renderTab();
|
||||
const dir = screen.getByText("src").closest("tr")!;
|
||||
const plain = screen.getByText("notes.txt").closest("tr")!;
|
||||
expect(dir.textContent).toContain("Folder");
|
||||
expect(plain.textContent).toContain("File");
|
||||
});
|
||||
|
||||
it("keeps the visible label inside the accessible name (WCAG 2.5.3)", async () => {
|
||||
await renderTab();
|
||||
const rename = screen.getByRole("button", { name: "Rename — notes.txt" });
|
||||
expect(rename.textContent).toBe("Rename");
|
||||
expect(rename.getAttribute("aria-label")).toContain(rename.textContent!);
|
||||
});
|
||||
|
||||
it("mounts the live region empty, then fills it", async () => {
|
||||
// A `role="status"` node inserted already carrying its text is frequently
|
||||
// not announced at all, which is how every one of these went by in silence.
|
||||
createContainerDirectory.mockResolvedValue("/workspace/new");
|
||||
await renderTab();
|
||||
const live = screen.getByRole("status");
|
||||
expect(live.textContent).toBe("");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "New folder" }));
|
||||
});
|
||||
const input = screen.getByLabelText("New folder name");
|
||||
fireEvent.change(input, { target: { value: "new" } });
|
||||
await act(async () => {
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
// Same node throughout — it is never unmounted.
|
||||
expect(screen.getByRole("status")).toBe(live);
|
||||
expect(live.textContent).toContain('Created "new"');
|
||||
});
|
||||
|
||||
it("keeps a listing failure inline, where the rows it explains are missing", async () => {
|
||||
// The one failure that does *not* go to the toast host: it is on screen,
|
||||
// in context, and there is nothing for it to scroll behind.
|
||||
listContainerFiles.mockRejectedValue("Permission denied");
|
||||
await renderTab();
|
||||
expect(screen.getByRole("alert").textContent).toContain("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The pane's two host-transfer affordances.
|
||||
*
|
||||
* They are asserted at the *button* level and not only in the hook, because
|
||||
* this is the half that was actually lost: the commands behind them had been
|
||||
* deleted, but so had the controls, and a working command nobody can reach is
|
||||
* the same regression. Neither button names a host path — Rust opens the
|
||||
* dialog — so what a click is required to prove is that the container-side
|
||||
* argument reaching the backend is the one the user is looking at.
|
||||
*/
|
||||
describe("FilesTab host transfers", () => {
|
||||
beforeEach(() => {
|
||||
uploadFilesToContainer.mockResolvedValue({ uploaded: [], failures: [] });
|
||||
downloadContainerFile.mockResolvedValue(4);
|
||||
});
|
||||
|
||||
it("uploads into the directory currently on screen", async () => {
|
||||
listContainerFiles.mockResolvedValue([entry("src", { is_directory: true })]);
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(screen.getByText("src"));
|
||||
});
|
||||
uploadFilesToContainer.mockResolvedValueOnce({
|
||||
uploaded: ["/workspace/src/a.txt"],
|
||||
failures: [],
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upload…" }));
|
||||
});
|
||||
expect(uploadFilesToContainer).toHaveBeenCalledWith("p1", "/workspace/src");
|
||||
});
|
||||
|
||||
it("offers Save to host on a file and not on a folder", async () => {
|
||||
listContainerFiles.mockResolvedValue([
|
||||
entry("notes.txt"),
|
||||
entry("src", { is_directory: true }),
|
||||
]);
|
||||
await renderTab();
|
||||
// The accessible name carries the row, per WCAG 2.5.3 — and it is how a
|
||||
// per-row action is told apart from every other row's copy of it.
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save to host — notes.txt" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Save to host — src" }),
|
||||
).toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save to host — notes.txt" }));
|
||||
});
|
||||
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
|
||||
});
|
||||
|
||||
it("does not open the file viewer when Save to host is double-clicked", async () => {
|
||||
// Opening a file is a *double*-click on the row, and a double-click on a
|
||||
// button inside that row still bubbles — `onClick`'s `stopPropagation` does
|
||||
// nothing about it. So an impatient double-click on Save used to save the
|
||||
// file and drop the viewer modal over the pane at the same time, on top of
|
||||
// the save dialog the backend had just opened.
|
||||
listContainerFiles.mockResolvedValue([entry("notes.txt")]);
|
||||
readContainerFile.mockResolvedValue(contents("hello"));
|
||||
await renderTab();
|
||||
await act(async () => {
|
||||
fireEvent.doubleClick(
|
||||
screen.getByRole("button", { name: "Save to host — notes.txt" }),
|
||||
);
|
||||
});
|
||||
expect(readContainerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,252 @@
|
||||
import { useEffect } from "react";
|
||||
import type { Project } from "../../../lib/types";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FileEntry, Project } from "../../../lib/types";
|
||||
import { useFileManager } from "../../../hooks/useFileManager";
|
||||
import Button from "../../ui/Button";
|
||||
import FileViewerModal from "./FileViewerModal";
|
||||
import { formatBytes } from "./format";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
/** The old 42rem FileManager popup, now a main-area section. */
|
||||
/** Key of the synthetic "go up one level" row. No listing ever contains `..`. */
|
||||
const PARENT_ROW = "..";
|
||||
|
||||
/**
|
||||
* The project's file browser.
|
||||
*
|
||||
* It lists, opens, renames and creates folders inside the container, and it
|
||||
* copies single files across the boundary: "Upload…" in the toolbar, and a
|
||||
* per-row "Save to host…".
|
||||
*
|
||||
* **Neither of those names a host path, and this file must never learn how
|
||||
* to.** Four successive audits found that host paths crossing IPC were where
|
||||
* the criticals lived — a frontend `open()`/`save()` handing Rust a string is
|
||||
* exactly the shape that failed — so the picker is opened by the *backend*
|
||||
* (`pick_files_to_upload` / `pick_save_path` in `commands/file_commands.rs`).
|
||||
* What this file *sends* is a project id and a container path; the host side of
|
||||
* the transfer is chosen by a person in an OS dialog. That is why
|
||||
* `uploadFiles()` takes no argument and `saveToHost()` takes only the entry.
|
||||
* (A failed transfer does report a host path back, in the text of its error —
|
||||
* the inbound direction is the one that is closed, not both.)
|
||||
*
|
||||
* Drag-and-drop is deliberately still absent, in both directions. A file also
|
||||
* gets into a container by being dropped onto the Terminal tab, and a whole
|
||||
* tree comes back out through "Back up container" in the project's ⋯ menu —
|
||||
* which is still the right answer for a directory, since "Save to host…" is one
|
||||
* file at a time and is not offered on folders.
|
||||
*
|
||||
* Interaction model, chosen to match every desktop file manager rather than
|
||||
* the old half-and-half: **single click selects, double click opens**. That
|
||||
* moved directory navigation onto double click too — a single click used to
|
||||
* navigate, which made it impossible to select a directory in order to rename
|
||||
* it. Keyboard mirrors it exactly: Enter opens, F2 renames.
|
||||
*
|
||||
* ## Focus, and why it is a roving tabindex
|
||||
*
|
||||
* Every row used to be `tabIndex={0}`, which made a 400-entry directory about
|
||||
* twelve hundred tab stops — Tab could not get *out* of the list, let alone
|
||||
* past it — and rows are keyed by name, so navigating unmounted the focused
|
||||
* `<tr>` and dropped focus to `<body>`: Enter on a directory ejected you from
|
||||
* the grid, arrows dead, Tab restarting from the top of the document. So
|
||||
* exactly one row carries `tabIndex={0}` (the *active* row), the arrows move
|
||||
* it, and a single effect below is responsible for putting focus back on a
|
||||
* sensible row after anything that re-renders the list.
|
||||
*/
|
||||
export default function FilesTab({ project }: Props) {
|
||||
const {
|
||||
currentPath,
|
||||
entries,
|
||||
loading,
|
||||
error,
|
||||
completed,
|
||||
navigate,
|
||||
goUp,
|
||||
refresh,
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
renameEntry,
|
||||
createFolder,
|
||||
uploadFiles,
|
||||
saveToHost,
|
||||
uploading,
|
||||
savingPaths,
|
||||
} = useFileManager(project.id);
|
||||
|
||||
const running = project.status === "running";
|
||||
|
||||
/** The row the user has selected, by name — names are unique in a directory. */
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const [creatingFolder, setCreatingFolder] = useState(false);
|
||||
const [folderDraft, setFolderDraft] = useState("");
|
||||
const [viewing, setViewing] = useState<FileEntry | null>(null);
|
||||
/** The row that owns the grid's single tab stop. */
|
||||
const [activeRow, setActiveRow] = useState<string | null>(null);
|
||||
|
||||
const paneRef = useRef<HTMLDivElement>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (running) navigate("/workspace");
|
||||
// Re-list when the container comes up.
|
||||
}, [navigate, running]);
|
||||
|
||||
// Leaving a directory invalidates every in-flight row interaction.
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
setRenaming(null);
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renaming) {
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
}
|
||||
}, [renaming]);
|
||||
|
||||
useEffect(() => {
|
||||
if (creatingFolder) folderInputRef.current?.focus();
|
||||
}, [creatingFolder]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roving tabindex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Every row's key, in visual order. The parent row is a row like any other. */
|
||||
const rowKeys = useMemo(
|
||||
() => [
|
||||
...(currentPath !== "/" ? [PARENT_ROW] : []),
|
||||
...entries.map((entry) => entry.name),
|
||||
],
|
||||
[currentPath, entries],
|
||||
);
|
||||
|
||||
/**
|
||||
* The active row, resolved against what is actually on screen. Keeping the
|
||||
* *intent* in state and resolving it at render time means a rename or a
|
||||
* deletion cannot leave the grid with no tab stop at all.
|
||||
*/
|
||||
const active = activeRow && rowKeys.includes(activeRow) ? activeRow : rowKeys[0];
|
||||
|
||||
const rowElement = useCallback((key: string): HTMLElement | undefined => {
|
||||
// Matched on the dataset rather than a selector, because a file name is
|
||||
// user data and can contain quotes, brackets and backslashes.
|
||||
const rows = paneRef.current?.querySelectorAll<HTMLElement>("tr[data-file-row]") ?? [];
|
||||
return Array.from(rows).find((row) => row.dataset.fileRow === key);
|
||||
}, []);
|
||||
|
||||
const focusRow = useCallback(
|
||||
(key: string) => {
|
||||
setActiveRow(key);
|
||||
rowElement(key)?.focus();
|
||||
},
|
||||
[rowElement],
|
||||
);
|
||||
|
||||
/**
|
||||
* Where focus should land the next time the grid re-renders, if it is loose.
|
||||
* `key` is a preference, not a promise — the row may not exist any more (a
|
||||
* rename that failed, a navigation into a different directory), in which case
|
||||
* the first row takes it.
|
||||
*/
|
||||
const wantFocus = useRef<{ key: string | null } | null>(null);
|
||||
|
||||
/**
|
||||
* The single place that decides where focus goes after the list changes.
|
||||
*
|
||||
* Runs after a navigation (rows are keyed by name, so the focused `<tr>` is
|
||||
* gone), after a rename commits or is abandoned, and after Escape. It never
|
||||
* *steals* focus: if the user has moved on to a button or the breadcrumb it
|
||||
* drops the request instead, so a background re-list cannot yank the caret
|
||||
* out from under them.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (renaming !== null) return; // the rename input owns focus
|
||||
const want = wantFocus.current;
|
||||
if (!want) return;
|
||||
wantFocus.current = null;
|
||||
|
||||
const focused = document.activeElement as HTMLElement | null;
|
||||
const loose =
|
||||
!focused ||
|
||||
focused === document.body ||
|
||||
focused === document.documentElement ||
|
||||
!!focused.closest?.("tr[data-file-row]");
|
||||
if (!loose) return;
|
||||
|
||||
const key = want.key && rowKeys.includes(want.key) ? want.key : rowKeys[0];
|
||||
if (key !== undefined) focusRow(key);
|
||||
}, [rowKeys, renaming, focusRow]);
|
||||
|
||||
/** Arrow / Home / End movement over the rows. */
|
||||
const moveActive = useCallback(
|
||||
(from: string, to: 1 | -1 | "first" | "last") => {
|
||||
if (rowKeys.length === 0) return;
|
||||
const i = rowKeys.indexOf(from);
|
||||
const next =
|
||||
to === "first"
|
||||
? 0
|
||||
: to === "last"
|
||||
? rowKeys.length - 1
|
||||
: Math.min(rowKeys.length - 1, Math.max(0, (i < 0 ? 0 : i) + to));
|
||||
focusRow(rowKeys[next]);
|
||||
},
|
||||
[rowKeys, focusRow],
|
||||
);
|
||||
|
||||
const startRename = useCallback((entry: FileEntry) => {
|
||||
setSelected(entry.name);
|
||||
setActiveRow(entry.name);
|
||||
setRenameDraft(entry.name);
|
||||
setRenaming(entry.name);
|
||||
// Whichever way the rename ends, focus comes back to this row unless the
|
||||
// commit renames it — `commitRename` overwrites the preference below.
|
||||
wantFocus.current = { key: entry.name };
|
||||
}, []);
|
||||
|
||||
const commitRename = useCallback(
|
||||
async (entry: FileEntry) => {
|
||||
const renamedTo = renameDraft.trim();
|
||||
wantFocus.current = { key: renamedTo || entry.name };
|
||||
const done = await renameEntry(entry, renameDraft);
|
||||
if (done) setRenaming(null);
|
||||
},
|
||||
[renameEntry, renameDraft],
|
||||
);
|
||||
|
||||
const commitFolder = useCallback(async () => {
|
||||
const created = folderDraft.trim();
|
||||
const done = await createFolder(folderDraft);
|
||||
if (done) {
|
||||
setCreatingFolder(false);
|
||||
setFolderDraft("");
|
||||
wantFocus.current = { key: created || null };
|
||||
}
|
||||
}, [createFolder, folderDraft]);
|
||||
|
||||
/** Double click / Enter: directories navigate, files open the viewer. */
|
||||
const openEntry = useCallback(
|
||||
(entry: FileEntry) => {
|
||||
if (entry.is_directory) {
|
||||
// The new listing's first row is `..`, which is the sensible landing
|
||||
// place: it is where you go to undo the step you just took.
|
||||
wantFocus.current = { key: null };
|
||||
navigate(entry.path);
|
||||
} else {
|
||||
setViewing(entry);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const openParent = useCallback(() => {
|
||||
// Coming back up, the directory just left is the interesting row.
|
||||
const leaving = currentPath.split("/").filter(Boolean).pop() ?? null;
|
||||
wantFocus.current = { key: leaving };
|
||||
goUp();
|
||||
}, [currentPath, goUp]);
|
||||
|
||||
const breadcrumbs =
|
||||
currentPath === "/"
|
||||
? [{ label: "/", path: "/" }]
|
||||
@@ -55,8 +273,25 @@ export default function FilesTab({ project }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const rowClass = (isSelected: boolean) =>
|
||||
`cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? "bg-[var(--bg-tertiary)]"
|
||||
: "hover:bg-[var(--bg-tertiary)]"
|
||||
}`;
|
||||
|
||||
const headerClass = "px-2 py-1.5 font-medium text-[var(--text-secondary)]";
|
||||
|
||||
/**
|
||||
* The live region's text. One region, always mounted, filled and emptied —
|
||||
* a `role="status"` node that is *inserted* already carrying its text is
|
||||
* frequently not announced at all, which is how every completion notice used
|
||||
* to go by in silence.
|
||||
*/
|
||||
const liveText = completed ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
||||
<nav aria-label="Path" className="flex items-center gap-1">
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
@@ -64,7 +299,10 @@ export default function FilesTab({ project }: Props) {
|
||||
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(crumb.path)}
|
||||
onClick={() => {
|
||||
wantFocus.current = { key: null };
|
||||
navigate(crumb.path);
|
||||
}}
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap font-mono"
|
||||
>
|
||||
{crumb.label}
|
||||
@@ -73,13 +311,38 @@ export default function FilesTab({ project }: Props) {
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex-1" />
|
||||
<Button onClick={uploadFile}>Upload file</Button>
|
||||
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{liveText}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setFolderDraft("");
|
||||
setCreatingFolder(true);
|
||||
}}
|
||||
>
|
||||
New folder
|
||||
</Button>
|
||||
{/* The file picker this opens belongs to Rust, not to the webview — so
|
||||
this file imports no dialog plugin and never composes a host path.
|
||||
`uploadFiles` takes no argument for the same reason. */}
|
||||
<Button
|
||||
onClick={() => void uploadFiles()}
|
||||
disabled={uploading}
|
||||
className="ml-1"
|
||||
>
|
||||
{uploading ? "Uploading…" : "Upload…"}
|
||||
</Button>
|
||||
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{/* The one failure that stays inline: it explains why the grid below is
|
||||
empty, it is in context, and there are no rows for it to scroll
|
||||
behind. Every *transient* failure — rename, new folder — goes to
|
||||
`ToastHost` instead, which is above the file viewer's overlay and
|
||||
does not scroll away. */}
|
||||
{error && (
|
||||
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||
{error}
|
||||
@@ -91,61 +354,218 @@ export default function FilesTab({ project }: Props) {
|
||||
Loading…
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<table role="grid" aria-label="Files" className="w-full text-xs">
|
||||
<thead>
|
||||
<tr role="row">
|
||||
<th role="columnheader" scope="col" className={`${headerClass} px-4 text-left`}>
|
||||
Name
|
||||
</th>
|
||||
<th role="columnheader" scope="col" className={`${headerClass} text-right`}>
|
||||
Size
|
||||
</th>
|
||||
<th role="columnheader" scope="col" className={`${headerClass} text-left`}>
|
||||
Modified
|
||||
</th>
|
||||
<th role="columnheader" scope="col" className={`${headerClass} text-right`}>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPath !== "/" && (
|
||||
<tr
|
||||
onClick={goUp}
|
||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-1.5 text-[var(--text-primary)] font-mono">..</td>
|
||||
<td colSpan={3} />
|
||||
{creatingFolder && (
|
||||
<tr role="row">
|
||||
<td role="gridcell" className="px-4 py-1.5" colSpan={4}>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
value={folderDraft}
|
||||
aria-label="New folder name"
|
||||
placeholder="Folder name"
|
||||
onChange={(e) => setFolderDraft(e.target.value)}
|
||||
onBlur={commitFolder}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") {
|
||||
setCreatingFolder(false);
|
||||
setFolderDraft("");
|
||||
}
|
||||
}}
|
||||
className="w-64 px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs font-mono text-[var(--text-primary)]"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
{currentPath !== "/" && (
|
||||
<tr
|
||||
key={entry.name}
|
||||
onClick={() => entry.is_directory && navigate(entry.path)}
|
||||
className={`${
|
||||
entry.is_directory ? "cursor-pointer" : ""
|
||||
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
||||
role="row"
|
||||
data-file-row={PARENT_ROW}
|
||||
tabIndex={active === PARENT_ROW ? 0 : -1}
|
||||
aria-label="Parent directory"
|
||||
onClick={() => setActiveRow(PARENT_ROW)}
|
||||
onDoubleClick={openParent}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
openParent();
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
moveActive(PARENT_ROW, e.key === "ArrowDown" ? 1 : -1);
|
||||
} else if (e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
moveActive(PARENT_ROW, e.key === "Home" ? "first" : "last");
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
<td className="px-4 py-1.5">
|
||||
<span
|
||||
className={`font-mono ${
|
||||
entry.is_directory
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{entry.is_directory ? "📁 " : ""}
|
||||
{entry.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap tabular-nums">
|
||||
{!entry.is_directory && formatBytes(entry.size)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{entry.modified}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{!entry.is_directory && (
|
||||
<Button
|
||||
aria-label={`Download ${entry.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry);
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
<td role="gridcell" className="px-4 py-1.5 text-[var(--text-primary)] font-mono">
|
||||
<span className="sr-only">Folder, </span>
|
||||
..
|
||||
</td>
|
||||
<td role="gridcell" colSpan={3} />
|
||||
</tr>
|
||||
))}
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const isSelected = selected === entry.name;
|
||||
const isRenaming = renaming === entry.name;
|
||||
return (
|
||||
<tr
|
||||
key={entry.name}
|
||||
role="row"
|
||||
data-file-row={entry.name}
|
||||
tabIndex={active === entry.name ? 0 : -1}
|
||||
aria-selected={isSelected}
|
||||
onClick={() => {
|
||||
setSelected(entry.name);
|
||||
setActiveRow(entry.name);
|
||||
}}
|
||||
onDoubleClick={() => openEntry(entry)}
|
||||
onKeyDown={(e) => {
|
||||
if (isRenaming) return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
setSelected(entry.name);
|
||||
setActiveRow(entry.name);
|
||||
openEntry(entry);
|
||||
} else if (e.key === "F2") {
|
||||
e.preventDefault();
|
||||
startRename(entry);
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
moveActive(entry.name, e.key === "ArrowDown" ? 1 : -1);
|
||||
} else if (e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
moveActive(entry.name, e.key === "Home" ? "first" : "last");
|
||||
}
|
||||
}}
|
||||
className={rowClass(isSelected)}
|
||||
>
|
||||
<td role="gridcell" className="px-4 py-1.5">
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameDraft}
|
||||
aria-label={`New name for ${entry.name}`}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
onBlur={() => commitRename(entry)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
}}
|
||||
className="w-64 px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs font-mono text-[var(--text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`font-mono ${
|
||||
entry.is_directory
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{/* Directory-ness was carried by hue and an
|
||||
`aria-hidden` emoji, i.e. by nothing at all for a
|
||||
screen reader. The emoji stays hidden — it reads
|
||||
as "file folder" in some voices and as nothing in
|
||||
others — and the word is what is announced. */}
|
||||
<span className="sr-only">
|
||||
{entry.is_directory ? "Folder, " : "File, "}
|
||||
</span>
|
||||
{entry.is_directory && <span aria-hidden="true">📁 </span>}
|
||||
<span>{entry.name}</span>
|
||||
{entry.is_symlink && (
|
||||
<span
|
||||
className="ml-1 text-[var(--text-secondary)]"
|
||||
title="Symbolic link"
|
||||
>
|
||||
↗ link
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td role="gridcell" className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap tabular-nums">
|
||||
{!entry.is_directory && formatBytes(entry.size)}
|
||||
</td>
|
||||
<td role="gridcell" className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||||
{entry.modified}
|
||||
</td>
|
||||
<td role="gridcell" className="px-2 py-1.5 text-right whitespace-nowrap">
|
||||
{!isRenaming && (
|
||||
<>
|
||||
{/* WCAG 2.5.3: the accessible name has to *contain*
|
||||
the visible label, so the row context is appended
|
||||
rather than substituted. "Rename notes.txt" used
|
||||
to be the whole name, which left a voice-control
|
||||
user saying "click Rename" at a button that had
|
||||
no such name. */}
|
||||
<Button
|
||||
aria-label={`Rename — ${entry.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startRename(entry);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
{/* Folders have no single-file equivalent — a
|
||||
recursive download is what "Back up container" is
|
||||
for, and offering one here would mean rebuilding
|
||||
the tree-walking this pane deliberately does not
|
||||
do. */}
|
||||
{!entry.is_directory && (
|
||||
<Button
|
||||
aria-label={`Save to host — ${entry.name}`}
|
||||
className="ml-1"
|
||||
// Only this row: a large file can take a while,
|
||||
// and there is no reason the rest of the pane
|
||||
// should go dead while it is written.
|
||||
disabled={savingPaths.has(entry.path)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void saveToHost(entry);
|
||||
}}
|
||||
// A double-click is its own event, and
|
||||
// `onClick`'s `stopPropagation` says nothing
|
||||
// about it — so an impatient double-click here
|
||||
// reached the row's `onDoubleClick` and dropped
|
||||
// the viewer modal over the pane, on top of the
|
||||
// save dialog the backend had just opened.
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{savingPaths.has(entry.path) ? "Saving…" : "Save to host…"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{entries.length === 0 && !loading && (
|
||||
<tr>
|
||||
<tr role="row">
|
||||
<td
|
||||
role="gridcell"
|
||||
colSpan={4}
|
||||
className="px-4 py-8 text-center text-[var(--text-secondary)]"
|
||||
>
|
||||
@@ -157,6 +577,14 @@ export default function FilesTab({ project }: Props) {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewing && (
|
||||
<FileViewerModal
|
||||
projectId={project.id}
|
||||
entry={viewing}
|
||||
onClose={() => setViewing(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,6 +120,15 @@ export default function OverviewTab({
|
||||
{project.mission_control_enabled ? "ON" : "OFF"}
|
||||
</span>
|
||||
</span>
|
||||
{/* Only when granted. It is off for nearly every project and an
|
||||
always-present "VPN OFF" would be noise, but where it *is* on the
|
||||
container holds NET_ADMIN, which is worth seeing at a glance. */}
|
||||
{project.vpn_support_enabled && (
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
VPN support{" "}
|
||||
<span className="text-[var(--text-primary)] font-medium">ON</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenTab("config")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { projectRemovalIsClean } from "../../../lib/types";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import { useProjects } from "../../../hooks/useProjects";
|
||||
@@ -18,6 +19,7 @@ import ConfigTab from "./ConfigTab";
|
||||
import FilesTab from "./FilesTab";
|
||||
import BrowserTab from "./BrowserTab";
|
||||
import { formatUptime } from "./format";
|
||||
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
|
||||
|
||||
const TABS = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
@@ -43,6 +45,16 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
const { projects, remove } = useProjects();
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
||||
|
||||
// Somewhere else asked for this project on a particular sub-tab — currently
|
||||
// "I opened a page in the container's browser, show me it". Consumed once, so
|
||||
// it cannot fight the user's own clicking afterwards.
|
||||
const pendingHomeTab = useAppState((s) => s.pendingHomeTab);
|
||||
useEffect(() => {
|
||||
if (pendingHomeTab?.projectId !== projectId) return;
|
||||
setTab(pendingHomeTab.tab as ProjectHomeTabId);
|
||||
useAppState.getState().clearPendingHomeTab();
|
||||
}, [pendingHomeTab, projectId]);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [showMigration, setShowMigration] = useState(false);
|
||||
@@ -272,7 +284,25 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
onConfirm={async () => {
|
||||
setConfirmRemove(false);
|
||||
try {
|
||||
await remove(project.id);
|
||||
const report = await remove(project.id);
|
||||
if (!projectRemovalIsClean(report)) {
|
||||
const verb = leftoverVerb(report);
|
||||
if (report.retry_scheduled) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "info",
|
||||
message: `“${project.name}” was removed, but Triple-C could not confirm all its Docker resources were removed`,
|
||||
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed. It will check again the next time it starts.`,
|
||||
});
|
||||
} else {
|
||||
// The pending-cleanup record itself failed to save — no
|
||||
// retry will happen, so this must not promise one.
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: `“${project.name}” was removed, but Triple-C could not confirm its Docker resources were removed`,
|
||||
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed, and could not record this for a retry. You may need to remove ${leftoverPronoun(report)} manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
|
||||
@@ -60,6 +60,8 @@ const existingTask: ScheduledTask = {
|
||||
created_at: null,
|
||||
last_run: null,
|
||||
next_run: null,
|
||||
running: false,
|
||||
running_since: null,
|
||||
};
|
||||
|
||||
async function renderEditor(task: ScheduledTask | null = null, project = baseProject) {
|
||||
|
||||
@@ -311,10 +311,22 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro
|
||||
</div>
|
||||
|
||||
{task && (
|
||||
/*
|
||||
An edit is `add` then `remove` (see `update_scheduled_task`), and
|
||||
`triple-c-scheduler`'s remove now reaps the task's log directory —
|
||||
so on a current container the old logs are gone, not merely filed
|
||||
under the old id, which is what this used to promise.
|
||||
|
||||
It is deliberately not stated as a certainty. `/usr/local/bin` only
|
||||
changes on base-image migration or Reset, so a project still running
|
||||
an older base image carries the older scheduler, whose remove leaves
|
||||
the log directory behind. "Assume they go with it" is true in both
|
||||
worlds and spares the user a paragraph about which one they are in.
|
||||
*/
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
The scheduler has no edit command, so saving re-creates this task under a new id and
|
||||
removes <code className="font-mono">{task.id}</code>. Its previous run logs stay under
|
||||
the old id.
|
||||
removes <code className="font-mono">{task.id}</code>. Assume its earlier run logs go
|
||||
with it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSecretField } from "../../../../hooks/useSecretField";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { Project } from "../../../../lib/types";
|
||||
import Button from "../../../ui/Button";
|
||||
@@ -24,14 +25,15 @@ export default function AccessSection({
|
||||
const [caCertPath, setCaCertPath] = useState(project.ca_cert_path ?? "");
|
||||
const [gitName, setGitName] = useState(project.git_user_name ?? "");
|
||||
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
|
||||
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
||||
// Never seeded from `project` — the backend does not serialize secrets, so
|
||||
// the box is always empty and only an edit may speak about the stored value.
|
||||
const gitToken = useSecretField(project.id);
|
||||
|
||||
useEffect(() => {
|
||||
setSshKeyPath(project.ssh_key_path ?? "");
|
||||
setCaCertPath(project.ca_cert_path ?? "");
|
||||
setGitName(project.git_user_name ?? "");
|
||||
setGitEmail(project.git_user_email ?? "");
|
||||
setGitToken(project.git_token ?? "");
|
||||
}, [project]);
|
||||
|
||||
return (
|
||||
@@ -101,15 +103,19 @@ export default function AccessSection({
|
||||
|
||||
<Field
|
||||
label="Git HTTPS token"
|
||||
hint="A personal access token (e.g. a GitHub PAT) for HTTPS git operations inside the container."
|
||||
hint={
|
||||
gitToken.edited
|
||||
? "Saved when you click away. Clearing the box removes the stored token."
|
||||
: "A personal access token (e.g. a GitHub PAT) for HTTPS git operations inside the container. A stored token is not shown; leave this empty to keep it."
|
||||
}
|
||||
>
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={gitToken}
|
||||
onChange={(e) => setGitToken(e.target.value)}
|
||||
onBlur={() => save({ git_token: gitToken || null })}
|
||||
value={gitToken.value}
|
||||
onChange={(e) => gitToken.setValue(e.target.value)}
|
||||
onBlur={() => save({ ...gitToken.patch("git_token") })}
|
||||
placeholder="ghp_…"
|
||||
disabled={disabled}
|
||||
className={inputClass}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import AuthBridgeRow, { bridgeIndicator } from "./AuthBridgeRow";
|
||||
import type { AuthBridgeStatus, Project } from "../../../../lib/types";
|
||||
|
||||
/**
|
||||
* The bridge shipped with a working backend, a typed IPC wrapper, and no way to
|
||||
* reach either: `setAuthBridgeEnabled` had zero call sites, and the
|
||||
* `auth-bridge-changed` event had no listener — so a host port the bridge could
|
||||
* not take was a silent failure that presented as a login that simply hung.
|
||||
* These tests hold both halves down.
|
||||
*/
|
||||
|
||||
const getAuthBridgeStatus = vi.fn<() => Promise<AuthBridgeStatus>>();
|
||||
const setAuthBridgeEnabled = vi.fn<(id: string, on: boolean) => Promise<AuthBridgeStatus>>();
|
||||
|
||||
vi.mock("../../../../lib/tauri-commands", () => ({
|
||||
getAuthBridgeStatus: () => getAuthBridgeStatus(),
|
||||
setAuthBridgeEnabled: (id: string, on: boolean) => setAuthBridgeEnabled(id, on),
|
||||
}));
|
||||
|
||||
/** Captured so a test can push an `auth-bridge-changed` payload by hand. */
|
||||
let emit: ((payload: unknown) => void) | null = null;
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (_name: string, handler: (e: { payload: unknown }) => void) => {
|
||||
emit = (payload) => handler({ payload });
|
||||
return () => {
|
||||
emit = null;
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const OFF: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
|
||||
|
||||
const project = {
|
||||
id: "p1",
|
||||
name: "api",
|
||||
status: "running",
|
||||
auth_bridge_enabled: false,
|
||||
} as unknown as Project;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getAuthBridgeStatus.mockResolvedValue(OFF);
|
||||
setAuthBridgeEnabled.mockResolvedValue({ ...OFF, enabled: true });
|
||||
});
|
||||
|
||||
describe("AuthBridgeRow", () => {
|
||||
it("turns the bridge on through its own command, not the project save", async () => {
|
||||
// The dedicated command exists so this can be flipped while the container
|
||||
// runs — which is exactly when a user discovers they need it. Routing it
|
||||
// through the Config tab's stopped-only save would make it unreachable at
|
||||
// the only moment it matters.
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays usable while the container is running", async () => {
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
|
||||
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("reports a port conflict the poller emitted", async () => {
|
||||
getAuthBridgeStatus.mockResolvedValue({ ...OFF, enabled: true });
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(emit).not.toBeNull());
|
||||
|
||||
emit!({
|
||||
project_id: "p1",
|
||||
status: {
|
||||
enabled: true,
|
||||
active_ports: [],
|
||||
conflicts: [
|
||||
{ port: 54545, reason: "Host port 54545 is already in use (…); not bridged." },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Port 54545/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Port conflict")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores an event for a different project", async () => {
|
||||
getAuthBridgeStatus.mockResolvedValue({ ...OFF, enabled: true });
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(emit).not.toBeNull());
|
||||
|
||||
emit!({
|
||||
project_id: "other",
|
||||
status: { enabled: true, active_ports: [], conflicts: [{ port: 1, reason: "nope" }] },
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Port 1:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* The two halves of this row disagree about *when*, not about *what*.
|
||||
*
|
||||
* `set_auth_bridge_enabled` resolves with a status sampled as it returned;
|
||||
* the poller's event carries one sampled afterwards. Writing the awaited
|
||||
* value unconditionally therefore rolls the row back in time whenever the
|
||||
* two overlap — the row says "Watching" while a port is bound, which is the
|
||||
* exact silent failure the event subscription was added to end. These two
|
||||
* hold the ordering down from both the resolve and the reject side.
|
||||
*/
|
||||
describe("a pushed event outranks an older awaited result", () => {
|
||||
/** A toggle that will not settle until the test says so. */
|
||||
function deferToggle() {
|
||||
let settle!: (s: AuthBridgeStatus) => void;
|
||||
let fail!: (e: unknown) => void;
|
||||
setAuthBridgeEnabled.mockImplementation(
|
||||
() =>
|
||||
new Promise<AuthBridgeStatus>((resolve, reject) => {
|
||||
settle = resolve;
|
||||
fail = reject;
|
||||
}),
|
||||
);
|
||||
return { settle: (s: AuthBridgeStatus) => settle(s), fail: (e: unknown) => fail(e) };
|
||||
}
|
||||
|
||||
const BRIDGING: AuthBridgeStatus = {
|
||||
enabled: true,
|
||||
active_ports: [{ port: 54545, family: "v4", bridged_at: "", ipv6_warning: null }],
|
||||
conflicts: [],
|
||||
};
|
||||
|
||||
async function startToggleThenPush() {
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
|
||||
await waitFor(() => expect(emit).not.toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
|
||||
await waitFor(() => expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true));
|
||||
|
||||
// The poller binds a port while the command is still in flight.
|
||||
emit!({ project_id: "p1", status: BRIDGING });
|
||||
expect(await screen.findByText("Bridging 1 port")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
it("keeps the newer state when the command settles with the older one", async () => {
|
||||
const toggle = deferToggle();
|
||||
await startToggleThenPush();
|
||||
|
||||
// …and only now returns the snapshot it took *before* that port existed.
|
||||
toggle.settle({ enabled: true, active_ports: [], conflicts: [] });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeDisabled(),
|
||||
);
|
||||
expect(screen.getByText("Bridging 1 port")).toBeInTheDocument();
|
||||
expect(screen.getByText("127.0.0.1:54545")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Watching")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not let the rollback undo a status pushed while it was failing", async () => {
|
||||
// The command failed, so the error belongs on screen — but the bridge
|
||||
// demonstrably came up, and reverting the switch to off would contradict
|
||||
// the port listed right beside it.
|
||||
const toggle = deferToggle();
|
||||
await startToggleThenPush();
|
||||
|
||||
toggle.fail("bridge probe timed out");
|
||||
|
||||
expect(await screen.findByText(/probe timed out/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Bridging 1 port")).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Auth bridge" })).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it("puts the switch back if the command rejects", async () => {
|
||||
setAuthBridgeEnabled.mockRejectedValue("Project p1 not found");
|
||||
render(<AuthBridgeRow project={project} />);
|
||||
await waitFor(() => expect(getAuthBridgeStatus).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Auth bridge" }));
|
||||
|
||||
expect(await screen.findByText(/not found/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch", { name: "Auth bridge" })).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bridgeIndicator", () => {
|
||||
// Every branch is a glyph plus a word — status is never colour alone.
|
||||
it("says nothing is on when it is off", () => {
|
||||
expect(bridgeIndicator(OFF, true)).toEqual({ tone: "off", label: "Off" });
|
||||
});
|
||||
|
||||
it("puts a conflict ahead of everything else", () => {
|
||||
expect(
|
||||
bridgeIndicator(
|
||||
{
|
||||
enabled: true,
|
||||
active_ports: [
|
||||
{ port: 1, family: "v4", bridged_at: "", ipv6_warning: null },
|
||||
],
|
||||
conflicts: [{ port: 2, reason: "taken" }],
|
||||
},
|
||||
true,
|
||||
).tone,
|
||||
).toBe("error");
|
||||
});
|
||||
|
||||
it("flags a port that only took the IPv4 half", () => {
|
||||
// Node resolves `localhost` to IPv6 first on Linux, so a v4-only listener
|
||||
// is a callback that never arrives in front of a bridge reporting healthy.
|
||||
expect(
|
||||
bridgeIndicator(
|
||||
{
|
||||
enabled: true,
|
||||
active_ports: [
|
||||
{ port: 1, family: "v6", bridged_at: "", ipv6_warning: "no ::1" },
|
||||
],
|
||||
conflicts: [],
|
||||
},
|
||||
true,
|
||||
).label,
|
||||
).toBe("IPv4 only");
|
||||
});
|
||||
|
||||
it("counts the ports it is holding", () => {
|
||||
expect(
|
||||
bridgeIndicator(
|
||||
{
|
||||
enabled: true,
|
||||
active_ports: [
|
||||
{ port: 1, family: "v4", bridged_at: "", ipv6_warning: null },
|
||||
{ port: 2, family: "v4", bridged_at: "", ipv6_warning: null },
|
||||
],
|
||||
conflicts: [],
|
||||
},
|
||||
true,
|
||||
).label,
|
||||
).toBe("Bridging 2 ports");
|
||||
});
|
||||
|
||||
it("says it is waiting when the container is not running", () => {
|
||||
// Enabled and holding nothing is normal; enabled with no container is a
|
||||
// different thing, and saying so stops it reading as a failure.
|
||||
expect(bridgeIndicator({ ...OFF, enabled: true }, false).label).toBe(
|
||||
"Waiting for the container",
|
||||
);
|
||||
expect(bridgeIndicator({ ...OFF, enabled: true }, true).label).toBe("Watching");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
getAuthBridgeStatus,
|
||||
setAuthBridgeEnabled,
|
||||
} from "../../../../lib/tauri-commands";
|
||||
import type {
|
||||
AuthBridgeChangedEvent,
|
||||
AuthBridgeStatus,
|
||||
Project,
|
||||
} from "../../../../lib/types";
|
||||
import { SwitchRow } from "../../../ui/Field";
|
||||
import StatusIndicator, { type StatusTone } from "../../../ui/StatusIndicator";
|
||||
import Toggle from "../../../ui/Toggle";
|
||||
|
||||
/** Emitted by `auth_bridge/mod.rs` whenever the port or conflict set changes. */
|
||||
const AUTH_BRIDGE_EVENT = "auth-bridge-changed";
|
||||
|
||||
const LABEL = "Auth bridge";
|
||||
|
||||
/**
|
||||
* What the indicator beside the switch says.
|
||||
*
|
||||
* Split out so the interesting part — that a conflict is a *visible* failure —
|
||||
* can be tested without a container. Every branch pairs a glyph with a word;
|
||||
* none of them are distinguished by colour alone.
|
||||
*/
|
||||
export function bridgeIndicator(
|
||||
status: AuthBridgeStatus | null,
|
||||
containerRunning: boolean,
|
||||
): { tone: StatusTone; label: string } {
|
||||
if (!status) return { tone: "unknown", label: "Checking" };
|
||||
if (!status.enabled) return { tone: "off", label: "Off" };
|
||||
// A conflict means a login is in progress and its port could not be taken —
|
||||
// the one state where doing nothing is the wrong answer, and until now the
|
||||
// one state nothing in the app reported at all.
|
||||
if (status.conflicts.length > 0) {
|
||||
return { tone: "error", label: "Port conflict" };
|
||||
}
|
||||
if (status.active_ports.some((p) => p.ipv6_warning)) {
|
||||
return { tone: "busy", label: "IPv4 only" };
|
||||
}
|
||||
if (status.active_ports.length > 0) {
|
||||
const n = status.active_ports.length;
|
||||
return { tone: "running", label: `Bridging ${n} port${n === 1 ? "" : "s"}` };
|
||||
}
|
||||
// Enabled but holding nothing. Normal: there is only something to bridge
|
||||
// while a login is actually waiting for a callback.
|
||||
if (!containerRunning) {
|
||||
return { tone: "stopped", label: "Waiting for the container" };
|
||||
}
|
||||
return { tone: "ok", label: "Watching" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The switch for `auth_bridge_enabled`, and the only place it can be changed.
|
||||
*
|
||||
* Two things here are deliberate and easy to undo by accident:
|
||||
*
|
||||
* - **It does not go through the Config tab's `save`.** That path is gated on
|
||||
* a stopped container, because almost everything else in the tab is baked
|
||||
* into the container at creation. This is not: the bridge is entirely
|
||||
* host-side, and `set_auth_bridge_enabled` exists precisely so it can be
|
||||
* flipped *while a login is hanging*, which is when the user finds out they
|
||||
* need it. Routing it through the generic save would make it unreachable at
|
||||
* the only moment it matters.
|
||||
* - **It subscribes to `auth-bridge-changed`.** The poller already emits the
|
||||
* bridged-port and conflict sets on every change and, before this, nothing
|
||||
* listened — so a host port the bridge could not take was a completely
|
||||
* silent failure, indistinguishable from a login that simply hung.
|
||||
*/
|
||||
export default function AuthBridgeRow({ project }: { project: Project }) {
|
||||
const projectId = project.id;
|
||||
const containerRunning = project.status === "running";
|
||||
|
||||
const [status, setStatus] = useState<AuthBridgeStatus | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Which write to `status` is the newest — the same "is this still mine?"
|
||||
* guard `useContainerMigration` uses around its async
|
||||
* writes, and needed here for a reason that is easy to miss.
|
||||
*
|
||||
* There are two sources of truth for this row and only one of them is
|
||||
* ordered. `set_auth_bridge_enabled` resolves with a status *sampled at the
|
||||
* moment it returned*; the poller's `auth-bridge-changed` event carries one
|
||||
* sampled later. Awaiting the command therefore hands back a value that may
|
||||
* already be historical, and writing it unconditionally is how the row ends
|
||||
* up saying "Watching" while a port is in fact bound — the failure mode the
|
||||
* event subscription exists to prevent, reintroduced one line below it.
|
||||
*
|
||||
* So every write claims a generation and only lands if it still holds it.
|
||||
* A pushed event always claims a fresh one, which is what makes it win over
|
||||
* an older awaited result no matter which order the two arrive in.
|
||||
*/
|
||||
const generation = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const mine = ++generation.current;
|
||||
let cancelled = false;
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
getAuthBridgeStatus(projectId)
|
||||
.then((s) => {
|
||||
// The initial fetch races the poller exactly like the toggle does: an
|
||||
// event can land first and describe a bridge this reply predates.
|
||||
if (!cancelled && generation.current === mine) setStatus(s);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(String(e));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<AuthBridgeChangedEvent>(AUTH_BRIDGE_EVENT, (event) => {
|
||||
if (event.payload.project_id !== projectId) return;
|
||||
// A pushed status is the most recent observation that exists, so it
|
||||
// claims the newest generation and invalidates anything still in flight.
|
||||
generation.current += 1;
|
||||
setStatus(event.payload.status);
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
else unlisten = un;
|
||||
})
|
||||
.catch((e) => console.error("Auth bridge event subscription failed:", e));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (next: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
// Optimistic, so the switch responds even though enabling has to await a
|
||||
// container probe. It claims a generation like every other write, so a
|
||||
// pushed event that lands mid-flight supersedes it rather than being
|
||||
// undone by the settle below.
|
||||
const mine = ++generation.current;
|
||||
setStatus((s) => (s ? { ...s, enabled: next } : s));
|
||||
try {
|
||||
const settled = await setAuthBridgeEnabled(projectId, next);
|
||||
// Stale by the time it arrived: the poller has already told us
|
||||
// something newer, and `settled` predates it.
|
||||
if (generation.current !== mine) return;
|
||||
setStatus(settled);
|
||||
} catch (e) {
|
||||
// The error is reported either way — the command really did fail — but
|
||||
// the rollback must not resurrect the pre-toggle value over a status
|
||||
// the poller pushed while the command was failing.
|
||||
setError(String(e));
|
||||
if (generation.current !== mine) return;
|
||||
setStatus((s) => (s ? { ...s, enabled: !next } : s));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
// Fall back to the persisted flag until the first status arrives, so the
|
||||
// switch never renders in the wrong position.
|
||||
const enabled = status?.enabled ?? project.auth_bridge_enabled;
|
||||
const indicator = bridgeIndicator(status, containerRunning);
|
||||
|
||||
return (
|
||||
<SwitchRow
|
||||
label={LABEL}
|
||||
hint={
|
||||
<>
|
||||
Mirrors a port a program inside the container is listening on onto the
|
||||
host's <code>127.0.0.1</code>, so a browser OAuth callback can reach
|
||||
the listener waiting inside the container —{" "}
|
||||
<code>claude login</code>, <code>aws sso login</code> and{" "}
|
||||
<code>gh auth login</code> all work this way, and without it the
|
||||
browser calls back into nothing and the login hangs. Host-side only:
|
||||
it never recreates the container, and it can be switched on while one
|
||||
is running. A bridged port is unauthenticated and reachable by any
|
||||
local process for as long as the in-container listener exists, so
|
||||
leave it off unless you need it.
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<StatusIndicator tone={indicator.tone} label={indicator.label} />
|
||||
{status?.active_ports.map((p) => (
|
||||
<span
|
||||
key={p.port}
|
||||
className="font-mono text-[var(--text-secondary)]"
|
||||
title={p.ipv6_warning ?? `Bound on host 127.0.0.1:${p.port}`}
|
||||
>
|
||||
127.0.0.1:{p.port}
|
||||
{p.ipv6_warning ? " (IPv4 only)" : ""}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{status?.conflicts.map((c) => (
|
||||
<span
|
||||
key={c.port}
|
||||
className="mt-1 block text-[var(--error)]"
|
||||
role="status"
|
||||
>
|
||||
Port {c.port}: {c.reason}
|
||||
</span>
|
||||
))}
|
||||
{status?.active_ports
|
||||
.filter((p) => p.ipv6_warning)
|
||||
.map((p) => (
|
||||
<span key={p.port} className="mt-1 block text-[var(--warning)]">
|
||||
Port {p.port}: {p.ipv6_warning}
|
||||
</span>
|
||||
))}
|
||||
{error && (
|
||||
<span className="mt-1 block text-[var(--error)]">{error}</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
control={
|
||||
<Toggle
|
||||
label={LABEL}
|
||||
checked={enabled}
|
||||
// Never gated on the container being stopped — see the note above.
|
||||
disabled={busy}
|
||||
onChange={toggle}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSecretField, withoutUntouchedSecrets } from "../../../../hooks/useSecretField";
|
||||
import type {
|
||||
Backend,
|
||||
BedrockAuthMethod,
|
||||
@@ -16,6 +17,17 @@ import Field, {
|
||||
} from "../../../ui/Field";
|
||||
import Toggle from "../../../ui/Toggle";
|
||||
|
||||
/** Bedrock fields held in the OS keychain, never serialized back to us. */
|
||||
const BEDROCK_SECRET_KEYS = [
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
"aws_session_token",
|
||||
"aws_bearer_token",
|
||||
] as const;
|
||||
|
||||
/** The same, for the OpenAI-compatible backend. */
|
||||
const OPENAI_SECRET_KEYS = ["api_key"] as const;
|
||||
|
||||
export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = {
|
||||
auth_method: "static_credentials",
|
||||
aws_region: "us-east-1",
|
||||
@@ -65,11 +77,12 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
|
||||
// Local text state — saved on blur, not on every keystroke.
|
||||
const [bedrockRegion, setBedrockRegion] = useState(bedrock.aws_region);
|
||||
const [accessKeyId, setAccessKeyId] = useState(bedrock.aws_access_key_id ?? "");
|
||||
const [secretKey, setSecretKey] = useState(bedrock.aws_secret_access_key ?? "");
|
||||
const [sessionToken, setSessionToken] = useState(bedrock.aws_session_token ?? "");
|
||||
// Secrets are never seeded from `project` — see `useSecretField`.
|
||||
const accessKeyId = useSecretField(project.id);
|
||||
const secretKey = useSecretField(project.id);
|
||||
const sessionToken = useSecretField(project.id);
|
||||
const [profile, setProfile] = useState(bedrock.aws_profile ?? "");
|
||||
const [bearerToken, setBearerToken] = useState(bedrock.aws_bearer_token ?? "");
|
||||
const bearerToken = useSecretField(project.id);
|
||||
const [bedrockModelId, setBedrockModelId] = useState(bedrock.model_id ?? "");
|
||||
const [serviceTier, setServiceTier] = useState(bedrock.service_tier ?? "");
|
||||
|
||||
@@ -97,9 +110,7 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
project.openai_compatible_config?.base_url ??
|
||||
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||
);
|
||||
const [oaiApiKey, setOaiApiKey] = useState(
|
||||
project.openai_compatible_config?.api_key ?? "",
|
||||
);
|
||||
const oaiApiKey = useSecretField(project.id);
|
||||
const [oaiModelId, setOaiModelId] = useState(
|
||||
project.openai_compatible_config?.model_id ?? "",
|
||||
);
|
||||
@@ -107,14 +118,13 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
project.openai_compatible_config?.haiku_model_id ?? "",
|
||||
);
|
||||
|
||||
// Secret fields are deliberately absent here: `useSecretField` owns its own
|
||||
// reset, and re-seeding one from `project` would write an empty string over
|
||||
// whatever the user had half-typed on any unrelated project update.
|
||||
useEffect(() => {
|
||||
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
||||
setBedrockRegion(bc.aws_region);
|
||||
setAccessKeyId(bc.aws_access_key_id ?? "");
|
||||
setSecretKey(bc.aws_secret_access_key ?? "");
|
||||
setSessionToken(bc.aws_session_token ?? "");
|
||||
setProfile(bc.aws_profile ?? "");
|
||||
setBearerToken(bc.aws_bearer_token ?? "");
|
||||
setBedrockModelId(bc.model_id ?? "");
|
||||
setServiceTier(bc.service_tier ?? "");
|
||||
setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url);
|
||||
@@ -129,13 +139,18 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
project.openai_compatible_config?.base_url ??
|
||||
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||
);
|
||||
setOaiApiKey(project.openai_compatible_config?.api_key ?? "");
|
||||
setOaiModelId(project.openai_compatible_config?.model_id ?? "");
|
||||
setOaiHaikuModelId(project.openai_compatible_config?.haiku_model_id ?? "");
|
||||
}, [project]);
|
||||
|
||||
const saveBedrock = (patch: Partial<BedrockConfig>) =>
|
||||
save({ bedrock_config: { ...bedrock, ...patch } });
|
||||
save({
|
||||
bedrock_config: withoutUntouchedSecrets(
|
||||
{ ...bedrock, ...patch },
|
||||
patch,
|
||||
BEDROCK_SECRET_KEYS,
|
||||
),
|
||||
});
|
||||
|
||||
const saveOllama = (patch: Partial<OllamaConfig>) =>
|
||||
save({
|
||||
@@ -152,10 +167,14 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
|
||||
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
|
||||
save({
|
||||
openai_compatible_config: {
|
||||
...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG),
|
||||
...patch,
|
||||
},
|
||||
openai_compatible_config: withoutUntouchedSecrets(
|
||||
{
|
||||
...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG),
|
||||
...patch,
|
||||
},
|
||||
patch,
|
||||
OPENAI_SECRET_KEYS,
|
||||
),
|
||||
});
|
||||
|
||||
// Defaults to on: projects created before the field existed, and any data
|
||||
@@ -261,9 +280,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
{(id) => (
|
||||
<input
|
||||
id={id}
|
||||
value={accessKeyId}
|
||||
onChange={(e) => setAccessKeyId(e.target.value)}
|
||||
onBlur={() => saveBedrock({ aws_access_key_id: accessKeyId || null })}
|
||||
value={accessKeyId.value}
|
||||
onChange={(e) => accessKeyId.setValue(e.target.value)}
|
||||
onBlur={() => saveBedrock(accessKeyId.patch("aws_access_key_id"))}
|
||||
placeholder="AKIA…"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
@@ -278,10 +297,10 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChange={(e) => setSecretKey(e.target.value)}
|
||||
value={secretKey.value}
|
||||
onChange={(e) => secretKey.setValue(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveBedrock({ aws_secret_access_key: secretKey || null })
|
||||
saveBedrock(secretKey.patch("aws_secret_access_key"))
|
||||
}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
@@ -296,10 +315,10 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={sessionToken}
|
||||
onChange={(e) => setSessionToken(e.target.value)}
|
||||
value={sessionToken.value}
|
||||
onChange={(e) => sessionToken.setValue(e.target.value)}
|
||||
onBlur={() =>
|
||||
saveBedrock({ aws_session_token: sessionToken || null })
|
||||
saveBedrock(sessionToken.patch("aws_session_token"))
|
||||
}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
@@ -337,9 +356,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={bearerToken}
|
||||
onChange={(e) => setBearerToken(e.target.value)}
|
||||
onBlur={() => saveBedrock({ aws_bearer_token: bearerToken || null })}
|
||||
value={bearerToken.value}
|
||||
onChange={(e) => bearerToken.setValue(e.target.value)}
|
||||
onBlur={() => saveBedrock(bearerToken.patch("aws_bearer_token"))}
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
@@ -507,9 +526,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
||||
<input
|
||||
id={id}
|
||||
type="password"
|
||||
value={oaiApiKey}
|
||||
onChange={(e) => setOaiApiKey(e.target.value)}
|
||||
onBlur={() => saveOpenAi({ api_key: oaiApiKey || null })}
|
||||
value={oaiApiKey.value}
|
||||
onChange={(e) => oaiApiKey.setValue(e.target.value)}
|
||||
onBlur={() => saveOpenAi(oaiApiKey.patch("api_key"))}
|
||||
placeholder="sk-…"
|
||||
disabled={disabled}
|
||||
className={monoInputClass}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import RuntimeSection from "./RuntimeSection";
|
||||
import type { AuthBridgeStatus, Project } from "../../../../lib/types";
|
||||
|
||||
// The auth-bridge row owns its own IPC — see `AuthBridgeRow.tsx` for why it
|
||||
// does not go through `save`.
|
||||
const OFF_BRIDGE: AuthBridgeStatus = { enabled: false, active_ports: [], conflicts: [] };
|
||||
const setAuthBridgeEnabled = vi.fn(async () => ({ ...OFF_BRIDGE, enabled: true }));
|
||||
|
||||
vi.mock("../../../../lib/tauri-commands", () => ({
|
||||
getAuthBridgeStatus: vi.fn(async () => OFF_BRIDGE),
|
||||
setAuthBridgeEnabled: (id: string, on: boolean) => setAuthBridgeEnabled(id, on),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async () => () => {}),
|
||||
}));
|
||||
|
||||
const baseProject: Project = {
|
||||
id: "p1",
|
||||
name: "api-server",
|
||||
paths: [{ host_path: "/src/api", mount_name: "api" }],
|
||||
container_id: null,
|
||||
status: "stopped",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
ollama_config: null,
|
||||
llamacpp_config: null,
|
||||
openai_compatible_config: null,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: true,
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
browser_view_enabled: false,
|
||||
vpn_support_enabled: false,
|
||||
use_shared_auth_token: true,
|
||||
full_permissions: false,
|
||||
permission_mode: null,
|
||||
ssh_key_path: null,
|
||||
ca_cert_path: null,
|
||||
git_token: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
port_mappings: [],
|
||||
claude_instructions: null,
|
||||
claude_code_settings: null,
|
||||
renamed_session_names: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const VPN = "VPN support";
|
||||
|
||||
const save = vi.fn().mockResolvedValue(true);
|
||||
|
||||
function renderSection(over: Partial<Project> = {}, disabled = false) {
|
||||
return render(
|
||||
<RuntimeSection
|
||||
project={{ ...baseProject, ...over }}
|
||||
save={save}
|
||||
disabled={disabled}
|
||||
disabledReason="Container must be stopped to change this setting."
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RuntimeSection — VPN support toggle", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("saves only the VPN flag when switched on", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("switch", { name: VPN }));
|
||||
expect(save).toHaveBeenCalledWith({ vpn_support_enabled: true });
|
||||
});
|
||||
|
||||
it("saves the flag off again, rather than dropping the key", () => {
|
||||
// Off has to be written explicitly: the container carries a
|
||||
// `triple-c.vpn-support` label either way, and an absent value would leave
|
||||
// the capability granted.
|
||||
renderSection({ vpn_support_enabled: true });
|
||||
fireEvent.click(screen.getByRole("switch", { name: VPN }));
|
||||
expect(save).toHaveBeenCalledWith({ vpn_support_enabled: false });
|
||||
});
|
||||
|
||||
it("reflects the project's current state", () => {
|
||||
renderSection({ vpn_support_enabled: true });
|
||||
expect(screen.getByRole("switch", { name: VPN })).toBeChecked();
|
||||
});
|
||||
|
||||
it("cannot be changed while the container is running", () => {
|
||||
// Capabilities and devices are fixed at creation, so this setting is gated
|
||||
// on the container being stopped along with the rest of the tab.
|
||||
renderSection({}, true);
|
||||
const toggle = screen.getByRole("switch", { name: VPN });
|
||||
expect(toggle).toBeDisabled();
|
||||
fireEvent.click(toggle);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns that the change recreates the container", () => {
|
||||
renderSection();
|
||||
expect(
|
||||
screen.getByText(/recreates the container on its next start/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `scope="project"` on the settings editor is one prop with no visible owner,
|
||||
* and deleting it fails silently in the worst possible direction: the editor
|
||||
* falls back to `"global"`, every three-state control collapses to an on/off
|
||||
* switch, and a field the project is *inheriting* as on renders flat Off. The
|
||||
* user then reads a lie and, worse, flipping that switch writes a deliberate
|
||||
* `false` that overrides the global On they thought they were looking at.
|
||||
*
|
||||
* Nothing asserted the prop was passed, so these go through what is rendered
|
||||
* rather than through props — a switch where a select belongs is exactly the
|
||||
* regression, and it is visible from the outside.
|
||||
*/
|
||||
describe("RuntimeSection — Claude Code settings are edited at project scope", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("gives every setting the third Global state a project can inherit through", () => {
|
||||
renderSection();
|
||||
const focus = screen.getByLabelText("Focus mode") as HTMLSelectElement;
|
||||
expect(
|
||||
Array.from(focus.querySelectorAll("option")).map((o) => o.getAttribute("value")),
|
||||
).toEqual(["global", "off", "on"]);
|
||||
});
|
||||
|
||||
it("renders an untouched setting as inheriting, not as Off", () => {
|
||||
// `claude_code_settings: null` means "this project has no opinion", which
|
||||
// is not the same instruction as off. At global scope the same field is a
|
||||
// plain unchecked switch — indistinguishable from a user who turned it
|
||||
// off, and the reason the missing prop would never be noticed.
|
||||
renderSection({ claude_code_settings: null });
|
||||
expect((screen.getByLabelText("Focus mode") as HTMLSelectElement).value).toBe(
|
||||
"global",
|
||||
);
|
||||
expect(screen.queryByRole("switch", { name: "Focus mode" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a stored project override visible over the inherited value", () => {
|
||||
renderSection({
|
||||
claude_code_settings: {
|
||||
tui_mode: null,
|
||||
effort: null,
|
||||
auto_scroll_disabled: null,
|
||||
focus_mode: true,
|
||||
show_thinking_summaries: null,
|
||||
session_recap_disabled: null,
|
||||
env_scrub: null,
|
||||
prompt_caching_1h: null,
|
||||
},
|
||||
});
|
||||
expect((screen.getByLabelText("Focus mode") as HTMLSelectElement).value).toBe("on");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RuntimeSection — auth bridge toggle", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("is reachable while the container is running", async () => {
|
||||
// The rest of the tab is gated on a stopped container because those
|
||||
// settings are baked in at creation. This one is host-side and has its own
|
||||
// command, and the moment a user needs it is the moment a login is hanging
|
||||
// in a *running* container — so the tab's `disabled` must not reach it.
|
||||
renderSection({ status: "running" }, true);
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: "Auth bridge" });
|
||||
await waitFor(() => expect(toggle).not.toBeDisabled());
|
||||
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(setAuthBridgeEnabled).toHaveBeenCalledWith("p1", true));
|
||||
// And never through the generic project save, which would drop it on the
|
||||
// floor while the container runs.
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { ConfigGroup, SwitchRow } from "../../../ui/Field";
|
||||
import PermissionModeControl, { permissionModePatch } from "../../PermissionModeControl";
|
||||
import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor";
|
||||
import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor";
|
||||
import AuthBridgeRow from "./AuthBridgeRow";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -57,6 +58,25 @@ export default function RuntimeSection({
|
||||
}
|
||||
/>
|
||||
|
||||
<SwitchRow
|
||||
label="VPN support"
|
||||
hint="Grants NET_ADMIN and the /dev/net/tun device so a VPN client (PIA, WireGuard, OpenVPN) can build a tunnel inside the container. Without it a client installs and runs but its connection hangs until it times out. Anything in the container can then reconfigure the container's own network stack; the host's is untouched. Changing this recreates the container on its next start — the home and .claude volumes are preserved."
|
||||
control={
|
||||
<Toggle
|
||||
label="VPN support"
|
||||
checked={project.vpn_support_enabled}
|
||||
disabled={disabled}
|
||||
onChange={(v) => save({ vpn_support_enabled: v })}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Not gated on `disabled`: the bridge is host-side and has its own
|
||||
command, so it can be switched on while a login is hanging — which
|
||||
is the only moment anyone reaches for it. It owns its state rather
|
||||
than going through `save`. */}
|
||||
<AuthBridgeRow project={project} />
|
||||
|
||||
<SwitchRow
|
||||
label="Mission Control"
|
||||
hint="A web dashboard for monitoring and managing Claude sessions remotely."
|
||||
@@ -89,9 +109,19 @@ export default function RuntimeSection({
|
||||
|
||||
<ConfigGroup
|
||||
title="Claude Code settings"
|
||||
description="Per-project CLI behaviour. These override the global defaults in Settings."
|
||||
description={
|
||||
"Per-project CLI behaviour. Anything left on Global follows Settings; " +
|
||||
"Off overrides a global On. Changing any of these recreates the container, " +
|
||||
"which commits a new image layer — so flipping switches repeatedly costs disk. " +
|
||||
"Turning TUI mode, Effort level, Focus mode or Session recap back to Global " +
|
||||
"also needs the base image updated first: those four are cleared by removing a " +
|
||||
"key, and an older image's startup script ignores the instruction to remove it. " +
|
||||
"Update the base image from Overview. TUI mode, Effort level and Focus mode " +
|
||||
"visibly refuse to switch off until you do; Session recap just stays off silently."
|
||||
}
|
||||
>
|
||||
<ClaudeCodeSettingsEditor
|
||||
scope="project"
|
||||
settings={project.claude_code_settings}
|
||||
disabled={disabled}
|
||||
disabledReason={disabledReason}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import WorkspaceSection from "./WorkspaceSection";
|
||||
import type { Project } from "../../../../lib/types";
|
||||
|
||||
// The Browse button is the OS folder picker.
|
||||
const open = vi.fn();
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
open: (...args: unknown[]) => open(...args),
|
||||
}));
|
||||
|
||||
const baseProject: Project = {
|
||||
id: "p1",
|
||||
name: "api-server",
|
||||
paths: [{ host_path: "/src/api", mount_name: "api" }],
|
||||
container_id: null,
|
||||
status: "stopped",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
ollama_config: null,
|
||||
llamacpp_config: null,
|
||||
openai_compatible_config: null,
|
||||
allow_docker_access: false,
|
||||
sandbox_mode_enabled: true,
|
||||
mission_control_enabled: false,
|
||||
auth_bridge_enabled: false,
|
||||
browser_view_enabled: false,
|
||||
vpn_support_enabled: false,
|
||||
use_shared_auth_token: true,
|
||||
full_permissions: false,
|
||||
permission_mode: null,
|
||||
ssh_key_path: null,
|
||||
ca_cert_path: null,
|
||||
git_token: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
custom_env_vars: [],
|
||||
port_mappings: [],
|
||||
claude_instructions: null,
|
||||
claude_code_settings: null,
|
||||
renamed_session_names: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const save = vi.fn().mockResolvedValue(true);
|
||||
|
||||
function renderSection(over: Partial<Project> = {}, disabled = false) {
|
||||
return render(
|
||||
<WorkspaceSection
|
||||
project={{ ...baseProject, ...over }}
|
||||
save={save}
|
||||
disabled={disabled}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Every folder list this component has sent to `update_project`. */
|
||||
function savedLists() {
|
||||
return save.mock.calls
|
||||
.filter(([patch]) => "paths" in patch)
|
||||
.map(([patch]) => patch.paths);
|
||||
}
|
||||
|
||||
describe("WorkspaceSection — the blank row is never stored", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
/**
|
||||
* The bug this file exists for. `create_container` mounts every stored row
|
||||
* unfiltered, so a persisted `{host_path: "", mount_name: ""}` becomes
|
||||
* `{"Target": "/workspace/", "Source": ""}` and the daemon refuses the whole
|
||||
* container with `field Source must not be empty` — the project can never be
|
||||
* started or recreated again. Click "+ Add folder", blur a field, and it is
|
||||
* bricked.
|
||||
*/
|
||||
it("drops the placeholder row when a real edit is saved", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
|
||||
const hostPath = screen.getByLabelText("Folder 1 host path");
|
||||
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
|
||||
fireEvent.blur(hostPath);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
|
||||
});
|
||||
|
||||
it("drops it when Browse fills a different row in", async () => {
|
||||
open.mockResolvedValueOnce("/src/api-v2");
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
|
||||
// The picker is awaited inside the handler, so the state update that
|
||||
// follows it lands outside the click.
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Browse" })[0]);
|
||||
});
|
||||
|
||||
expect(savedLists()[0]).toEqual([{ host_path: "/src/api-v2", mount_name: "api" }]);
|
||||
});
|
||||
|
||||
it("drops it when a row is removed", () => {
|
||||
renderSection({
|
||||
paths: [
|
||||
{ host_path: "/src/api", mount_name: "api" },
|
||||
{ host_path: "/src/web", mount_name: "web" },
|
||||
],
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove folder 2" }));
|
||||
|
||||
expect(savedLists()[0]).toEqual([{ host_path: "/src/api", mount_name: "api" }]);
|
||||
});
|
||||
|
||||
it("never sends a row with an empty host path, whatever the route", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
const hostPath = screen.getByLabelText("Folder 1 host path");
|
||||
fireEvent.change(hostPath, { target: { value: "/src/api-v2" } });
|
||||
fireEvent.blur(hostPath);
|
||||
|
||||
for (const list of savedLists()) {
|
||||
for (const row of list) {
|
||||
expect(row.host_path).not.toBe("");
|
||||
expect(row.mount_name).not.toBe("");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceSection — what a blur is allowed to save", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
/**
|
||||
* Both inputs save on blur, so tabbing from the host path to the mount name
|
||||
* fires a save with the name still empty — which `update_project` refuses,
|
||||
* turning an ordinary keystroke into an error toast.
|
||||
*/
|
||||
it("holds a half-filled row back until it is complete", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
|
||||
const newHostPath = screen.getByLabelText("Folder 2 host path");
|
||||
fireEvent.change(newHostPath, { target: { value: "/src/web" } });
|
||||
fireEvent.blur(newHostPath);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
|
||||
const newMountName = screen.getByLabelText("Folder 2 mount name");
|
||||
fireEvent.change(newMountName, { target: { value: "web" } });
|
||||
fireEvent.blur(newMountName);
|
||||
expect(savedLists()[0]).toEqual([
|
||||
{ host_path: "/src/api", mount_name: "api" },
|
||||
{ host_path: "/src/web", mount_name: "web" },
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Blurring out of an untouched field is not an edit. Saving anyway would
|
||||
* round-trip the filtered list through `project` and take the empty row away
|
||||
* while the user was still filling it in.
|
||||
*/
|
||||
it("saves nothing when the blur changed nothing", () => {
|
||||
renderSection();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Add folder" }));
|
||||
fireEvent.blur(screen.getByLabelText("Folder 1 mount name"));
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Folder 2 host path")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still saves a rename, which does not go through the folder list", () => {
|
||||
renderSection();
|
||||
const name = screen.getByDisplayValue("api-server");
|
||||
fireEvent.change(name, { target: { value: "api-v2" } });
|
||||
fireEvent.blur(name);
|
||||
expect(save).toHaveBeenCalledWith({ name: "api-v2" });
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,14 @@ interface Props {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
/** Whether two folder lists are the same rows in the same order. */
|
||||
function sameRows(a: ProjectPath[], b: ProjectPath[]): boolean {
|
||||
return (
|
||||
a.length === b.length &&
|
||||
a.every((row, i) => row.host_path === b[i].host_path && row.mount_name === b[i].mount_name)
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [paths, setPaths] = useState<ProjectPath[]>(project.paths ?? []);
|
||||
@@ -19,6 +27,49 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
setPaths(project.paths ?? []);
|
||||
}, [project]);
|
||||
|
||||
/**
|
||||
* Persist a folder list, minus the rows that are only in it because the UI
|
||||
* put them there.
|
||||
*
|
||||
* **The blank row must never reach the store.** "+ Add folder" inserts
|
||||
* `{host_path: "", mount_name: ""}` deliberately, and `create_container`
|
||||
* mounts every stored row unfiltered — a stored blank one becomes
|
||||
* `{"Target": "/workspace/", "Source": ""}`, which the daemon rejects with
|
||||
* `field Source must not be empty`. The project then cannot be started or
|
||||
* recreated at all, from a click and a blur. `AddProjectDialog` has always
|
||||
* filtered this; this section computed the filtered list and then saved the
|
||||
* unfiltered one.
|
||||
*
|
||||
* Every save goes through here for that reason — Browse and Remove write the
|
||||
* list too, and either can be holding a blank row from an earlier click.
|
||||
*/
|
||||
const persist = (rows: ProjectPath[]) => {
|
||||
const filled = rows.filter((p) => p.host_path.trim() || p.mount_name.trim());
|
||||
return save({ paths: filled });
|
||||
};
|
||||
|
||||
/**
|
||||
* Save only when every row is fully filled in.
|
||||
*
|
||||
* Both inputs save on blur, so tabbing from the host path to the mount name
|
||||
* fires a save with the name still empty. `update_project` now validates —
|
||||
* a half-filled row is refused — so the unconditional save turned an ordinary
|
||||
* keystroke into an error toast. A blank row is *not* incomplete: the
|
||||
* "+ Add folder" button adds one deliberately, and it is dropped on save.
|
||||
*
|
||||
* A blur that changed nothing saves nothing, which is what keeps the blank
|
||||
* row on screen while it is being filled in: persisting the filtered list
|
||||
* would round-trip through `project` and take the empty row away under the
|
||||
* cursor.
|
||||
*/
|
||||
const saveIfComplete = () => {
|
||||
const filled = paths.filter((p) => p.host_path.trim() || p.mount_name.trim());
|
||||
const halfFilled = filled.some((p) => !p.host_path.trim() || !p.mount_name.trim());
|
||||
if (halfFilled) return;
|
||||
if (sameRows(filled, project.paths ?? [])) return;
|
||||
return persist(paths);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigGroup
|
||||
title="Workspace"
|
||||
@@ -70,7 +121,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
updated[i] = { ...updated[i], host_path: e.target.value };
|
||||
setPaths(updated);
|
||||
}}
|
||||
onBlur={() => save({ paths })}
|
||||
onBlur={() => saveIfComplete()}
|
||||
placeholder="/path/to/folder"
|
||||
disabled={disabled}
|
||||
className={`flex-1 min-w-0 ${inputClass}`}
|
||||
@@ -90,7 +141,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
mount_name: updated[i].mount_name || basename,
|
||||
};
|
||||
setPaths(updated);
|
||||
save({ paths: updated });
|
||||
persist(updated);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -107,7 +158,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
updated[i] = { ...updated[i], mount_name: e.target.value };
|
||||
setPaths(updated);
|
||||
}}
|
||||
onBlur={() => save({ paths })}
|
||||
onBlur={() => saveIfComplete()}
|
||||
placeholder="name"
|
||||
disabled={disabled}
|
||||
className={`w-40 ${monoInputClass}`}
|
||||
@@ -121,7 +172,7 @@ export default function WorkspaceSection({ project, save, disabled }: Props) {
|
||||
onClick={() => {
|
||||
const updated = paths.filter((_, j) => j !== i);
|
||||
setPaths(updated);
|
||||
save({ paths: updated });
|
||||
persist(updated);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
IMAGE_PREVIEW_LIMIT,
|
||||
TEXT_PREVIEW_LIMIT,
|
||||
decodeBase64,
|
||||
extensionOf,
|
||||
imageMimeFor,
|
||||
looksBinary,
|
||||
previewKind,
|
||||
previewLimit,
|
||||
} from "./filePreview";
|
||||
|
||||
describe("extensionOf", () => {
|
||||
it("lowercases, and takes only the last segment", () => {
|
||||
expect(extensionOf("Photo.PNG")).toBe("png");
|
||||
expect(extensionOf("archive.tar.gz")).toBe("gz");
|
||||
expect(extensionOf("/workspace/app/main.rs")).toBe("rs");
|
||||
});
|
||||
|
||||
it("treats a leading dot as hidden, not as an extension", () => {
|
||||
// `.gitignore` is a text file called `.gitignore`, not one of type "gitignore".
|
||||
expect(extensionOf(".gitignore")).toBe("");
|
||||
expect(extensionOf("Makefile")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewKind", () => {
|
||||
it("recognises images by extension, with a MIME the Blob can use", () => {
|
||||
expect(previewKind("logo.png")).toBe("image");
|
||||
expect(imageMimeFor("logo.PNG")).toBe("image/png");
|
||||
expect(imageMimeFor("photo.jpeg")).toBe("image/jpeg");
|
||||
expect(imageMimeFor("icon.svg")).toBe("image/svg+xml");
|
||||
expect(imageMimeFor("notes.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("recognises known text extensions and conventional extensionless names", () => {
|
||||
expect(previewKind("main.rs")).toBe("text");
|
||||
expect(previewKind("config.yaml")).toBe("text");
|
||||
expect(previewKind("Dockerfile")).toBe("text");
|
||||
expect(previewKind("README")).toBe("text");
|
||||
expect(previewKind(".gitignore")).toBe("text");
|
||||
});
|
||||
|
||||
it("leaves anything else undecided rather than refusing it outright", () => {
|
||||
// `unknown` means "read it and sniff the bytes" — a .bak of a config file
|
||||
// should still preview.
|
||||
expect(previewKind("dump.bak")).toBe("unknown");
|
||||
expect(previewKind("app.wasm")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewLimit", () => {
|
||||
it("gives images the bigger budget, since they are what blows a text cap", () => {
|
||||
expect(previewLimit("photo.jpg")).toBe(IMAGE_PREVIEW_LIMIT);
|
||||
expect(previewLimit("notes.md")).toBe(TEXT_PREVIEW_LIMIT);
|
||||
expect(previewLimit("mystery.bin")).toBe(TEXT_PREVIEW_LIMIT);
|
||||
expect(IMAGE_PREVIEW_LIMIT).toBeGreaterThan(TEXT_PREVIEW_LIMIT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeBase64 / looksBinary", () => {
|
||||
it("round-trips bytes that are not valid UTF-8", () => {
|
||||
// The reason the backend returns base64 at all: these bytes must survive.
|
||||
const bytes = decodeBase64(btoa("\xff\xd8\xff\xe0"));
|
||||
expect(Array.from(bytes)).toEqual([0xff, 0xd8, 0xff, 0xe0]);
|
||||
});
|
||||
|
||||
it("calls a NUL-bearing prefix binary and plain text text", () => {
|
||||
expect(looksBinary(new Uint8Array([0x68, 0x69, 0x0a]))).toBe(false);
|
||||
expect(looksBinary(new Uint8Array([0x68, 0x00, 0x69]))).toBe(true);
|
||||
});
|
||||
|
||||
it("only sniffs the first 8 KB, so a NUL deep in a big file is ignored", () => {
|
||||
const bytes = new Uint8Array(20000).fill(0x61);
|
||||
bytes[9000] = 0;
|
||||
expect(looksBinary(bytes)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* What the Files viewer can show, and how much of it to ask for.
|
||||
*
|
||||
* Pure helpers, deliberately separate from the modal: the type sniffing is
|
||||
* where a preview quietly turns into a screenful of mojibake, and it is worth
|
||||
* testing without a container.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extension → MIME, for the raster/vector types an `<img>` actually renders.
|
||||
* The MIME matters because the bytes are handed to the DOM as a `Blob`, and a
|
||||
* blob with the wrong (or empty) type will not decode.
|
||||
*/
|
||||
const IMAGE_MIME: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
avif: "image/avif",
|
||||
// Safe in an `<img>`: that context cannot run the script an SVG may carry.
|
||||
svg: "image/svg+xml",
|
||||
};
|
||||
|
||||
/** Extensions we are confident are text, so no byte sniffing is needed. */
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
"txt", "md", "markdown", "rst", "log", "csv", "tsv",
|
||||
"json", "jsonc", "yaml", "yml", "toml", "ini", "cfg", "conf", "env", "properties",
|
||||
"js", "jsx", "mjs", "cjs", "ts", "tsx", "rs", "py", "rb", "go", "java", "kt",
|
||||
"c", "h", "cc", "cpp", "hpp", "cs", "php", "swift", "scala", "lua", "pl", "r",
|
||||
"sh", "bash", "zsh", "fish", "ps1", "bat",
|
||||
"html", "htm", "xml", "svelte", "vue", "css", "scss", "sass", "less",
|
||||
"sql", "graphql", "gql", "proto", "diff", "patch", "lock", "gitignore",
|
||||
"dockerfile", "makefile", "cmake", "gradle", "tf", "tfvars",
|
||||
]);
|
||||
|
||||
/** Extensionless files that are text by convention. */
|
||||
const TEXT_BASENAMES = new Set([
|
||||
"dockerfile", "makefile", "readme", "license", "licence", "changelog",
|
||||
"authors", "notice", "copying", "procfile", "rakefile", "gemfile", "vagrantfile",
|
||||
// Dotfiles: the leading dot is stripped before the lookup.
|
||||
"gitignore", "gitattributes", "gitmodules", "dockerignore", "npmrc", "nvmrc",
|
||||
"editorconfig", "bashrc", "zshrc", "profile", "env",
|
||||
]);
|
||||
|
||||
/** 1 MiB of text is already far more than anyone reads in a modal. */
|
||||
export const TEXT_PREVIEW_LIMIT = 1024 * 1024;
|
||||
/**
|
||||
* Images get five times the budget: they are the file kind that routinely
|
||||
* blows past a text-sized cap, and a half-read image is not a preview at all —
|
||||
* it either decodes whole or it does not.
|
||||
*/
|
||||
export const IMAGE_PREVIEW_LIMIT = 5 * 1024 * 1024;
|
||||
|
||||
/** Lowercased extension, or "" for an extensionless name. */
|
||||
export function extensionOf(name: string): string {
|
||||
const base = name.slice(name.lastIndexOf("/") + 1);
|
||||
const dot = base.lastIndexOf(".");
|
||||
// A leading dot is "hidden file", not "extension" (`.gitignore`).
|
||||
if (dot <= 0) return "";
|
||||
return base.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/** The MIME to build the Blob with, or null if this is not a previewable image. */
|
||||
export function imageMimeFor(name: string): string | null {
|
||||
return IMAGE_MIME[extensionOf(name)] ?? null;
|
||||
}
|
||||
|
||||
export type PreviewKind = "image" | "text" | "unknown";
|
||||
|
||||
/**
|
||||
* A first guess from the name alone. `unknown` is not a refusal — the viewer
|
||||
* reads the bytes and falls back to sniffing them, so a `.bak` of a config
|
||||
* file still previews.
|
||||
*/
|
||||
export function previewKind(name: string): PreviewKind {
|
||||
if (imageMimeFor(name)) return "image";
|
||||
const ext = extensionOf(name);
|
||||
if (ext) return TEXT_EXTENSIONS.has(ext) ? "text" : "unknown";
|
||||
const base = name.slice(name.lastIndexOf("/") + 1).replace(/^\./, "").toLowerCase();
|
||||
return TEXT_BASENAMES.has(base) ? "text" : "unknown";
|
||||
}
|
||||
|
||||
/** How many bytes to ask the backend for, given what we expect to render. */
|
||||
export function previewLimit(name: string): number {
|
||||
return previewKind(name) === "image" ? IMAGE_PREVIEW_LIMIT : TEXT_PREVIEW_LIMIT;
|
||||
}
|
||||
|
||||
/** Base64 → bytes. `atob` yields a binary string; widen it one char at a time. */
|
||||
export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The classic heuristic: a NUL byte early on means this is not text. Cheap,
|
||||
* and it is what `git` and `grep` use to decide the same question.
|
||||
*/
|
||||
export function looksBinary(bytes: Uint8Array): boolean {
|
||||
const limit = Math.min(bytes.length, 8000);
|
||||
for (let i = 0; i < limit; i++) if (bytes[i] === 0) return true;
|
||||
return false;
|
||||
}
|
||||