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 | ||
|
|
f68d9c5788 | ||
|
|
bd72781482 |
@@ -1,23 +1,78 @@
|
|||||||
name: Build App (Preview)
|
name: Build App (Preview)
|
||||||
|
|
||||||
# Builds the Tauri app for branches other than main and exposes the bundles as
|
# Builds the Tauri app for branches other than main and publishes the bundles as
|
||||||
# workflow artifacts. No Gitea release, no GitHub sync — intended for local
|
# a **prerelease**, so they are downloadable from the Releases page. No GitHub
|
||||||
# smoke-testing of feature branches before they merge.
|
# sync.
|
||||||
#
|
#
|
||||||
# The uploads pin actions/upload-artifact@v3 and must not be "upgraded". v4
|
# This is also the **PR build check**: it compiles Linux, macOS and Windows, so
|
||||||
# bundles @actions/artifact v2, which refuses to run before making a single
|
# a push that breaks any of them fails here. build-app.yml used to do that job
|
||||||
# request whenever GITHUB_SERVER_URL is not github.com:
|
# 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
|
# ## Why not workflow artifacts
|
||||||
# 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
|
# Two attempts failed before this one, and both failure modes are worth knowing:
|
||||||
# #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
|
# * `actions/upload-artifact@v4` cannot run here at all. It bundles
|
||||||
# Gitea releases API instead — see build-app.yml.
|
# `@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:
|
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:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -25,24 +80,150 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.VERSION }}
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fetch all tags
|
||||||
|
run: git fetch --tags
|
||||||
|
|
||||||
- name: Compute preview version
|
- name: Compute preview version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
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 "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "SUFFIX=${SUFFIX}" >> $GITHUB_OUTPUT
|
||||||
echo "Computed preview version: ${VERSION}"
|
echo "Computed preview version: ${VERSION}"
|
||||||
|
|
||||||
|
# One release, created once. The three build jobs run concurrently, so
|
||||||
|
# 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:
|
build-linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [compute-version]
|
needs: [compute-version, create-release]
|
||||||
steps:
|
steps:
|
||||||
- name: Install Node.js 22
|
- name: Install Node.js 22
|
||||||
run: |
|
run: |
|
||||||
@@ -129,6 +310,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Tauri app
|
- name: Build Tauri app
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
env:
|
||||||
|
# Baked into the binary via `option_env!` in `get_app_version()` —
|
||||||
|
# the bundle version above stays bare (WiX/MSI's ProductVersion has
|
||||||
|
# no room for a suffix), so this is the only place a preview build
|
||||||
|
# can still tell itself apart from a production one. See
|
||||||
|
# triple-c#32.
|
||||||
|
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
npx tauri build
|
npx tauri build
|
||||||
@@ -141,18 +329,47 @@ jobs:
|
|||||||
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
|
||||||
ls -la artifacts/
|
ls -la artifacts/
|
||||||
|
|
||||||
# v3, not v4, and it must stay v3 — see the note at the top of this file.
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||||
- name: Upload Linux artifacts
|
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||||
uses: actions/upload-artifact@v3
|
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||||
with:
|
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||||
name: triple-c-${{ needs.compute-version.outputs.version }}-linux
|
- name: Upload Linux bundles to the preview release
|
||||||
path: artifacts/
|
shell: bash
|
||||||
if-no-files-found: error
|
env:
|
||||||
retention-days: 14
|
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:
|
build-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
needs: [compute-version]
|
needs: [compute-version, create-release]
|
||||||
steps:
|
steps:
|
||||||
- name: Install Node.js 22
|
- name: Install Node.js 22
|
||||||
run: |
|
run: |
|
||||||
@@ -212,6 +429,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Tauri app (universal)
|
- name: Build Tauri app (universal)
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
env:
|
||||||
|
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||||
|
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.cargo/bin:$PATH"
|
export PATH="$HOME/.cargo/bin:$PATH"
|
||||||
npx tauri build --target universal-apple-darwin
|
npx tauri build --target universal-apple-darwin
|
||||||
@@ -223,17 +443,47 @@ jobs:
|
|||||||
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
|
||||||
ls -la artifacts/
|
ls -la artifacts/
|
||||||
|
|
||||||
- name: Upload macOS artifacts
|
# Assets, not workflow artifacts — see the note at the top of this file.
|
||||||
uses: actions/upload-artifact@v3 # v3 deliberately — see the top of this file
|
# Delete-then-upload so a re-dispatch replaces rather than 409s, and the
|
||||||
with:
|
# retry/http1.1 hardening that build-app.yml learned from real macOS
|
||||||
name: triple-c-${{ needs.compute-version.outputs.version }}-macos
|
# upload failures (curl exit 92 and exit 28 mid-stream).
|
||||||
path: artifacts/
|
- name: Upload macOS bundles to the preview release
|
||||||
if-no-files-found: error
|
shell: bash
|
||||||
retention-days: 14
|
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:
|
build-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
needs: [compute-version]
|
needs: [compute-version, create-release]
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: cmd
|
shell: cmd
|
||||||
@@ -310,6 +560,8 @@ jobs:
|
|||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
env:
|
env:
|
||||||
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
|
||||||
|
# See the matching comment on the Linux job's "Build Tauri app" step.
|
||||||
|
TRIPLE_C_BUILD_SUFFIX: ${{ needs.compute-version.outputs.suffix }}
|
||||||
run: |
|
run: |
|
||||||
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
|
||||||
cargo tauri build
|
cargo tauri build
|
||||||
@@ -322,10 +574,78 @@ jobs:
|
|||||||
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
|
||||||
dir artifacts\
|
dir artifacts\
|
||||||
|
|
||||||
- name: Upload Windows artifacts
|
# PowerShell, because this job's default shell is cmd. Same
|
||||||
uses: actions/upload-artifact@v3 # v3 deliberately — see the top of this file
|
# delete-then-upload shape as the other two.
|
||||||
with:
|
- name: Upload Windows bundles to the preview release
|
||||||
name: triple-c-${{ needs.compute-version.outputs.version }}-windows
|
shell: powershell
|
||||||
path: artifacts/
|
env:
|
||||||
if-no-files-found: error
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
retention-days: 14
|
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/**"
|
- "app/**"
|
||||||
- "VERSION"
|
- "VERSION"
|
||||||
- ".gitea/workflows/build-app.yml"
|
- ".gitea/workflows/build-app.yml"
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
paths:
|
|
||||||
- "app/**"
|
|
||||||
- "VERSION"
|
|
||||||
- ".gitea/workflows/build-app.yml"
|
|
||||||
workflow_dispatch:
|
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:
|
env:
|
||||||
GITEA_URL: ${{ gitea.server_url }}
|
GITEA_URL: ${{ gitea.server_url }}
|
||||||
REPO: ${{ gitea.repository }}
|
REPO: ${{ gitea.repository }}
|
||||||
@@ -39,16 +39,55 @@ jobs:
|
|||||||
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||||
echo "Major.Minor: ${MAJOR_MINOR}"
|
echo "Major.Minor: ${MAJOR_MINOR}"
|
||||||
|
|
||||||
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
|
# The patch number is **one past the highest patch already used**, and
|
||||||
# `|| true` so an empty grep result doesn't fail the step under pipefail.
|
# never a distance.
|
||||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
|
#
|
||||||
|
# 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
|
# A re-run of a commit that already released must not mint a new
|
||||||
echo "Latest matching tag: ${LATEST_TAG}"
|
# version just because its own tag now exists.
|
||||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
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
|
else
|
||||||
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
|
# A minor line nobody has tagged yet is a *new* line, and a new line
|
||||||
PATCH=$(git rev-list --count HEAD)
|
# 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
|
fi
|
||||||
|
|
||||||
VERSION="${MAJOR_MINOR}.${PATCH}"
|
VERSION="${MAJOR_MINOR}.${PATCH}"
|
||||||
@@ -161,21 +200,70 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
TAG="v${{ needs.compute-version.outputs.version }}"
|
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}" \
|
||||||
|
"${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 "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C ${TAG} (Linux)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
-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
|
"${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]*')
|
;;
|
||||||
|
*)
|
||||||
|
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}"
|
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
|
for file in artifacts/*; do
|
||||||
[ -f "$file" ] || continue
|
[ -f "$file" ] || continue
|
||||||
filename=$(basename "$file")
|
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}..."
|
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 "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
--data-binary "@${file}" \
|
--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/
|
app/src-tauri/target/
|
||||||
Screenshot*.png
|
Screenshot*.png
|
||||||
code-review.md
|
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:
|
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
|
||||||
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
|
||||||
modals — see "UI conventions" below.
|
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.**
|
- **`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`,
|
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
|
||||||
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
|
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
|
||||||
@@ -126,6 +181,23 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
viewer that no longer exists. It closes with `destroy()`, never `close()`, to stay clear of
|
viewer that no longer exists. It closes with `destroy()`, never `close()`, to stay clear of
|
||||||
`CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive*
|
`CloseRequested`. The pane drops its iframe while popped out — two viewers can both *drive*
|
||||||
the browser.
|
the browser.
|
||||||
|
- **`page.rs` opens a page, which is the one thing the pane could not do.** A URL plus a
|
||||||
|
viewport: launch a browser in the container, `browser.bind()` it so the pane shows it, and
|
||||||
|
keep the handle. Serves 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. **Verified: a second client cannot join a bound browser** —
|
||||||
|
`chromium.connect()` against the published endpoint times out in every URL form, because that
|
||||||
|
socket speaks the dashboard's transport, not the public connect protocol. So whoever launches
|
||||||
|
is the only process that can drive, which is why the helper is resident and why live resize
|
||||||
|
applies to pages *we* opened and never to `@playwright/mcp`'s (those take `--viewport-size` /
|
||||||
|
`PLAYWRIGHT_MCP_VIEWPORT_SIZE` at launch). Control is a polled JSON file in `/tmp` — no port,
|
||||||
|
no second listener — and a re-open 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 (measured
|
||||||
|
against a `@media (max-width: 900px)` rule), and match-window mode pushes the pop-out's
|
||||||
|
settled `Resized` size into it — debounced by generation counter, since a drag emits
|
||||||
|
continuously and each one costs a container exec.
|
||||||
- **`lib.rs`'s `on_window_event` fires for every window and must stay guarded on
|
- **`lib.rs`'s `on_window_event` fires for every window and must stay guarded on
|
||||||
`label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every
|
`label() == "main"`.** Without that guard, closing a pop-out runs the app's shutdown: every
|
||||||
container stopped, process exited.
|
container stopped, process exited.
|
||||||
@@ -186,7 +258,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
### Container (`container/`)
|
### 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
|
- **`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
|
- **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
|
`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
|
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
|
||||||
@@ -256,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
|
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.
|
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
|
### 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.
|
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.
|
||||||
@@ -368,6 +525,158 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
|
|||||||
`models/project.rs` for anything that should default to true.
|
`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
|
- 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
|
## Testing
|
||||||
|
|
||||||
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code
|
|||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Installation](#installation)
|
||||||
- [Prerequisites](#prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
- [First Launch](#first-launch)
|
- [First Launch](#first-launch)
|
||||||
- [The Interface](#the-interface)
|
- [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
|
## Prerequisites
|
||||||
|
|
||||||
### Docker
|
### 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.
|
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.
|
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
|
> If the login hangs after the browser step, the callback could not reach the container. Either
|
||||||
> [Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project.
|
> 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:**
|
**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 |
|
| **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) |
|
| **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) |
|
| **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) |
|
| **Browser** | Watch — and take over — the browser Claude is driving with Playwright, see [The Browser Tab](#the-browser-tab) |
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
@@ -280,11 +301,35 @@ Press **Start browser view** and the pane fills with Playwright's own dashboard,
|
|||||||
container and reached over a token-gated listener on your machine's loopback address. Nothing is
|
container and reached over a token-gated listener on your machine's loopback address. Nothing is
|
||||||
exposed off the machine.
|
exposed off the machine.
|
||||||
|
|
||||||
|
#### Opening a page yourself
|
||||||
|
|
||||||
|
**Open a page…** launches a browser inside the container at a URL and viewport you choose, and
|
||||||
|
publishes it to this pane. Two uses:
|
||||||
|
|
||||||
|
- **A sign-in page.** The callback the tool is waiting for is a listener *inside* the container, so
|
||||||
|
a container-side browser completes the login without anything crossing to your host browser.
|
||||||
|
When a long URL appears in a terminal, the prompt that offers to open it on your host now also
|
||||||
|
offers **In container**, which does the same thing in one click.
|
||||||
|
- **A dev server.** `http://localhost:5173` inside the container is reachable with no port mapping
|
||||||
|
and nothing exposed to your network — which is how you watch a UI Claude is building, and click
|
||||||
|
around it yourself.
|
||||||
|
|
||||||
|
The **viewport** is the page's own resolution, and it is not the same thing as the window size.
|
||||||
|
The pane shows a video of the browser, so a bigger window draws the same pixels larger; changing
|
||||||
|
the viewport is what makes the layout actually reflow. Pick a preset or type a size.
|
||||||
|
|
||||||
|
Note the limit, because it is not obvious: a browser Claude opened through `@playwright/mcp` can
|
||||||
|
be *watched* but not resized — a published browser admits only the client that launched it. Set
|
||||||
|
its size with `PLAYWRIGHT_MCP_VIEWPORT_SIZE=1920x1080` in the project's environment variables
|
||||||
|
instead.
|
||||||
|
|
||||||
#### Watching it while you work
|
#### Watching it while you work
|
||||||
|
|
||||||
Press **Open in own window** and the view moves out of the tab into a window of its own — put it on
|
Press **Open in own window** and the view moves out of the tab into a window of its own — put it on
|
||||||
a second monitor, or turn on **Keep on top** and let it float above the app while you work in a
|
a second monitor, or turn on **Keep on top** and let it float above the app while you work in a
|
||||||
terminal. This is a window change only: the browser and the view keep running throughout, so
|
terminal. **Match window** goes further: the page's viewport follows the window as you drag it, so
|
||||||
|
the pop-out becomes a responsive-design ruler. It applies to pages opened with **Open a page…**,
|
||||||
|
for the reason above. This is a window change only: the browser and the view keep running throughout, so
|
||||||
popping out and back costs nothing and interrupts nothing.
|
popping out and back costs nothing and interrupts nothing.
|
||||||
|
|
||||||
While the view is in its own window the tab shows a placeholder rather than a second copy of it —
|
While the view is in its own window the tab shows a placeholder rather than a second copy of it —
|
||||||
@@ -324,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 |
|
| **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 |
|
| **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) |
|
| **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) |
|
| **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 |
|
| **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 |
|
| **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 |
|
||||||
@@ -447,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.
|
> 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
|
### Mission Control
|
||||||
|
|
||||||
Toggle **Mission Control** to integrate Flight Control — an AI-first development methodology bundled with Triple-C — into the project. When enabled:
|
Toggle **Mission Control** to integrate Flight Control — an AI-first development methodology bundled with Triple-C — into the project. When enabled:
|
||||||
@@ -501,18 +633,27 @@ The **Claude Code settings** editor, also at the bottom of the Config tab, confi
|
|||||||
|
|
||||||
| Setting | What It Does |
|
| Setting | What It Does |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| **TUI Mode** | Set to **Fullscreen** for flicker-free alt-screen rendering (uses `CLAUDE_CODE_NO_FLICKER=1`) |
|
| **TUI Mode** | **Automatic** lets Claude Code choose; **Classic** pins the main-screen renderer; **Fullscreen** pins the flicker-free alt-screen one |
|
||||||
| **Effort Level** | Controls reasoning depth: **Low** (fast, less thorough), **Medium**, **High** (deep reasoning) |
|
| **Effort Level** | Reasoning depth: **Low**, **Medium**, **High**, **Extra high** |
|
||||||
| **Focus Mode** | Collapses tool output to one-line summaries, showing only the prompt and final response |
|
| **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 process as summaries during responses |
|
| **Thinking Summaries** | Shows Claude's thinking as summaries rather than a collapsed stub |
|
||||||
| **Session Recap** | Provides context when returning to a session after being away |
|
| **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 Disabled** | Disables auto-scroll when in fullscreen TUI mode |
|
| **Auto-Scroll** | Follows new output to the bottom in fullscreen rendering. On by default |
|
||||||
| **Env Scrub** | Strips credentials from subprocess environments for security |
|
| **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
|
### MCP Servers
|
||||||
|
|
||||||
@@ -678,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**.
|
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
|
### What it does
|
||||||
|
|
||||||
- Every couple of seconds it looks inside the container for programs listening on the container's
|
- Every couple of seconds it looks inside the container for programs listening on the container's
|
||||||
@@ -1034,14 +1188,61 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
|
|||||||
|
|
||||||
### Files
|
### 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
|
- **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
|
Double-click a folder to open it, or the `..` row to go up; the arrow keys, Home and End move
|
||||||
- **Upload file** from your host into the current container directory
|
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
|
- **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
|
### Terminal Rendering
|
||||||
|
|
||||||
@@ -1087,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
|
**Bypass** a task may stop early when Claude Code asks for approval; the run log records the mode
|
||||||
that was used.
|
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 quickest route is the **New task** button on a project's **Automation** tab, which gives you a
|
||||||
the commands yourself in a **Shell** session, or just ask Claude to do it.
|
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
|
### Create a Recurring Task
|
||||||
|
|
||||||
@@ -1115,13 +1320,23 @@ triple-c-scheduler list # List all tasks
|
|||||||
triple-c-scheduler enable --id abc123 # Enable a task
|
triple-c-scheduler enable --id abc123 # Enable a task
|
||||||
triple-c-scheduler disable --id abc123 # Disable a task
|
triple-c-scheduler disable --id abc123 # Disable a task
|
||||||
triple-c-scheduler remove --id abc123 # Delete 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 --id abc123 # View logs for a task
|
||||||
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
|
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
|
||||||
triple-c-scheduler notifications # View completion notifications
|
triple-c-scheduler notifications # View completion notifications
|
||||||
triple-c-scheduler notifications --clear # Clear 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
|
### Cron Schedule Format
|
||||||
|
|
||||||
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
||||||
@@ -1172,9 +1387,19 @@ triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run test
|
|||||||
| **Ctrl+Shift+V** | Paste |
|
| **Ctrl+Shift+V** | Paste |
|
||||||
| **Ctrl+V** | Paste an image from the clipboard into the container |
|
| **Ctrl+V** | Paste an image from the clipboard into the container |
|
||||||
| **Ctrl+Shift+M** | Toggle speech-to-text recording (when enabled) |
|
| **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.
|
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
|
## What's Inside the Container
|
||||||
@@ -1191,6 +1416,7 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
|||||||
| ruff | Latest | Python linter/formatter |
|
| ruff | Latest | Python linter/formatter |
|
||||||
| Rust | Stable | Rust development (via rustup) |
|
| Rust | Stable | Rust development (via rustup) |
|
||||||
| Docker CLI | Latest | Container management (when spawning is enabled) |
|
| 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 |
|
| git | Latest | Version control |
|
||||||
| GitHub CLI (gh) | Latest | GitHub integration |
|
| GitHub CLI (gh) | Latest | GitHub integration |
|
||||||
| AWS CLI | v2 | AWS services and Bedrock |
|
| AWS CLI | v2 | AWS services and Bedrock |
|
||||||
@@ -1280,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
|
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
|
callback from your browser is landing on your host's `localhost` while the CLI is listening on the
|
||||||
*container's*. Enable the
|
*container's*.
|
||||||
[Auth Bridge](#browser-logins-inside-the-container-auth-bridge) for that project and try again.
|
|
||||||
|
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
|
For Claude specifically, the simpler answer is usually
|
||||||
[Shared Claude Authentication](#shared-claude-authentication), which finishes on an Anthropic-hosted
|
[Shared Claude Authentication](#shared-claude-authentication), which finishes on an Anthropic-hosted
|
||||||
@@ -1318,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.
|
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 (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.
|
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
|
## Architecture
|
||||||
|
|
||||||
- **Frontend**: React 19 + TypeScript + Tailwind CSS v4 + Zustand state management
|
- **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
|
separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one
|
||||||
thing is current at a time.
|
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
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
|
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+Shift+W` | Close the active tab |
|
||||||
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
|
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
|
||||||
| `Ctrl+1` … `Ctrl+9` | Jump to the nth tab |
|
| `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
|
`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`,
|
terminal this app is built around. Plain `Ctrl+←/→` is readline's word-wise cursor motion, which is
|
||||||
`Ctrl+Shift+M`) are handled in `TerminalView.tsx`.
|
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
|
### Project Home
|
||||||
|
|
||||||
Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project
|
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
|
view, with tabs **Overview · Sessions · Automation · Config · Files · Browser**. The sidebar row
|
||||||
select-only (plus hover controls for start/stop and opening a terminal); it holds no configuration.
|
itself is select-only (plus hover controls for start/stop and opening a terminal); it holds no
|
||||||
Per-project configuration lives in the Config tab rather than in modals.
|
configuration. Per-project configuration lives in the Config tab rather than in modals.
|
||||||
|
|
||||||
| Tab | Contents |
|
| 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** |
|
| **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) |
|
| **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
|
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
|
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
|
||||||
progress modal.
|
progress modal.
|
||||||
|
|
||||||
### Permission Modes
|
## Permission Modes
|
||||||
|
|
||||||
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
|
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
|
||||||
mapped to CLI flags by `PermissionMode::cli_args()`:
|
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
|
only reaches the scheduler after the container is recreated on its next start (the label mismatch
|
||||||
forces that).
|
forces that).
|
||||||
|
|
||||||
### Container Introspection (Capability Tiles)
|
## Containers
|
||||||
|
|
||||||
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
### Container Lifecycle
|
||||||
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.
|
|
||||||
|
|
||||||
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
|
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
|
||||||
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
|
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)
|
### 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
|
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.
|
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
|
Watch — and take over — the browser Claude is driving with Playwright inside the container. The
|
||||||
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
|
**Browser** tab runs Playwright's own dashboard (`browser.bind()` plus `playwright-cli show`) in the
|
||||||
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
|
container and fronts it with a **token-gated** loopback proxy on the host (`browser_view/`). Opt-in
|
||||||
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
|
per project.
|
||||||
anything resembling an `sk-ant-` secret.
|
|
||||||
|
|
||||||
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
|
- **It deliberately does not reuse the auth bridge's `PortForward`**, which binds an
|
||||||
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
|
unauthenticated port — fine for a throwaway OAuth listener, wrong for remote control of a browser.
|
||||||
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
|
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
|
### Host File Transfers
|
||||||
`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.
|
|
||||||
|
|
||||||
### 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
|
- **The OS dialogs are opened by Rust, not by the webview.** `upload_files_to_container` and
|
||||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, injects Claude Code settings, rebuilds the scheduler crontab
|
`download_container_file` drive `tauri-plugin-dialog` themselves and take nothing but a project
|
||||||
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
|
id and a container-side path; `FilesTab.tsx` imports no dialog plugin and `useFileManager`'s
|
||||||
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
|
`uploadFiles` takes no argument at all. The web UI can ask for a dialog, and that is the whole of
|
||||||
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
|
its influence over where a file comes from or goes — it cannot name a host path as an *input*.
|
||||||
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.
|
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 |
|
### Container Introspection (Capability Tiles)
|
||||||
|---|---|---|---|
|
|
||||||
| `/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 |
|
|
||||||
|
|
||||||
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
|
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
|
||||||
removal, and by nothing else.
|
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
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
### Mission Control Integration
|
### 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
|
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
|
||||||
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
|
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
|
||||||
- **Port**: Default `9876` (configurable)
|
- **Port**: Default `9876` (configurable)
|
||||||
|
- **Input device**: Selectable in Settings when the host exposes more than one microphone
|
||||||
- **Language**: Optional language hint for transcription
|
- **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
|
- **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
|
- **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.
|
**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
|
## Key Files
|
||||||
|
|
||||||
|
### Frontend — layout and projects
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
|
| `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/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/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/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/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/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/ProjectList.tsx` | Project list in sidebar |
|
||||||
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
|
| `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/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/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/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/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/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/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". |
|
||||||
| `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` |
|
### Frontend — settings, terminal and hooks
|
||||||
| `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 |
|
| 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/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/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/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/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/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/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/useClaudeAuth.ts` | Shared-token status and acquisition |
|
||||||
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
|
| `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/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/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/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/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/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/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/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_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/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/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/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, shared-token opt-out) |
|
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
|
||||||
| `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/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/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/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/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/storage/secure.rs` | OS keychain access (per-project secrets, shared token, gateway keys, rotation id) |
|
||||||
| `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) |
|
### Container and packaging
|
||||||
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
|
|
||||||
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
|
| File | Purpose |
|
||||||
| `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/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, Docker group config, Claude Code settings injection, Mission Control setup |
|
| `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/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 |
|
| `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/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-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-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 |
|
| `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
|
## CSS / Styling Notes
|
||||||
|
|
||||||
@@ -382,7 +633,7 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
|||||||
|
|
||||||
**Base**: Ubuntu 24.04
|
**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)
|
**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
|
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.
|
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)
|
**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)
|
## 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 |
|
| Key | Surfaced as |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `tui` | TUI Mode select (`fullscreen`) |
|
| `tui` | TUI mode select — unset (Claude Code chooses), `default` (classic renderer), `fullscreen` (flicker-free alt-screen). Three distinct states, not two. |
|
||||||
| `effort` | Effort Level select (`low`/`medium`/`high`) |
|
| `effortLevel` | Effort level select (`low`/`medium`/`high`/`xhigh`) |
|
||||||
| `autoScrollEnabled` | Auto-Scroll Disabled toggle |
|
| `viewMode` | Focus mode toggle, written as `"focus"`. Unset means the user's own `verbose` setting and sticky `/focus` choice still apply. |
|
||||||
| `focusMode` | Focus Mode toggle |
|
| `autoScrollEnabled` | Auto-scroll toggle. Claude Code's default is `true`, so it is the *off* state that writes `false`. |
|
||||||
| `showThinkingSummaries` | Thinking Summaries toggle |
|
| `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`) |
|
| `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`,
|
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_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,
|
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
|
||||||
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
|
||||||
|
|||||||
@@ -412,13 +412,18 @@ triple-c/
|
|||||||
│
|
│
|
||||||
├── .gitea/
|
├── .gitea/
|
||||||
│ └── workflows/
|
│ └── workflows/
|
||||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows); mirrors releases to GitHub inline
|
||||||
│ ├── build-app-preview.yml # Preview builds
|
│ ├── build-app-preview.yml # Preview builds
|
||||||
│ ├── build.yml # Build container image (multi-arch)
|
│ ├── build.yml # Build container image (multi-arch)
|
||||||
│ ├── build-stt.yml # Build the STT image
|
│ ├── build-stt.yml # Build the STT image
|
||||||
│ ├── sync-release.yml # Mirror releases to GitHub
|
|
||||||
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
│ ├── backfill-releases.yml # Bulk copy releases to GitHub
|
||||||
│ └── cleanup-releases.yml # Prune old releases
|
│ ├── cleanup-releases.yml # Prune old releases
|
||||||
|
│ └── publish-arch-package.yml # Build triple-c-bin, attach it to the GitHub release (packaging/arch/)
|
||||||
|
│
|
||||||
|
├── packaging/
|
||||||
|
│ └── arch/ # triple-c-bin Arch package — see packaging/arch/README.md
|
||||||
|
│ ├── PKGBUILD
|
||||||
|
│ └── README.md
|
||||||
│
|
│
|
||||||
└── app/ # Tauri v2 desktop application
|
└── app/ # Tauri v2 desktop application
|
||||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||||
@@ -436,7 +441,7 @@ triple-c/
|
|||||||
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
|
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
|
||||||
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
|
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
|
||||||
│ │ ├── useDocker.ts # Docker status, image build/pull
|
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||||
│ │ ├── useFileManager.ts # File browser operations
|
│ │ ├── useFileManager.ts # File browser operations + host transfers
|
||||||
│ │ ├── useInstallHelper.ts # Guided Docker installation
|
│ │ ├── useInstallHelper.ts # Guided Docker installation
|
||||||
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
|
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
|
||||||
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
|
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
|
||||||
@@ -464,7 +469,7 @@ triple-c/
|
|||||||
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
|
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
|
||||||
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
|
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
|
||||||
│ │ │ ├── ConfigTab.tsx # Config section host
|
│ │ │ ├── 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
|
│ │ │ ├── CapabilityTiles.tsx # Read-only capability counts
|
||||||
│ │ │ ├── format.ts # Age / size / uptime formatting
|
│ │ │ ├── format.ts # Age / size / uptime formatting
|
||||||
│ │ │ └── config/ # WorkspaceSection, ModelSection,
|
│ │ │ └── config/ # WorkspaceSection, ModelSection,
|
||||||
@@ -504,7 +509,7 @@ triple-c/
|
|||||||
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
|
||||||
│ ├── aws_commands.rs # AWS profile/region discovery
|
│ ├── aws_commands.rs # AWS profile/region discovery
|
||||||
│ ├── docker_commands.rs # Docker status, image ops
|
│ ├── 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
|
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
|
||||||
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
│ ├── inspect_commands.rs # Sessions, capabilities, scheduler tasks
|
||||||
│ ├── install_helper_commands.rs # Guided Docker installation
|
│ ├── install_helper_commands.rs # Guided Docker installation
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Triple-C</title>
|
<title>Triple-C</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
|
||||||
"@xterm/addon-fit": "^0.10",
|
"@xterm/addon-fit": "^0.10",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-webgl": "^0.18",
|
"@xterm/addon-webgl": "^0.18",
|
||||||
@@ -2001,15 +2000,6 @@
|
|||||||
"@tauri-apps/api": "^2.8.0"
|
"@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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "triple-c",
|
"name": "triple-c",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -9,13 +9,13 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"hooks": "git -C .. config core.hooksPath .githooks && echo \"pre-commit secret scan enabled\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||||
"@tauri-apps/plugin-store": "^2",
|
|
||||||
"@xterm/addon-fit": "^0.10",
|
"@xterm/addon-fit": "^0.10",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/addon-webgl": "^0.18",
|
"@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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aead"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes-gcm"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"aes",
|
||||||
|
"cipher",
|
||||||
|
"ctr",
|
||||||
|
"ghash",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -47,6 +82,18 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -280,6 +327,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -310,6 +363,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -569,6 +631,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cipher"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"inout",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -694,6 +766,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -753,6 +826,15 @@ version = "0.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.20.11"
|
version = "0.20.11"
|
||||||
@@ -923,6 +1005,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1135,7 +1218,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1550,6 +1633,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ghash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||||
|
dependencies = [
|
||||||
|
"opaque-debug",
|
||||||
|
"polyval",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gio"
|
name = "gio"
|
||||||
version = "0.18.4"
|
version = "0.18.4"
|
||||||
@@ -2114,6 +2207,15 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inout"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -2831,6 +2933,12 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.3.3"
|
version = "5.3.3"
|
||||||
@@ -2913,6 +3021,17 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -3194,6 +3313,18 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polyval"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"opaque-debug",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -3394,7 +3525,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3743,7 +3874,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4654,22 +4785,6 @@ dependencies = [
|
|||||||
"zbus",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -4782,7 +4897,7 @@ dependencies = [
|
|||||||
"getrandom 0.4.1",
|
"getrandom 0.4.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5163,8 +5278,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "triple-c"
|
name = "triple-c"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bollard",
|
"bollard",
|
||||||
@@ -5187,10 +5304,10 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-store",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5304,6 +5421,16 @@ version = "0.2.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "universal-hash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -5689,7 +5816,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "triple-c"
|
name = "triple-c"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
@@ -13,7 +13,6 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["image-png", "image-ico"] }
|
tauri = { version = "2", features = ["image-png", "image-ico"] }
|
||||||
tauri-plugin-store = "2"
|
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -37,6 +36,9 @@ tower-http = { version = "0.6", features = ["cors"] }
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
local-ip-address = "0.6"
|
local-ip-address = "0.6"
|
||||||
|
argon2 = "0.5"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
zeroize = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||||
|
|||||||
@@ -2473,180 +2473,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "opener:deny-reveal-item-in-dir",
|
"const": "opener:deny-reveal-item-in-dir",
|
||||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
"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",
|
"type": "string",
|
||||||
"const": "opener:deny-reveal-item-in-dir",
|
"const": "opener:deny-reveal-item-in-dir",
|
||||||
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
"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,
|
pub family: PortFamily,
|
||||||
/// RFC 3339 timestamp of when the host listener was bound.
|
/// RFC 3339 timestamp of when the host listener was bound.
|
||||||
pub bridged_at: String,
|
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.
|
/// A loopback listener that was discovered but could not be bridged.
|
||||||
@@ -132,6 +136,7 @@ impl BridgeState {
|
|||||||
port: f.port,
|
port: f.port,
|
||||||
family: f.family,
|
family: f.family,
|
||||||
bridged_at: f.bridged_at.clone(),
|
bridged_at: f.bridged_at.clone(),
|
||||||
|
ipv6_warning: f.ipv6_warning.clone(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
conflicts: self
|
conflicts: self
|
||||||
@@ -329,7 +334,10 @@ async fn poll_loop(
|
|||||||
Ok(text) => {
|
Ok(text) => {
|
||||||
exec_failures = 0;
|
exec_failures = 0;
|
||||||
let discovered = proc_net::parse_loopback_listeners(&text);
|
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 {
|
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||||
emit_status(&app, &project_id, &state, true).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
|
/// Every port this project's bridge must not take.
|
||||||
/// explicitly published has a host-side path already, and the mapping's host
|
|
||||||
/// port is a binding we must not fight over.
|
|
||||||
///
|
///
|
||||||
/// [`RESERVED_CONTAINER_PORTS`] is folded in as well: those are container
|
/// The bridge's rule is "a container loopback listener on port N becomes an
|
||||||
/// loopback listeners another feature owns and exposes on its own,
|
/// **unauthenticated** host listener on port N". That is only safe for ports
|
||||||
/// authenticated terms.
|
/// nothing else on the host owns, so everything that *is* owned has to be
|
||||||
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
/// 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
|
let mut skip: HashSet<u16> = project
|
||||||
.port_mappings
|
.port_mappings
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|m| [m.container_port, m.host_port])
|
.flat_map(|m| [m.container_port, m.host_port])
|
||||||
.collect();
|
.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_CONTAINER_PORTS.clone());
|
||||||
skip.extend(RESERVED_HOST_PORTS.clone());
|
skip.extend(RESERVED_HOST_PORTS.clone());
|
||||||
skip
|
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
|
// Reservations
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -586,7 +671,7 @@ async fn emit_status(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::models::{PortMapping, Project, ProjectPath};
|
use crate::models::{AppSettings, PortMapping, Project, ProjectPath};
|
||||||
|
|
||||||
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
||||||
let mut p = Project::new(
|
let mut p = Project::new(
|
||||||
@@ -607,9 +692,14 @@ mod tests {
|
|||||||
p
|
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]
|
#[test]
|
||||||
fn ports_already_published_by_docker_are_skipped() {
|
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));
|
assert!(skip.contains(&3000));
|
||||||
// Both ends of an asymmetric mapping are off limits: the container port
|
// Both ends of an asymmetric mapping are off limits: the container port
|
||||||
// is already reachable, and the host port is Docker's binding.
|
// is already reachable, and the host port is Docker's binding.
|
||||||
@@ -619,20 +709,96 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_mappings_means_nothing_but_the_reserved_ranges_are_skipped() {
|
fn no_mappings_means_nothing_but_the_reservations_are_skipped() {
|
||||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
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!(
|
assert_eq!(
|
||||||
skip.len(),
|
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]
|
#[test]
|
||||||
fn the_browser_views_host_ports_are_never_taken() {
|
fn the_browser_views_host_ports_are_never_taken() {
|
||||||
// The bridge binds *host* ports chosen by the container, so without
|
// The bridge binds *host* ports chosen by the container, so without
|
||||||
// this it can take the port the browser-view proxy will want later —
|
// this it can take the port the browser-view proxy will want later —
|
||||||
// that pane binds on demand, so first-come would win.
|
// 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 {
|
for port in RESERVED_HOST_PORTS {
|
||||||
assert!(skip.contains(&port), "host port {} should be reserved", port);
|
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
|
// Mirroring these would publish an ungated second door to the
|
||||||
// Playwright dashboard, which the pane deliberately keeps behind a
|
// Playwright dashboard, which the pane deliberately keeps behind a
|
||||||
// token-checking listener.
|
// 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 {
|
for port in RESERVED_CONTAINER_PORTS {
|
||||||
assert!(skip.contains(&port), "port {} should be reserved", port);
|
assert!(skip.contains(&port), "port {} should be reserved", port);
|
||||||
}
|
}
|
||||||
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
||||||
|
|
||||||
// Reservations coexist with Docker's own published ports.
|
// 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(RESERVED_CONTAINER_PORTS.start()));
|
||||||
assert!(skip.contains(&3000));
|
assert!(skip.contains(&3000));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,48 @@
|
|||||||
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||||
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||||
//! interactive terminal sessions are built on.
|
//! 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::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use bollard::container::LogOutput;
|
use bollard::container::LogOutput;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
@@ -30,6 +70,38 @@ use super::proc_net::PortFamily;
|
|||||||
/// only needs to not be pathological.
|
/// only needs to not be pathological.
|
||||||
const PUMP_BUF: usize = 16 * 1024;
|
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
|
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
||||||
/// child running.
|
/// child running.
|
||||||
struct AbortOnDrop(JoinHandle<()>);
|
struct AbortOnDrop(JoinHandle<()>);
|
||||||
@@ -52,6 +124,15 @@ pub struct PortForward {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub family: PortFamily,
|
pub family: PortFamily,
|
||||||
pub bridged_at: String,
|
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<()>,
|
task: JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,16 +167,31 @@ impl PortForward {
|
|||||||
// first, so a v4-only host listener would miss those callbacks. This is
|
// 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)
|
// 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.
|
// 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 {
|
let (v6, ipv6_warning) =
|
||||||
Ok(l) => Some(l),
|
match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
|
||||||
|
Ok(l) => (Some(l), None),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::debug!(
|
// Warn, not debug. Best-effort is about whether to *fail*,
|
||||||
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only",
|
// 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,
|
||||||
port,
|
port,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
None
|
(
|
||||||
|
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
|
||||||
|
)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,6 +202,7 @@ impl PortForward {
|
|||||||
port,
|
port,
|
||||||
family,
|
family,
|
||||||
bridged_at: chrono::Utc::now().to_rfc3339(),
|
bridged_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
ipv6_warning,
|
||||||
task,
|
task,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -142,6 +239,22 @@ async fn accept_loop(
|
|||||||
|
|
||||||
match accepted {
|
match accepted {
|
||||||
Ok((stream, peer)) => {
|
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);
|
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
||||||
let _ = stream.set_nodelay(true);
|
let _ = stream.set_nodelay(true);
|
||||||
conns.spawn(tunnel_connection(
|
conns.spawn(tunnel_connection(
|
||||||
@@ -170,9 +283,224 @@ async fn accept_optional(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Carry one accepted host connection into the container over `socat`.
|
/// Carry one accepted host connection into the container over `socat`, after
|
||||||
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
|
/// deciding it is not a web page reaching into loopback.
|
||||||
tunnel_connection_with_prelude(container_id, target, stream, port, Vec::new()).await
|
///
|
||||||
|
/// 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,
|
/// 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];
|
let mut buf = vec![0u8; PUMP_BUF];
|
||||||
loop {
|
loop {
|
||||||
match host_rx.read(&mut buf).await {
|
// Idle-bounded. Without this a client that connects, sends a
|
||||||
Ok(0) => break,
|
// request and then never speaks or closes holds the exec open for
|
||||||
Ok(n) => {
|
// 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() {
|
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Ok(Err(_)) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Container → host. This direction is authoritative: when the exec's output
|
// Container → host. This direction is authoritative: when the exec's output
|
||||||
// stream ends, socat has exited and the connection is over.
|
// stream ends, socat has exited and the connection is over. It is also the
|
||||||
while let Some(chunk) = output.next().await {
|
// 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 {
|
match chunk {
|
||||||
// Only stdout is payload. The exec is created with tty = false
|
// Only stdout is payload. The exec is created with tty = false
|
||||||
// precisely so Docker demultiplexes these, keeping socat's stderr
|
// 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.
|
// Explicit: stop reading from the host now that the container side is gone.
|
||||||
drop(upstream);
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
||||||
use crate::browser_view::{manager, popout, BrowserViewState, BrowserViewStatus};
|
use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
/// Turn the pane on or off for a project.
|
/// Turn the pane on or off for a project.
|
||||||
@@ -164,6 +164,153 @@ pub async fn set_browser_view_popout_always_on_top(
|
|||||||
popout::set_always_on_top(&app_handle, &project_id, on_top)
|
popout::set_always_on_top(&app_handle, &project_id, on_top)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a URL in a browser *inside* the container, published so the pane shows
|
||||||
|
/// it.
|
||||||
|
///
|
||||||
|
/// Two uses, one action: an auth URL — where the OAuth callback listener is in
|
||||||
|
/// the container too, so the loop closes without the host being involved at all
|
||||||
|
/// — and a dev server on container loopback, which is how you watch a UI Claude
|
||||||
|
/// is building.
|
||||||
|
///
|
||||||
|
/// The scheme allow-list mirrors the URL relay's: `http`/`https` only, so this
|
||||||
|
/// can never be talked into opening `file:` on the container's filesystem.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_page_in_container_browser(
|
||||||
|
project_id: String,
|
||||||
|
url: String,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
show_window: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<page::PageState, String> {
|
||||||
|
let trimmed = url.trim();
|
||||||
|
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
|
||||||
|
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?;
|
||||||
|
let opened = page::open(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
&container_id,
|
||||||
|
&detection,
|
||||||
|
trimmed,
|
||||||
|
page::Viewport::sane(width, height),
|
||||||
|
)
|
||||||
|
.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
|
||||||
|
/// every settled resize, so it is deliberately cheap: one control-file write.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_container_page_viewport(
|
||||||
|
project_id: String,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let container_id = running_container(&state, &project_id, "resizing the page").await?;
|
||||||
|
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of the page this opened, if any. Never fails: "no page" is an answer.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_container_page_state(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<page::PageState, String> {
|
||||||
|
let Ok(container_id) = running_container(&state, &project_id, "reading the page").await else {
|
||||||
|
return Ok(page::PageState::default());
|
||||||
|
};
|
||||||
|
Ok(page::state(&container_id).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the page this opened, leaving the view itself running.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn close_container_page(
|
||||||
|
project_id: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let container_id = running_container(&state, &project_id, "closing the page").await?;
|
||||||
|
page::close(&container_id).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the page track the pop-out window's size as it is dragged.
|
||||||
|
///
|
||||||
|
/// Only affects a page **this app opened**: a bound browser admits no second
|
||||||
|
/// client, so one `@playwright/mcp` launched keeps the viewport it was given.
|
||||||
|
/// Turning it on applies the window's current size immediately, so the toggle
|
||||||
|
/// has a visible effect without waiting for a drag.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_browser_view_match_window(
|
||||||
|
project_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
popout::set_match_window(&project_id, enabled);
|
||||||
|
if !enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some((width, height)) = popout::inner_size(&app_handle, &project_id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let container_id = running_container(&state, &project_id, "matching the window").await?;
|
||||||
|
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether match-window mode is on. Read on mount, like the rest of the
|
||||||
|
/// pop-out's state — the pane is unmounted whenever another sub-tab is shown.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_browser_view_match_window(project_id: String) -> Result<bool, String> {
|
||||||
|
Ok(popout::match_window(&project_id))
|
||||||
|
}
|
||||||
|
|
||||||
/// The project's container, or a sentence saying why there isn't one.
|
/// The project's container, or a sentence saying why there isn't one.
|
||||||
///
|
///
|
||||||
/// Every command here needs a *running* container, and every one of them used
|
/// Every command here needs a *running* container, and every one of them used
|
||||||
|
|||||||
@@ -93,6 +93,32 @@ pub struct PlaywrightDetection {
|
|||||||
/// user's own scripts and not for the MCP plugin.
|
/// user's own scripts and not for the MCP plugin.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub chrome_channel: Option<String>,
|
pub chrome_channel: Option<String>,
|
||||||
|
/// The Chromium binary the *resolved* Playwright would launch, asked of the
|
||||||
|
/// build itself rather than derived from the cache listing.
|
||||||
|
#[serde(default)]
|
||||||
|
pub chromium_executable: Option<String>,
|
||||||
|
/// Whether that binary is actually on disk.
|
||||||
|
///
|
||||||
|
/// False with a non-empty [`Self::browsers`] is the revision-skew case: two
|
||||||
|
/// Playwright copies in one container pin different revisions, so the cache
|
||||||
|
/// can be full of browsers and every launch still fail.
|
||||||
|
#[serde(default)]
|
||||||
|
pub chromium_executable_exists: bool,
|
||||||
|
/// The version a *script's* `require("playwright")` resolves to.
|
||||||
|
///
|
||||||
|
/// Tracked separately from [`Self::playwright_version`] because they are
|
||||||
|
/// routinely different in one directory: `@playwright/cli` pins its own
|
||||||
|
/// `playwright-core`, npm hoists that, and a separately-installed
|
||||||
|
/// `playwright` then nests a second core beside it. The viewer uses one,
|
||||||
|
/// Claude's scripts use the other.
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_playwright_version: Option<String>,
|
||||||
|
/// The Chromium that copy would launch, and whether it is there. This is
|
||||||
|
/// the pair that decides whether a script Claude writes actually runs.
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_chromium_executable: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_chromium_executable_exists: bool,
|
||||||
/// Where the probe looked, echoed back for the "not found" message.
|
/// Where the probe looked, echoed back for the "not found" message.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub searched: Vec<String>,
|
pub searched: Vec<String>,
|
||||||
@@ -155,13 +181,82 @@ impl PlaywrightDetection {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The revision-skew sentence, for the pane's browser step.
|
||||||
|
///
|
||||||
|
/// Separate from [`Self::blocker`] because it does not block the *viewer* —
|
||||||
|
/// the dashboard runs fine; it is the browser that cannot start. Names both
|
||||||
|
/// halves, because "install a browser" over a cache that visibly already
|
||||||
|
/// has one reads as nonsense without them.
|
||||||
|
pub fn skew_message(&self) -> Option<String> {
|
||||||
|
if !self.revision_skew() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Which half is broken changes what the user sees, so say the one that
|
||||||
|
// is. The scripts case is the one that looks like a lie: the pane is
|
||||||
|
// green, the viewer works, and every script Claude writes dies.
|
||||||
|
if self.scripts_cannot_launch() {
|
||||||
|
return Some(format!(
|
||||||
|
"This container has {}, and the viewer works — but `require(\"playwright\")` \
|
||||||
|
resolves Playwright {}, which launches {}. That file isn't there, so every \
|
||||||
|
script Claude writes fails with “Executable doesn't exist”. Two copies ended \
|
||||||
|
up in one tree: `@playwright/cli` pins its own `playwright-core`, and a \
|
||||||
|
separately-installed `playwright` nests a second one beside it. “Set up \
|
||||||
|
Playwright” below reinstalls them as one consistent set.",
|
||||||
|
self.browsers.join(", "),
|
||||||
|
self.script_playwright_version.as_deref().unwrap_or("?"),
|
||||||
|
self.script_chromium_executable.as_deref().unwrap_or("?"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(format!(
|
||||||
|
"This container has {}, but Playwright {} launches {} — which isn't there, so \
|
||||||
|
every `chromium.launch()` fails with “Executable doesn't exist”. That happens \
|
||||||
|
when two Playwright copies share a container (typically an npx `@playwright/mcp` \
|
||||||
|
alongside this one); each pins its own browser revision. “Install Chromium” below \
|
||||||
|
fetches the revision this build needs — it runs that build's own installer, so it \
|
||||||
|
cannot pick the wrong one again.",
|
||||||
|
self.browsers.join(", "),
|
||||||
|
self.playwright_version.as_deref().unwrap_or("?"),
|
||||||
|
self.chromium_executable.as_deref().unwrap_or("?"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether Playwright is present but has no browser at all to drive —
|
/// Whether Playwright is present but has no browser at all to drive —
|
||||||
/// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer
|
/// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer
|
||||||
/// still runs, it just has nothing to show until a browser is bound.
|
/// still runs, it just has nothing to show until a browser is bound.
|
||||||
pub fn needs_browser(&self) -> bool {
|
pub fn needs_browser(&self) -> bool {
|
||||||
self.playwright_version.is_some()
|
self.playwright_version.is_some()
|
||||||
&& self.browsers.is_empty()
|
|
||||||
&& self.chrome_channel.is_none()
|
&& self.chrome_channel.is_none()
|
||||||
|
&& (self.browsers.is_empty() || self.revision_skew())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browsers are installed, but not the revision this Playwright launches.
|
||||||
|
///
|
||||||
|
/// The container looks equipped and every `chromium.launch()` fails with
|
||||||
|
/// "Executable doesn't exist". It happens whenever two Playwright copies
|
||||||
|
/// share a container — the npx `@playwright/mcp` one and a `/workspace`
|
||||||
|
/// one — because each pins its own revision and installs into the same
|
||||||
|
/// cache. The install action fixes it: it runs the *resolved* build's own
|
||||||
|
/// CLI, so it fetches exactly the revision that was missing.
|
||||||
|
///
|
||||||
|
/// Requires the probe to have answered: an older container image, or a
|
||||||
|
/// Playwright too broken to `require`, leaves `chromium_executable` unset,
|
||||||
|
/// and "didn't answer" must not read as "skewed".
|
||||||
|
pub fn revision_skew(&self) -> bool {
|
||||||
|
!self.browsers.is_empty() && (self.viewer_cannot_launch() || self.scripts_cannot_launch())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The copy serving the viewer would not find its browser.
|
||||||
|
fn viewer_cannot_launch(&self) -> bool {
|
||||||
|
self.chromium_executable.is_some() && !self.chromium_executable_exists
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `require("playwright")` — what every script Claude writes uses — would
|
||||||
|
/// not find its browser. Independent of the above, and the more common of
|
||||||
|
/// the two: `@playwright/cli` pins a `playwright-core`, npm hoists it, and
|
||||||
|
/// a separately-installed `playwright` nests a second one that no browser
|
||||||
|
/// was ever downloaded for.
|
||||||
|
fn scripts_cannot_launch(&self) -> bool {
|
||||||
|
self.script_chromium_executable.is_some() && !self.script_chromium_executable_exists
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The searched roots as prose, so a message never trails off into "Looked
|
/// The searched roots as prose, so a message never trails off into "Looked
|
||||||
@@ -277,6 +372,30 @@ const PROBE: &str = concat!(
|
|||||||
// the pane claim a browser is present when none is.
|
// the pane claim a browser is present when none is.
|
||||||
r#"try{const bd=process.env.PLAYWRIGHT_BROWSERS_PATH||(home?path.join(home,".cache","ms-playwright"):null);"#,
|
r#"try{const bd=process.env.PLAYWRIGHT_BROWSERS_PATH||(home?path.join(home,".cache","ms-playwright"):null);"#,
|
||||||
r#"if(bd)out.browsers=fs.readdirSync(bd).filter((n)=>/^(chromium|firefox|webkit)/.test(n)).sort();}catch(e){}"#,
|
r#"if(bd)out.browsers=fs.readdirSync(bd).filter((n)=>/^(chromium|firefox|webkit)/.test(n)).sort();}catch(e){}"#,
|
||||||
|
// What this Playwright would *actually launch*, and whether it is there.
|
||||||
|
//
|
||||||
|
// A cache listing is not the same question. Two Playwright copies in one
|
||||||
|
// container — the npx `@playwright/mcp` one and a `/workspace` one — pin
|
||||||
|
// different browser revisions, and each installs its own. So the cache can
|
||||||
|
// hold `chromium-1237` while the resolved build wants `chromium-1234` and
|
||||||
|
// every `chromium.launch()` dies with "Executable doesn't exist", *while
|
||||||
|
// the pane reports a browser installed*. Asking the build itself sidesteps
|
||||||
|
// revision arithmetic entirely: this is the path a launch would use.
|
||||||
|
r#"const exe=(dir)=>{try{const bt=require(dir).chromium;"#,
|
||||||
|
r#"const ep=bt&&bt.executablePath?bt.executablePath():null;"#,
|
||||||
|
r#"return ep?[ep,fs.existsSync(ep)]:null;}catch(e){return null;}};"#,
|
||||||
|
r#"if(core){const r=exe(path.dirname(core));"#,
|
||||||
|
r#"if(r){out.chromium_executable=r[0];out.chromium_executable_exists=r[1];}}"#,
|
||||||
|
// And separately: what a *script* gets. `require("playwright")` is what
|
||||||
|
// every Playwright example writes, and it resolves the wrapper — which
|
||||||
|
// carries its own nested `playwright-core` whenever npm could not settle on
|
||||||
|
// one version. That copy can want a different browser revision than the one
|
||||||
|
// the viewer's copy installed, so it is asked its own question.
|
||||||
|
r#"try{const w=res("playwright/package.json");"#,
|
||||||
|
r#"if(w){const j=JSON.parse(fs.readFileSync(w,"utf8"));out.script_playwright_version=j.version;"#,
|
||||||
|
r#"const wc=at("playwright-core/package.json",path.dirname(w));"#,
|
||||||
|
r#"const r=exe(path.dirname(wc||w));"#,
|
||||||
|
r#"if(r){out.script_chromium_executable=r[0];out.script_chromium_executable_exists=r[1];}}}catch(e){}"#,
|
||||||
// The Chrome *channel* is an apt package, not a Playwright download, so it
|
// The Chrome *channel* is an apt package, not a Playwright download, so it
|
||||||
// is looked for where apt puts it.
|
// is looked for where apt puts it.
|
||||||
r#"try{for(const p of ["/usr/bin/google-chrome-stable","/usr/bin/google-chrome","/opt/google/chrome/chrome"]){"#,
|
r#"try{for(const p of ["/usr/bin/google-chrome-stable","/usr/bin/google-chrome","/opt/google/chrome/chrome"]){"#,
|
||||||
@@ -467,6 +586,66 @@ mod tests {
|
|||||||
assert!(PROBE.contains("/opt/google/chrome/chrome"), "{}", PROBE);
|
assert!(PROBE.contains("/opt/google/chrome/chrome"), "{}", PROBE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_probe_asks_playwright_what_it_would_launch() {
|
||||||
|
// Not derived from the cache listing — asked of the build, because the
|
||||||
|
// cache can hold a browser this build will never launch.
|
||||||
|
assert!(PROBE.contains("executablePath"), "{}", PROBE);
|
||||||
|
assert!(PROBE.contains("out.chromium_executable_exists"), "{}", PROBE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A container carrying browsers from a *different* Playwright copy.
|
||||||
|
fn skewed() -> PlaywrightDetection {
|
||||||
|
parse_probe_output(&payload(concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||||
|
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1237"],"#,
|
||||||
|
r#""chromium_executable":"/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome","#,
|
||||||
|
r#""chromium_executable_exists":false}"#,
|
||||||
|
)))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_browser_cache_full_of_the_wrong_revision_counts_as_no_browser() {
|
||||||
|
let d = skewed();
|
||||||
|
// The viewer still serves — it is the browser that cannot start.
|
||||||
|
assert!(d.is_usable());
|
||||||
|
assert_eq!(d.blocker(), None);
|
||||||
|
assert!(d.revision_skew());
|
||||||
|
assert!(d.needs_browser(), "a browser that cannot launch is not a browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_skew_message_names_both_revisions_and_the_way_out() {
|
||||||
|
let msg = skewed().skew_message().unwrap();
|
||||||
|
assert!(msg.contains("chromium-1237"), "{}", msg); // what is there
|
||||||
|
assert!(msg.contains("chromium-1234"), "{}", msg); // what it wants
|
||||||
|
assert!(msg.contains("Install Chromium"), "{}", msg); // what fixes it
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_chrome_channel_covers_a_skewed_cache() {
|
||||||
|
// The channel is an apt binary at a fixed path, so a revision mismatch
|
||||||
|
// cannot affect it: there is still something to drive.
|
||||||
|
let mut d = skewed();
|
||||||
|
d.chrome_channel = Some("/usr/bin/google-chrome-stable".to_string());
|
||||||
|
assert!(!d.needs_browser());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_probe_that_could_not_answer_is_not_reported_as_skew() {
|
||||||
|
// Older container, or a Playwright too broken to `require`: unset is
|
||||||
|
// "unknown", and unknown must never render as "your browsers are wrong".
|
||||||
|
let d = parse_probe_output(&payload(concat!(
|
||||||
|
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
|
||||||
|
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1237"]}"#,
|
||||||
|
)))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!d.revision_skew());
|
||||||
|
assert!(!d.needs_browser());
|
||||||
|
assert_eq!(d.skew_message(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_missing_viewer_package_is_reported_separately() {
|
fn a_missing_viewer_package_is_reported_separately() {
|
||||||
let d = parse_probe_output(&payload(
|
let d = parse_probe_output(&payload(
|
||||||
|
|||||||
@@ -73,14 +73,33 @@ use crate::docker::exec::{
|
|||||||
|
|
||||||
use super::detect::{self, PlaywrightDetection};
|
use super::detect::{self, PlaywrightDetection};
|
||||||
|
|
||||||
/// The two packages the pane genuinely needs, pinned to `@latest` because
|
/// The viewer package — installed **first**, and it decides the version of
|
||||||
/// `browser.bind()` is recent and the viewer tracks it.
|
/// `playwright` installed after it.
|
||||||
///
|
///
|
||||||
/// This is the *minimum* set. A user who followed the old guidance ended up
|
/// `@playwright/mcp` is deliberately not part of the set: it is Claude's MCP
|
||||||
/// with a global install as well as these; only these are required. Note what
|
/// configuration to make, and it contributes nothing to serving a viewer.
|
||||||
/// is not here: `@playwright/mcp` is Claude's MCP configuration to make, not
|
///
|
||||||
/// this pane's, and it contributes nothing to serving a viewer.
|
/// **Order matters here, and `playwright` is deliberately not `@latest`.**
|
||||||
pub const PACKAGES: [&str; 2] = ["playwright@latest", "@playwright/cli@latest"];
|
///
|
||||||
|
/// Installing both at `@latest` produces a tree that looks right and is broken.
|
||||||
|
/// Verified on a real container: `@playwright/cli@0.1.18` pins
|
||||||
|
/// `playwright-core@1.63.0-alpha`, npm hoists that to the root, and
|
||||||
|
/// `playwright@latest` (1.62.1) then nests its own `playwright-core@1.62.1`
|
||||||
|
/// beside it. The two cores want *different browser revisions*. The browser
|
||||||
|
/// step runs the resolved — hoisted — CLI, so it downloads 1237; every script
|
||||||
|
/// Claude writes says `require("playwright")`, gets the nested 1.62.1, and dies
|
||||||
|
/// with "Executable doesn't exist … chromium_headless_shell-1234". The pane
|
||||||
|
/// meanwhile reports a browser installed, because one is.
|
||||||
|
///
|
||||||
|
/// So the viewer package goes first and its own pinned `playwright` version is
|
||||||
|
/// what gets installed second — one core, one browser revision, both halves
|
||||||
|
/// agreeing. See [`pinned_playwright_spec`].
|
||||||
|
pub const VIEWER_PACKAGE: &str = "@playwright/cli@latest";
|
||||||
|
|
||||||
|
/// Fallback when the viewer's manifest can't be read: better a possibly-skewed
|
||||||
|
/// tree than no Playwright at all, and [`detect`](super::detect) reports the
|
||||||
|
/// skew either way.
|
||||||
|
pub const PLAYWRIGHT_FALLBACK: &str = "playwright@latest";
|
||||||
|
|
||||||
/// Where the packages are installed. Container storage, not a bind mount — see
|
/// Where the packages are installed. Container storage, not a bind mount — see
|
||||||
/// the module docs.
|
/// the module docs.
|
||||||
@@ -175,11 +194,24 @@ impl BrowserTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `channel` a launch check must pass. `None` means the bundled build.
|
/// Every `channel` a launch check must pass, comma-separated, where
|
||||||
fn channel(self) -> Option<&'static str> {
|
/// `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 {
|
match self {
|
||||||
Self::Chromium => None,
|
Self::Chromium => "default,chrome-for-testing",
|
||||||
Self::Chrome => Some("chrome"),
|
Self::Chrome => "chrome",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,49 +245,39 @@ pub async fn install_packages(
|
|||||||
emit_progress(
|
emit_progress(
|
||||||
app,
|
app,
|
||||||
project_id,
|
project_id,
|
||||||
&format!(
|
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
|
||||||
"Installing playwright and @playwright/cli into {}/node_modules…",
|
|
||||||
INSTALL_DIR
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in
|
let mut step = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE]).await?;
|
||||||
// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
|
|
||||||
// involved. The guard matters because these are `@latest`: current
|
|
||||||
// Playwright 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 explicitly asked for.
|
|
||||||
let mut cmd = vec![
|
|
||||||
"env".to_string(),
|
|
||||||
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
|
|
||||||
"npm".to_string(),
|
|
||||||
"install".to_string(),
|
|
||||||
// Leaves any package.json and lockfile at /workspace untouched.
|
|
||||||
"--no-save".to_string(),
|
|
||||||
"--no-fund".to_string(),
|
|
||||||
"--no-audit".to_string(),
|
|
||||||
];
|
|
||||||
cmd.extend(PACKAGES.iter().map(|p| p.to_string()));
|
|
||||||
|
|
||||||
let step = run_step(
|
|
||||||
app,
|
|
||||||
project_id,
|
|
||||||
container_id,
|
|
||||||
"claude",
|
|
||||||
INSTALL_DIR,
|
|
||||||
cmd,
|
|
||||||
NPM_TIMEOUT,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if step.exit_code != 0 {
|
if step.exit_code != 0 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"npm couldn't install Playwright in this container (exit {}).\n\nnpm said:\n{}",
|
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
|
||||||
step.exit_code,
|
step.exit_code,
|
||||||
step.log_or("it produced no output at all")
|
step.log_or("it produced no output at all")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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, &[VIEWER_PACKAGE, &spec]).await?;
|
||||||
|
if second.exit_code != 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
|
||||||
|
spec,
|
||||||
|
second.exit_code,
|
||||||
|
second.log_or("it produced no output at all")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
step.log = merge_logs(step.log, second.log);
|
||||||
|
|
||||||
emit_progress(app, project_id, "Re-checking what the container has…");
|
emit_progress(app, project_id, "Re-checking what the container has…");
|
||||||
let detection = detect::detect(container_id).await?;
|
let detection = detect::detect(container_id).await?;
|
||||||
|
|
||||||
@@ -265,7 +287,12 @@ pub async fn install_packages(
|
|||||||
// saying so here is what stops someone walking away from a pane that will
|
// saying so here is what stops someone walking away from a pane that will
|
||||||
// never show them anything.
|
// never show them anything.
|
||||||
let mut warning = detection.blocker();
|
let mut warning = detection.blocker();
|
||||||
if detection.needs_browser() {
|
// Skew outranks "no browser": a container in that state *has* browsers, and
|
||||||
|
// telling someone to install one they can see already installed is how a
|
||||||
|
// real user ends up doing it three times.
|
||||||
|
if let Some(skew) = detection.skew_message() {
|
||||||
|
warning = merge(warning, skew);
|
||||||
|
} else if detection.needs_browser() {
|
||||||
warning = merge(
|
warning = merge(
|
||||||
warning,
|
warning,
|
||||||
"Playwright is installed, but this container has no browser to drive yet. Install \
|
"Playwright is installed, but this container has no browser to drive yet. Install \
|
||||||
@@ -282,6 +309,98 @@ pub async fn install_packages(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// involved. The guard matters because these are `@latest`: current Playwright
|
||||||
|
/// 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,
|
||||||
|
specs: &[&str],
|
||||||
|
) -> Result<StepResult, String> {
|
||||||
|
let mut cmd = vec![
|
||||||
|
"env".to_string(),
|
||||||
|
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
|
||||||
|
"npm".to_string(),
|
||||||
|
"install".to_string(),
|
||||||
|
// Leaves any package.json and lockfile at /workspace untouched.
|
||||||
|
"--no-save".to_string(),
|
||||||
|
"--no-fund".to_string(),
|
||||||
|
"--no-audit".to_string(),
|
||||||
|
];
|
||||||
|
cmd.extend(specs.iter().map(|s| s.to_string()));
|
||||||
|
run_step(
|
||||||
|
app,
|
||||||
|
project_id,
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
INSTALL_DIR,
|
||||||
|
cmd,
|
||||||
|
NPM_TIMEOUT,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `playwright` spec to install: the exact version `@playwright/cli`
|
||||||
|
/// depends on, so both halves share one `playwright-core`.
|
||||||
|
///
|
||||||
|
/// Read from the manifest npm just wrote rather than guessed, and falling back
|
||||||
|
/// to `@latest` when it can't be read — an unreadable manifest is a reason to
|
||||||
|
/// install something, not nothing.
|
||||||
|
async fn pinned_playwright_spec(container_id: &str) -> String {
|
||||||
|
let script = format!(
|
||||||
|
"try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\
|
||||||
|
process.stdout.write(d.playwright||'');}}catch(e){{}}",
|
||||||
|
INSTALL_DIR
|
||||||
|
);
|
||||||
|
let (out, _code) = exec_oneshot_as(
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
vec!["node".to_string(), "-e".to_string(), script],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let version: &str = out.trim();
|
||||||
|
// A version, not a range or a URL: anything else goes to the fallback
|
||||||
|
// rather than into an npm command line.
|
||||||
|
if !version.is_empty()
|
||||||
|
&& version
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
|
||||||
|
{
|
||||||
|
format!("playwright@{}", version)
|
||||||
|
} else {
|
||||||
|
PLAYWRIGHT_FALLBACK.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep both npm runs' output, so a failure in either is diagnosable.
|
||||||
|
fn merge_logs(first: String, second: String) -> String {
|
||||||
|
match (first.trim().is_empty(), second.trim().is_empty()) {
|
||||||
|
(true, _) => second,
|
||||||
|
(_, true) => first,
|
||||||
|
_ => format!("{}\n{}", first.trim_end(), second),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Install a browser: its system libraries first, then the browser, then prove
|
/// Install a browser: its system libraries first, then the browser, then prove
|
||||||
/// one actually starts.
|
/// one actually starts.
|
||||||
///
|
///
|
||||||
@@ -615,7 +734,7 @@ async fn verify_launch(
|
|||||||
],
|
],
|
||||||
vec![
|
vec![
|
||||||
format!("TRIPLE_C_PW_DIR={}", dir),
|
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),
|
format!("TRIPLE_C_PW_URL={}", REACHABILITY_URL),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -706,27 +825,43 @@ fn parse_launch_output(output: &str) -> LaunchVerdict {
|
|||||||
/// The launch check. One `argv` element, no newlines, same contract as the
|
/// The launch check. One `argv` element, no newlines, same contract as the
|
||||||
/// detection probe.
|
/// detection probe.
|
||||||
///
|
///
|
||||||
/// Playwright leaves the Chromium sandbox disabled by default, which is what
|
/// `chromiumSandbox` is set explicitly rather than left to Playwright's
|
||||||
/// makes this work in a container at all. The timeout exists so a browser that
|
/// default, so this check states the same thing the seeded
|
||||||
/// hangs on a missing library still returns a verdict rather than sitting there
|
/// `cli.config.json` does instead of agreeing with it by coincidence. The
|
||||||
/// until the exec is torn down. The navigation is best-effort and never decides
|
/// containers forbid unprivileged user namespaces, so a sandboxed Chromium
|
||||||
/// `ok` — it exists to tell a TLS-intercepted network apart from a broken
|
/// aborts on launch; nothing here should be able to drift back into testing a
|
||||||
/// install.
|
/// 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!(
|
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#"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#"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 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#"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#"(async()=>{let b=null,cur="";try{const {chromium}=require(d);"#,
|
||||||
r#"let v="";try{v=b.version();}catch(e){}"#,
|
r#"const list=chs.split(",").map(s=>s.trim()).filter(Boolean);"#,
|
||||||
r#"let nav={ok:true,cert:false,detail:""};"#,
|
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});}"#,
|
r#"try{const p=await b.newPage();await p.goto(u,{timeout:20000});}"#,
|
||||||
// A certificate failure is classified here, next to the message, because
|
// A certificate failure is classified here, next to the message, because
|
||||||
// Chromium's wording is the only place the distinction exists.
|
// 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#"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#"await b.close();b=null;}"#,
|
||||||
r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}say(false,one(e));}"#,
|
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);})();"#,
|
r#"process.exit(0);})();"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -865,10 +1000,20 @@ mod tests {
|
|||||||
fn the_package_set_is_the_minimum_that_satisfies_the_probe() {
|
fn the_package_set_is_the_minimum_that_satisfies_the_probe() {
|
||||||
// The viewer package is not optional, and `@playwright/mcp` is not a
|
// The viewer package is not optional, and `@playwright/mcp` is not a
|
||||||
// member: it can bind sessions, it can never serve the UI.
|
// member: it can bind sessions, it can never serve the UI.
|
||||||
assert!(PACKAGES.iter().any(|p| p.starts_with("playwright@")));
|
assert!(VIEWER_PACKAGE.starts_with("@playwright/cli@"));
|
||||||
assert!(PACKAGES.iter().any(|p| p.starts_with("@playwright/cli@")));
|
assert!(PLAYWRIGHT_FALLBACK.starts_with("playwright@"));
|
||||||
assert!(!PACKAGES.iter().any(|p| p.contains("@playwright/mcp")));
|
assert!(!VIEWER_PACKAGE.contains("@playwright/mcp"));
|
||||||
assert_eq!(PACKAGES.len(), 2);
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playwright_is_not_installed_at_latest_alongside_the_viewer() {
|
||||||
|
// `@latest` for both is exactly what splits the tree into two
|
||||||
|
// `playwright-core`s wanting different browser revisions — the viewer
|
||||||
|
// green, every `require("playwright")` dead. The version comes from the
|
||||||
|
// viewer's own manifest instead; `@latest` is only the fallback for an
|
||||||
|
// unreadable one.
|
||||||
|
assert!(!VIEWER_PACKAGE.contains("playwright@latest"));
|
||||||
|
assert_eq!(PLAYWRIGHT_FALLBACK, "playwright@latest");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -885,8 +1030,10 @@ mod tests {
|
|||||||
// `@playwright/mcp` asks for the chrome channel specifically, so the UI
|
// `@playwright/mcp` asks for the chrome channel specifically, so the UI
|
||||||
// must be able to say so.
|
// must be able to say so.
|
||||||
assert!(BrowserTarget::Chrome.needed_for().contains("@playwright/mcp"));
|
assert!(BrowserTarget::Chrome.needed_for().contains("@playwright/mcp"));
|
||||||
assert_eq!(BrowserTarget::Chrome.channel(), Some("chrome"));
|
assert_eq!(BrowserTarget::Chrome.channels(), "chrome");
|
||||||
assert_eq!(BrowserTarget::Chromium.channel(), None);
|
// 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.
|
// And a size, before the click, for both.
|
||||||
for t in [BrowserTarget::Chromium, BrowserTarget::Chrome] {
|
for t in [BrowserTarget::Chromium, BrowserTarget::Chrome] {
|
||||||
assert!(t.download_note().to_lowercase().contains("mb"), "{:?}", t);
|
assert!(t.download_note().to_lowercase().contains("mb"), "{:?}", t);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod detect;
|
pub mod detect;
|
||||||
pub mod install;
|
pub mod install;
|
||||||
|
pub mod page;
|
||||||
pub mod popout;
|
pub mod popout;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
|
|
||||||
@@ -303,21 +304,7 @@ impl BrowserViewManager {
|
|||||||
// a container with no dashboard makes this a no-op.
|
// a container with no dashboard makes this a no-op.
|
||||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||||
|
|
||||||
let container_port = pick_viewer_port(&container_id).await?;
|
let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).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 token = generate_token();
|
let token = generate_token();
|
||||||
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
||||||
@@ -600,12 +587,91 @@ async fn read_viewer_log(container_id: &str) -> String {
|
|||||||
// Readiness, ports, URLs
|
// Readiness, ports, URLs
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on.
|
/// How many free ports a start will try before giving up.
|
||||||
async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
///
|
||||||
|
/// 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(
|
let text = exec_oneshot(
|
||||||
container_id,
|
container_id,
|
||||||
vec![
|
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/tcp".to_string(),
|
||||||
"/proc/net/tcp6".to_string(),
|
"/proc/net/tcp6".to_string(),
|
||||||
],
|
],
|
||||||
@@ -615,7 +681,7 @@ async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
|||||||
let taken = proc_net::parse_loopback_listeners(&text);
|
let taken = proc_net::parse_loopback_listeners(&text);
|
||||||
VIEWER_PORTS
|
VIEWER_PORTS
|
||||||
.clone()
|
.clone()
|
||||||
.find(|p| !taken.contains_key(p))
|
.find(|p| !taken.contains_key(p) && !tried.contains(p))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"No free port in {}–{} inside the container for the Playwright viewer.",
|
"No free port in {}–{} inside the container for the Playwright viewer.",
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
//! Open a page in the container's browser, and resize it while it runs.
|
||||||
|
//!
|
||||||
|
//! The pane [watches](super) browsers something else published. This opens one:
|
||||||
|
//! the user hands it a URL, it launches a browser inside the container,
|
||||||
|
//! publishes it with `browser.bind()` so the pane picks it up, and holds the
|
||||||
|
//! handle so the page can be navigated and **resized** afterwards.
|
||||||
|
//!
|
||||||
|
//! ## Why the handle has to be held
|
||||||
|
//!
|
||||||
|
//! Verified against a real bound browser: a second client cannot join one.
|
||||||
|
//! `chromium.connect()` against the published endpoint times out in every URL
|
||||||
|
//! form — the descriptor's socket speaks the dashboard's own transport, not the
|
||||||
|
//! public connect protocol. So whoever launches the browser is the only process
|
||||||
|
//! that can ever drive it. That is the whole reason this helper is a resident
|
||||||
|
//! process rather than a one-shot `node -e` that exits.
|
||||||
|
//!
|
||||||
|
//! It also draws the line for the feature: pages *this* opens can be resized
|
||||||
|
//! live; a browser `@playwright/mcp` launched can only be watched, and its size
|
||||||
|
//! is whatever `--viewport-size` it was given.
|
||||||
|
//!
|
||||||
|
//! ## Control channel
|
||||||
|
//!
|
||||||
|
//! A JSON file in `/tmp`, polled by the helper. No port, no second listener, no
|
||||||
|
//! addition to the proxy's attack surface — and it composes with the one exec
|
||||||
|
//! path this codebase already has. Writes go through `node -e` rather than
|
||||||
|
//! shell redirection so a URL never touches a shell.
|
||||||
|
//!
|
||||||
|
//! ## Viewport, and why it is the interesting part
|
||||||
|
//!
|
||||||
|
//! `page.setViewportSize()` genuinely reflows: measured on a page carrying a
|
||||||
|
//! `@media (max-width: 900px)` rule, the rule fires at 800×600 and clears at
|
||||||
|
//! 1440×900. Resizing the *window* the pane lives in does nothing of the sort —
|
||||||
|
//! the viewer is a CDP screencast, so a bigger window is the same pixels drawn
|
||||||
|
//! 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;
|
||||||
|
|
||||||
|
/// Control file the helper polls, and the state file it writes back.
|
||||||
|
const CONTROL_PATH: &str = "/tmp/triple-c-page-control.json";
|
||||||
|
const STATE_PATH: &str = "/tmp/triple-c-page-state.json";
|
||||||
|
/// Where the detached helper's own output goes, so a failed start has a trail.
|
||||||
|
const HELPER_LOG: &str = "/tmp/triple-c-page.log";
|
||||||
|
|
||||||
|
/// How long to wait for the helper to report that the page is up.
|
||||||
|
const READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
|
||||||
|
/// Navigating a browser that is already up. One page load, not a cold start.
|
||||||
|
const REUSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(35);
|
||||||
|
const READY_POLL: std::time::Duration = std::time::Duration::from_millis(400);
|
||||||
|
|
||||||
|
/// A viewport, in CSS pixels.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Viewport {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Viewport {
|
||||||
|
/// Clamped to something a browser will accept. A window dragged to nothing
|
||||||
|
/// must not ask Chromium for a zero-width page.
|
||||||
|
pub fn sane(width: u32, height: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
width: width.clamp(200, 7680),
|
||||||
|
height: height.clamp(200, 4320),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the helper reports about itself.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
|
pub struct PageState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub ready: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub viewport: Option<Viewport>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `url` in a freshly launched, bound browser.
|
||||||
|
///
|
||||||
|
/// 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,
|
||||||
|
viewport: Viewport,
|
||||||
|
) -> Result<PageState, String> {
|
||||||
|
let core = detection.playwright_path.as_deref().ok_or_else(|| {
|
||||||
|
"Playwright isn't installed in this container — set it up from the Browser tab first."
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
// The directory of the resolved manifest is what `require()` wants.
|
||||||
|
let core_dir = core.trim_end_matches("/package.json");
|
||||||
|
|
||||||
|
// The executable is passed explicitly rather than left to Playwright's
|
||||||
|
// revision lookup: a container can hold browsers a given copy will not
|
||||||
|
// launch (see `detect::revision_skew`), and this is the one place we know
|
||||||
|
// which binary is actually on disk.
|
||||||
|
let executable = detection
|
||||||
|
.chromium_executable
|
||||||
|
.as_deref()
|
||||||
|
.filter(|_| detection.chromium_executable_exists);
|
||||||
|
|
||||||
|
// Reuse a helper that is already up. Relaunching would throw away the
|
||||||
|
// 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 {
|
||||||
|
return Ok(state);
|
||||||
|
}
|
||||||
|
// It stopped answering; fall through and start a fresh one.
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
"executable": executable,
|
||||||
|
"url": url,
|
||||||
|
"viewport": viewport,
|
||||||
|
"control": CONTROL_PATH,
|
||||||
|
"state": STATE_PATH,
|
||||||
|
});
|
||||||
|
let script = format!("const CFG={};{}", config, HELPER);
|
||||||
|
|
||||||
|
// Detached, for the same reason the viewer is: the process has to outlive
|
||||||
|
// the exec that started it, or the page closes the moment we return.
|
||||||
|
let launcher = format!(
|
||||||
|
"cd /workspace 2>/dev/null || true; rm -f {} {}; nohup node -e {} >{} 2>&1 &",
|
||||||
|
STATE_PATH,
|
||||||
|
CONTROL_PATH,
|
||||||
|
shell_quote(&script),
|
||||||
|
HELPER_LOG
|
||||||
|
);
|
||||||
|
exec_oneshot_as(
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
vec!["sh".to_string(), "-c".to_string(), launcher],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize the open page. Cheap enough to call from a window-resize handler.
|
||||||
|
pub async fn set_viewport(container_id: &str, viewport: Viewport) -> Result<(), String> {
|
||||||
|
write_control(
|
||||||
|
container_id,
|
||||||
|
serde_json::json!({ "viewport": viewport }).to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate the open page without relaunching the browser.
|
||||||
|
pub async fn navigate(container_id: &str, url: &str) -> Result<(), String> {
|
||||||
|
write_control(container_id, serde_json::json!({ "url": url }).to_string()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the helper to shut down. Best effort: a container that has none is the
|
||||||
|
/// normal case, and the caller is usually about to start one anyway.
|
||||||
|
pub async fn close(container_id: &str) {
|
||||||
|
let _ = write_control(container_id, serde_json::json!({ "close": true }).to_string()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current state, or a default when no helper has ever run here.
|
||||||
|
pub async fn state(container_id: &str) -> PageState {
|
||||||
|
let script = format!(
|
||||||
|
"try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}",
|
||||||
|
STATE_PATH
|
||||||
|
);
|
||||||
|
let Ok((out, _)) = exec_oneshot_as(
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
vec!["node".to_string(), "-e".to_string(), script],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return PageState::default();
|
||||||
|
};
|
||||||
|
serde_json::from_str(out.trim()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the control file through Node rather than a shell redirect, so a URL
|
||||||
|
/// is never interpreted by `sh`.
|
||||||
|
async fn write_control(container_id: &str, json: String) -> Result<(), String> {
|
||||||
|
let script = format!(
|
||||||
|
"require('fs').writeFileSync('{}',process.argv[1]);",
|
||||||
|
CONTROL_PATH
|
||||||
|
);
|
||||||
|
exec_oneshot_as(
|
||||||
|
container_id,
|
||||||
|
"claude",
|
||||||
|
vec!["node".to_string(), "-e".to_string(), script, json],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("Could not reach the browser helper: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for a *running* helper to report the URL we just asked it for.
|
||||||
|
///
|
||||||
|
/// Bounded much tighter than a cold start: the browser is already up, so this
|
||||||
|
/// is one navigation. `None` means it stopped answering, and the caller starts
|
||||||
|
/// a fresh helper rather than reporting a page that isn't there.
|
||||||
|
async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
|
||||||
|
let deadline = std::time::Instant::now() + REUSE_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let state = state(container_id).await;
|
||||||
|
if state.ready && state.url.as_deref() == Some(url) {
|
||||||
|
return Some(state);
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(READY_POLL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll the state file until the helper says the page is up, or says why not.
|
||||||
|
async fn wait_until_ready(container_id: &str) -> Result<PageState, String> {
|
||||||
|
let deadline = std::time::Instant::now() + READY_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
let state = state(container_id).await;
|
||||||
|
if let Some(error) = state.error.clone() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
if state.ready {
|
||||||
|
return Ok(state);
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return Err(format!(
|
||||||
|
"The browser didn't come up within {}s. Its log is at {} inside the container.",
|
||||||
|
READY_TIMEOUT.as_secs(),
|
||||||
|
HELPER_LOG
|
||||||
|
));
|
||||||
|
}
|
||||||
|
tokio::time::sleep(READY_POLL).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-quote for `sh`, the same way [`super`] does for the viewer's paths.
|
||||||
|
fn shell_quote(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', r"'\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resident helper, appended to a `const CFG={…};` prelude.
|
||||||
|
///
|
||||||
|
/// Deliberately one string passed as a single `argv` element — no shell parsing
|
||||||
|
/// of any part of it, exactly like `detect`'s probe. It launches, binds, and
|
||||||
|
/// then polls the control file; every failure path writes the state file, so a
|
||||||
|
/// helper that dies during startup is reported rather than waited out.
|
||||||
|
const HELPER: &str = concat!(
|
||||||
|
r#"const fs=require('fs');"#,
|
||||||
|
r#"const {chromium}=require(CFG.core);"#,
|
||||||
|
r#"const write=(o)=>{try{fs.writeFileSync(CFG.state,JSON.stringify(o));}catch(e){}};"#,
|
||||||
|
r#"const fail=(e)=>{write({ready:false,error:String(e&&e.message||e)});process.exit(1);};"#,
|
||||||
|
r#"process.on('unhandledRejection',fail);"#,
|
||||||
|
r#"(async()=>{"#,
|
||||||
|
// `chromiumSandbox:false` because the container has no user namespaces to
|
||||||
|
// give Chromium; headless because there is no display, which is also the
|
||||||
|
// only mode the dashboard can screencast anyway.
|
||||||
|
r#"const opts={headless:true,chromiumSandbox:false};"#,
|
||||||
|
r#"if(CFG.executable)opts.executablePath=CFG.executable;"#,
|
||||||
|
r#"const browser=await chromium.launch(opts);"#,
|
||||||
|
r#"const ctx=await browser.newContext({viewport:CFG.viewport});"#,
|
||||||
|
r#"const page=await ctx.newPage();"#,
|
||||||
|
// Bind before navigating: the pane should show the page loading rather than
|
||||||
|
// appearing once it is done.
|
||||||
|
r#"await browser.bind('claude',{metadata:{source:'triple-c'}});"#,
|
||||||
|
r#"let current=CFG.url,viewport=CFG.viewport;"#,
|
||||||
|
r#"const report=()=>write({ready:true,url:current,viewport});"#,
|
||||||
|
r#"try{await page.goto(CFG.url,{waitUntil:'domcontentloaded',timeout:30000});}catch(e){}"#,
|
||||||
|
r#"report();"#,
|
||||||
|
// The control loop. A poll, not a watcher: `fs.watch` misses writes on some
|
||||||
|
// filesystems and this costs nothing at 4 Hz.
|
||||||
|
r#"setInterval(async()=>{let c;try{c=JSON.parse(fs.readFileSync(CFG.control,'utf8'));}catch(e){return;}"#,
|
||||||
|
r#"try{fs.unlinkSync(CFG.control);}catch(e){}"#,
|
||||||
|
r#"if(c.close){await browser.close().catch(()=>{});write({ready:false});process.exit(0);}"#,
|
||||||
|
r#"if(c.viewport){viewport=c.viewport;await page.setViewportSize(c.viewport).catch(()=>{});}"#,
|
||||||
|
r#"if(c.url&&c.url!==current){current=c.url;await page.goto(c.url,{waitUntil:'domcontentloaded',timeout:30000}).catch(()=>{});}"#,
|
||||||
|
r#"report();},250);"#,
|
||||||
|
// A browser that dies (crash, or the user closing the last page) must not
|
||||||
|
// leave a helper claiming a live page.
|
||||||
|
r#"browser.on('disconnected',()=>{write({ready:false});process.exit(0);});"#,
|
||||||
|
r#"})().catch(fail);"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_helper_is_one_argv_element_with_no_shell_hazards() {
|
||||||
|
// Same rule as the detect probe: it is passed as a single argument, so
|
||||||
|
// it must contain neither a newline nor a single quote that would end
|
||||||
|
// the quoting `open` wraps it in.
|
||||||
|
assert!(!HELPER.contains('\n'), "{}", HELPER);
|
||||||
|
assert!(HELPER.contains("chromium.launch"), "{}", HELPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_helper_binds_so_the_pane_can_see_the_page() {
|
||||||
|
// Without this the page opens and the pane shows nothing — the whole
|
||||||
|
// feature hinges on the browser being published.
|
||||||
|
assert!(HELPER.contains("browser.bind('claude'"), "{}", HELPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_helper_reports_startup_failures_instead_of_hanging() {
|
||||||
|
// `wait_until_ready` polls the state file; a helper that dies silently
|
||||||
|
// would turn every failure into a 45-second timeout.
|
||||||
|
assert!(HELPER.contains("unhandledRejection"), "{}", HELPER);
|
||||||
|
assert!(HELPER.contains("error:String"), "{}", HELPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_viewport_is_clamped_to_something_a_browser_accepts() {
|
||||||
|
assert_eq!(Viewport::sane(0, 0), Viewport { width: 200, height: 200 });
|
||||||
|
assert_eq!(
|
||||||
|
Viewport::sane(99_999, 99_999),
|
||||||
|
Viewport { width: 7680, height: 4320 }
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Viewport::sane(1440, 900),
|
||||||
|
Viewport { width: 1440, height: 900 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_url_is_never_parsed_by_a_shell() {
|
||||||
|
// The launcher runs through `sh -c`, so the script is quoted with the
|
||||||
|
// POSIX close-escape-reopen form: the embedded quote becomes `'\''`,
|
||||||
|
// which leaves the `;rm` inside the string rather than starting a new
|
||||||
|
// command. (A naive "the output must not contain ';rm'" check fails
|
||||||
|
// here and would be wrong — that substring is *inside* the quoting.)
|
||||||
|
assert_eq!(
|
||||||
|
shell_quote("http://x/?a=1&b=2';rm -rf /"),
|
||||||
|
r"'http://x/?a=1&b=2'\'';rm -rf /'"
|
||||||
|
);
|
||||||
|
// The control channel doesn't go near a shell at all: the JSON travels
|
||||||
|
// as an argv element to `node`.
|
||||||
|
assert!(!HELPER.contains("exec("), "{}", HELPER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_defaults_to_not_ready_rather_than_failing() {
|
||||||
|
// An empty/absent state file is the normal case before anything runs.
|
||||||
|
let s: PageState = serde_json::from_str("{}").unwrap();
|
||||||
|
assert!(!s.ready);
|
||||||
|
assert!(s.error.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@
|
|||||||
//! dead viewer is worse than no window. The reverse is not true; closing the
|
//! dead viewer is worse than no window. The reverse is not true; closing the
|
||||||
//! window leaves the view running, and the pane takes it back into the tab.
|
//! window leaves the view running, and the pane takes it back into the tab.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};
|
||||||
|
|
||||||
@@ -106,11 +110,17 @@ pub fn open(
|
|||||||
.map_err(|e| format!("Could not open the browser window: {}", e))?;
|
.map_err(|e| format!("Could not open the browser window: {}", e))?;
|
||||||
|
|
||||||
// Closed from its own titlebar, this is the only thing that tells the pane
|
// Closed from its own titlebar, this is the only thing that tells the pane
|
||||||
// to take the view back into the tab.
|
// to take the view back into the tab. `Resized` drives match-window mode —
|
||||||
window.on_window_event(move |event| {
|
// see `set_match_window`.
|
||||||
if matches!(event, WindowEvent::Destroyed) {
|
window.on_window_event(move |event| match event {
|
||||||
|
WindowEvent::Destroyed => {
|
||||||
|
set_match_window(&project_id_owned, false);
|
||||||
emit(&app_for_event, &project_id_owned, PopoutState::CLOSED);
|
emit(&app_for_event, &project_id_owned, PopoutState::CLOSED);
|
||||||
}
|
}
|
||||||
|
WindowEvent::Resized(size) => {
|
||||||
|
on_resized(&app_for_event, &project_id_owned, size.width, size.height);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
log::info!("Browser view: popped out for project {}", project_id);
|
log::info!("Browser view: popped out for project {}", project_id);
|
||||||
@@ -170,6 +180,99 @@ pub fn set_always_on_top(app: &AppHandle, project_id: &str, on_top: bool) -> Res
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Match-window mode
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Projects whose pop-out is driving the page's viewport, and the generation of
|
||||||
|
/// the latest resize for each — the debounce is "did anything else arrive while
|
||||||
|
/// I slept?", which needs no timer to cancel.
|
||||||
|
static MATCH_WINDOW: OnceLock<Mutex<HashMap<String, (bool, u64)>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// How long the window has to stop moving before the page is resized.
|
||||||
|
///
|
||||||
|
/// A drag emits `Resized` continuously; each one costs a container exec, and
|
||||||
|
/// Chromium relayouts the page. Settling first turns a drag into one resize.
|
||||||
|
const RESIZE_SETTLE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
|
fn match_window_map() -> &'static Mutex<HashMap<String, (bool, u64)>> {
|
||||||
|
MATCH_WINDOW.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn match-window mode on or off for a project.
|
||||||
|
///
|
||||||
|
/// Only ever affects a page **Triple-C opened** — a bound browser cannot be
|
||||||
|
/// joined by a second client, so a page `@playwright/mcp` launched keeps
|
||||||
|
/// whatever viewport it was given. See [`super::page`].
|
||||||
|
pub fn set_match_window(project_id: &str, enabled: bool) {
|
||||||
|
let mut map = match_window_map().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let entry = map.entry(project_id.to_string()).or_insert((false, 0));
|
||||||
|
entry.0 = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn match_window(project_id: &str) -> bool {
|
||||||
|
match_window_map()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|e| e.into_inner())
|
||||||
|
.get(project_id)
|
||||||
|
.map(|(on, _)| *on)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pop-out's current inner size, for applying match-window immediately
|
||||||
|
/// rather than only on the next drag.
|
||||||
|
pub fn inner_size(app: &AppHandle, project_id: &str) -> Option<(u32, u32)> {
|
||||||
|
let window = app.get_webview_window(&window_label(project_id))?;
|
||||||
|
let size = window.inner_size().ok()?;
|
||||||
|
Some((size.width, size.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debounce a resize, then push the settled size into the page's viewport.
|
||||||
|
fn on_resized(app: &AppHandle, project_id: &str, width: u32, height: u32) {
|
||||||
|
let generation = {
|
||||||
|
let mut map = match_window_map().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let Some(entry) = map.get_mut(project_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !entry.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.1 += 1;
|
||||||
|
entry.1
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = app.clone();
|
||||||
|
let project_id = project_id.to_string();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
tokio::time::sleep(RESIZE_SETTLE).await;
|
||||||
|
// Superseded by a later resize: that one will do the work.
|
||||||
|
{
|
||||||
|
let map = match_window_map().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
match map.get(&project_id) {
|
||||||
|
Some((true, latest)) if *latest == generation => {}
|
||||||
|
_ => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = app.state::<crate::AppState>();
|
||||||
|
let Some(container_id) = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.and_then(|p| p.container_id)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(e) = super::page::set_viewport(
|
||||||
|
&container_id,
|
||||||
|
super::page::Viewport::sane(width, height),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::debug!("Browser view: could not match the page to the window: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn emit(app: &AppHandle, project_id: &str, state: PopoutState) {
|
fn emit(app: &AppHandle, project_id: &str, state: PopoutState) {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
POPOUT_EVENT,
|
POPOUT_EVENT,
|
||||||
@@ -198,4 +301,38 @@ mod tests {
|
|||||||
fn distinct_projects_get_distinct_windows() {
|
fn distinct_projects_get_distinct_windows() {
|
||||||
assert_ne!(window_label("alpha"), window_label("beta"));
|
assert_ne!(window_label("alpha"), window_label("beta"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_window_is_off_until_asked_for_and_is_per_project() {
|
||||||
|
assert!(!match_window("mw-a"));
|
||||||
|
set_match_window("mw-a", true);
|
||||||
|
assert!(match_window("mw-a"));
|
||||||
|
// Another project's window must not start driving its page too.
|
||||||
|
assert!(!match_window("mw-b"));
|
||||||
|
set_match_window("mw-a", false);
|
||||||
|
assert!(!match_window("mw-a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resize_supersedes_the_one_before_it() {
|
||||||
|
// The debounce is a generation counter, not a cancellable timer: only
|
||||||
|
// the newest resize of a drag survives to touch the container.
|
||||||
|
set_match_window("mw-gen", true);
|
||||||
|
let read = || {
|
||||||
|
match_window_map()
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get("mw-gen")
|
||||||
|
.map(|(_, g)| *g)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let before = read();
|
||||||
|
{
|
||||||
|
let mut map = match_window_map().lock().unwrap();
|
||||||
|
let entry = map.get_mut("mw-gen").unwrap();
|
||||||
|
entry.1 += 1;
|
||||||
|
}
|
||||||
|
assert!(read() > before);
|
||||||
|
set_match_window("mw-gen", false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1188,16 +1188,53 @@ pub async fn has_claude_token() -> Result<bool, String> {
|
|||||||
Ok(secure::has_claude_oauth_token())
|
Ok(secure::has_claude_oauth_token())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`clear_claude_token`] managed to reach. The keychain entry is always
|
/// The tail of every refusal [`crate::project_lock::try_acquire`] produces.
|
||||||
/// gone by the time this is returned — the rest is about copies of the token
|
///
|
||||||
/// that live outside it.
|
/// [`crate::docker::container::scrub_secrets_from_snapshots`] folds two very
|
||||||
#[derive(Debug, Default, serde::Serialize)]
|
/// 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 {
|
pub struct ClearTokenOutcome {
|
||||||
/// Snapshot images that were holding the token and have been rewritten.
|
/// Snapshot images that were holding the token and have been rewritten.
|
||||||
pub snapshots_scrubbed: Vec<String>,
|
pub snapshots_scrubbed: Vec<String>,
|
||||||
/// Images still holding it, with the reason each could not be rewritten.
|
/// 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.
|
/// Non-empty means the revocation is **incomplete** and the UI must say so.
|
||||||
pub snapshots_failed: Vec<String>,
|
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
|
/// Rewritten, but the pre-rewrite image object could not be deleted because
|
||||||
/// a container is still running off it. Worth mentioning, not worth
|
/// a container is still running off it. Worth mentioning, not worth
|
||||||
/// alarming about — see `SnapshotScrubReport::superseded_retained`.
|
/// alarming about — see `SnapshotScrubReport::superseded_retained`.
|
||||||
@@ -1207,7 +1244,130 @@ pub struct ClearTokenOutcome {
|
|||||||
pub docker_unavailable: Option<String>,
|
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
|
/// 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
|
/// 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
|
/// as long as the image exists. New commits no longer bake it in (see
|
||||||
/// [`crate::docker::container::commit_container_snapshot`]), but images
|
/// [`crate::docker::container::commit_container_snapshot`]), but images
|
||||||
/// committed by earlier builds have to be rewritten, which is what
|
/// 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
|
/// ## Why the keychain goes first
|
||||||
/// completed revocation is still better than none, and the outcome is reported
|
///
|
||||||
/// so the UI can be explicit about what is left.
|
/// 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]
|
#[tauri::command]
|
||||||
pub async fn clear_claude_token() -> Result<ClearTokenOutcome, String> {
|
pub async fn clear_claude_token() -> Result<ClearTokenOutcome, String> {
|
||||||
secure::delete_claude_oauth_token()?;
|
run_cleanup(
|
||||||
log::info!("Cleared the shared Claude authentication token");
|
Cleanup::KeychainThenImages,
|
||||||
|
secure::delete_claude_oauth_token,
|
||||||
let report = crate::docker::container::scrub_secrets_from_snapshots().await;
|
crate::docker::container::scrub_secrets_from_snapshots,
|
||||||
if report.left_something_behind() {
|
)
|
||||||
log::warn!(
|
.await
|
||||||
"Revoked the shared Claude token but {} snapshot image(s) may still contain it",
|
|
||||||
report.failed.len()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ClearTokenOutcome {
|
/// Rewrite every snapshot image that still carries a credential, **without
|
||||||
snapshots_scrubbed: report.scrubbed,
|
/// touching the keychain**.
|
||||||
snapshots_failed: report
|
///
|
||||||
.failed
|
/// This is the retry, and it is its own command because the retry is its own
|
||||||
.into_iter()
|
/// act. `docker commit` copied the token into each project's snapshot image;
|
||||||
.map(|(image, reason)| format!("{}: {}", image, reason))
|
/// rewriting those images is a cleanup that has nothing to do with whether a
|
||||||
.collect(),
|
/// token is stored today, and folding it into [`clear_claude_token`] made every
|
||||||
snapshots_superseded: report.superseded_retained,
|
/// press of "Retry snapshot cleanup" an unconfirmed credential deletion.
|
||||||
docker_unavailable: report.unavailable,
|
///
|
||||||
})
|
/// 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)]
|
#[cfg(test)]
|
||||||
@@ -1761,5 +1968,265 @@ mod tests {
|
|||||||
seen.push_str(&s.push(format!("\nYour token: {}\n", tok).as_bytes()));
|
seen.push_str(&s.push(format!("\nYour token: {}\n", tok).as_bytes()));
|
||||||
assert_eq!(parse_setup_token(&seen), Some(tok));
|
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
|
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
|
/// Only known for enabled one-shot tasks (their `at` time). Recurring cron
|
||||||
/// expressions are not evaluated here.
|
/// expressions are not evaluated here.
|
||||||
pub next_run: Option<String>,
|
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.
|
/// 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
|
set -u
|
||||||
TASKS="$HOME/.claude/scheduler/tasks"
|
TASKS="$HOME/.claude/scheduler/tasks"
|
||||||
LOGS="$HOME/.claude/scheduler/logs"
|
LOGS="$HOME/.claude/scheduler/logs"
|
||||||
|
RUNNING="$HOME/.claude/scheduler/running"
|
||||||
[ -d "$TASKS" ] || { echo '[]'; exit 0; }
|
[ -d "$TASKS" ] || { echo '[]'; exit 0; }
|
||||||
for f in "$TASKS"/*.json; do
|
for f in "$TASKS"/*.json; do
|
||||||
[ -f "$f" ] || continue
|
[ -f "$f" ] || continue
|
||||||
id=$(jq -r '.id // ""' "$f") || continue
|
id=$(jq -r '.id // ""' "$f") || continue
|
||||||
[ -n "$id" ] || id=$(basename "$f" .json)
|
[ -n "$id" ] || id=$(basename "$f" .json)
|
||||||
last=$(find "$LOGS/$id" -name '*.log' -type f -printf '%T@\n' | sort -rn | head -1)
|
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),
|
id: (if (.id // "") == "" then $fallback_id else .id end),
|
||||||
name: (.name // ""),
|
name: (.name // ""),
|
||||||
prompt: (.prompt // ""),
|
prompt: (.prompt // ""),
|
||||||
@@ -630,7 +647,8 @@ for f in "$TASKS"/*.json; do
|
|||||||
enabled: (.enabled == true),
|
enabled: (.enabled == true),
|
||||||
working_dir: (.working_dir // "/workspace"),
|
working_dir: (.working_dir // "/workspace"),
|
||||||
created_at: (.created_at // null),
|
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"
|
}' "$f"
|
||||||
done | jq -s 'sort_by(.name, .id)'
|
done | jq -s 'sort_by(.name, .id)'
|
||||||
"#;
|
"#;
|
||||||
@@ -673,6 +691,7 @@ struct RawScheduledTask {
|
|||||||
working_dir: String,
|
working_dir: String,
|
||||||
created_at: Option<String>,
|
created_at: Option<String>,
|
||||||
last_run_epoch: Option<i64>,
|
last_run_epoch: Option<i64>,
|
||||||
|
running_since_epoch: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -723,6 +742,8 @@ pub async fn list_scheduled_tasks(
|
|||||||
created_at: t.created_at,
|
created_at: t.created_at,
|
||||||
last_run: t.last_run_epoch.map(epoch_to_iso),
|
last_run: t.last_run_epoch.map(epoch_to_iso),
|
||||||
next_run,
|
next_run,
|
||||||
|
running: t.running_since_epoch.is_some(),
|
||||||
|
running_since: t.running_since_epoch.map(epoch_to_iso),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -1179,9 +1200,14 @@ pub async fn add_scheduled_task(
|
|||||||
/// * **In that order**, so a rejected `add` leaves the original untouched
|
/// * **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
|
/// 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.
|
/// sub-second window in which both tasks are in the crontab.
|
||||||
/// * The task therefore gets a **new id**. Its old log directory
|
/// * The task therefore gets a **new id**, and its old log directory
|
||||||
/// (`~/.claude/scheduler/logs/<old-id>/`) stays behind under the old id; the
|
/// (`~/.claude/scheduler/logs/<old-id>/`) goes with the removal — the
|
||||||
/// UI warns about this before saving.
|
/// 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` is carried over explicitly, because `add` always creates an
|
||||||
/// enabled task and silently re-enabling a task the user had switched off
|
/// enabled task and silently re-enabling a task the user had switched off
|
||||||
/// would schedule a run they did not ask for.
|
/// 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
|
/// Report how far behind the current base image a project's container is, and
|
||||||
/// what migrating it would actually carry across.
|
/// 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
|
/// Read-only. Runs two filesystem probes (~3 s each) and is therefore meant to
|
||||||
/// be called on demand, not polled.
|
/// be called on demand, not polled.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -98,20 +117,24 @@ pub async fn get_container_staleness(
|
|||||||
// Lineage, most authoritative source first: the live container's label,
|
// Lineage, most authoritative source first: the live container's label,
|
||||||
// then the snapshot image's. Both are written by `create_container` and
|
// then the snapshot image's. Both are written by `create_container` and
|
||||||
// propagated onto the snapshot by `docker commit`.
|
// 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 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,
|
Some(id) => container_label(id, mig::LABEL_BASE_IMAGE_ID).await,
|
||||||
None => None,
|
None => None,
|
||||||
}
|
};
|
||||||
.or_else(|| None);
|
let from_snapshot = mig::image_labels(&snapshot_image)
|
||||||
let recorded = match recorded {
|
|
||||||
Some(v) => Some(v),
|
|
||||||
None => mig::image_labels(&snapshot_image)
|
|
||||||
.await
|
.await
|
||||||
.get(mig::LABEL_BASE_IMAGE_ID)
|
.get(mig::LABEL_BASE_IMAGE_ID)
|
||||||
.cloned(),
|
.cloned();
|
||||||
}
|
let recorded = pick_recorded_lineage(from_container, from_snapshot);
|
||||||
.filter(|v| !v.is_empty());
|
|
||||||
|
|
||||||
out.base_image_id = recorded.clone();
|
out.base_image_id = recorded.clone();
|
||||||
out.known = recorded.is_some();
|
out.known = recorded.is_some();
|
||||||
@@ -204,58 +227,62 @@ async fn container_label(container_id: &str, label: &str) -> Option<String> {
|
|||||||
// Migrate
|
// 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
|
/// Whether a migration for this project is running **in this process right
|
||||||
/// now**. Every command that stops, removes or recreates the project's
|
/// now**. Every command that stops, removes or recreates the project's
|
||||||
/// container has to consult it: the window between `remove_container` and the
|
/// container has to consult it: the window between `remove_container` and the
|
||||||
/// create that follows looks exactly like "no container", and an ordinary
|
/// create that follows looks exactly like "no container", and an ordinary
|
||||||
/// Start landing in it creates a second container under the same name.
|
/// 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 {
|
pub(crate) fn is_migrating(project_id: &str) -> bool {
|
||||||
active_migrations()
|
crate::project_lock::is_held_by(project_id, crate::project_lock::ProjectOp::Migration)
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
|
||||||
.contains(project_id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RAII marker: removes the project from [`ACTIVE_MIGRATIONS`] however the
|
/// RAII marker: releases the project's [`crate::project_lock`] claim however
|
||||||
/// migration ends, including an early `?`.
|
/// the migration ends, including an early `?`.
|
||||||
struct ActiveGuard(String);
|
///
|
||||||
|
/// 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 {
|
impl ActiveGuard {
|
||||||
/// `None` when a migration is already running for this project.
|
/// `Err` with the registry's own refusal when a migration — **or anything
|
||||||
fn acquire(project_id: &str) -> Option<Self> {
|
/// else** — already holds this project.
|
||||||
let mut set = active_migrations()
|
///
|
||||||
.lock()
|
/// The error string is the point. This returned `Option`, and all three
|
||||||
.unwrap_or_else(|e| e.into_inner());
|
/// callers replaced the discarded reason with a sentence about a migration
|
||||||
if !set.insert(project_id.to_string()) {
|
/// — so a user blocked by a *compaction*, a reset or a cache clear was told
|
||||||
return None;
|
/// to wait for a base update that was not running, with nothing in the UI
|
||||||
}
|
/// that could ever name what actually held the project.
|
||||||
Some(Self(project_id.to_string()))
|
/// `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
|
||||||
impl Drop for ActiveGuard {
|
/// base update" — for confirm and rollback as well as for the migration
|
||||||
fn drop(&mut self) {
|
/// itself. That is the class all three belong to, and splitting it would
|
||||||
active_migrations()
|
/// mean a `ProjectOp` variant per command: the wrong place to encode a
|
||||||
.lock()
|
/// verb, for a phrase that is at worst imprecise where the old one was
|
||||||
.unwrap_or_else(|e| e.into_inner())
|
/// simply wrong.
|
||||||
.remove(&self.0);
|
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,
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<MigrationReport, String> {
|
) -> Result<MigrationReport, String> {
|
||||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
let _guard = match ActiveGuard::acquire(&project_id) {
|
||||||
return Ok(MigrationReport::failed_preflight(
|
Ok(guard) => guard,
|
||||||
"A migration is already running for this project.",
|
Err(busy) => return Ok(MigrationReport::failed_preflight(&busy)),
|
||||||
));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let existing = migration_store::load(&project_id)?;
|
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...");
|
emit_progress(&app_handle, &project_id, "Stopping the container...");
|
||||||
let _ = state
|
let _ = state
|
||||||
.projects_store
|
.projects_store
|
||||||
@@ -805,12 +858,7 @@ pub async fn confirm_migration(
|
|||||||
let _ = &state;
|
let _ = &state;
|
||||||
// Confirming drops the only way back. Doing that underneath a running
|
// Confirming drops the only way back. Doing that underneath a running
|
||||||
// migration would delete the pin it is relying on mid-flight.
|
// migration would delete the pin it is relying on mid-flight.
|
||||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
let _guard = ActiveGuard::acquire(&project_id)?;
|
||||||
return Err(
|
|
||||||
"A container base update is running for this project right now. Wait for it to finish."
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
let Some(mstate) = migration_store::load(&project_id)? else {
|
let Some(mstate) = migration_store::load(&project_id)? else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -833,6 +881,16 @@ pub async fn confirm_migration(
|
|||||||
migration_store::clear_staging(&project_id)?;
|
migration_store::clear_staging(&project_id)?;
|
||||||
migration_store::clear(&project_id)?;
|
migration_store::clear(&project_id)?;
|
||||||
log::info!("Migration confirmed for project {}", 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,12 +905,7 @@ pub async fn rollback_migration(
|
|||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let Some(_guard) = ActiveGuard::acquire(&project_id) else {
|
let _guard = ActiveGuard::acquire(&project_id)?;
|
||||||
return Err(
|
|
||||||
"A container base update is running for this project right now. Wait for it to finish."
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut project = state
|
let mut project = state
|
||||||
.projects_store
|
.projects_store
|
||||||
@@ -924,6 +977,16 @@ pub async fn rollback_migration(
|
|||||||
let _ = mig::untag_image(&rollback_ref).await;
|
let _ = mig::untag_image(&rollback_ref).await;
|
||||||
migration_store::clear_staging(&project_id)?;
|
migration_store::clear_staging(&project_id)?;
|
||||||
migration_store::clear(&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(
|
emit_progress(
|
||||||
&app_handle,
|
&app_handle,
|
||||||
&project_id,
|
&project_id,
|
||||||
@@ -955,9 +1018,203 @@ pub async fn get_migration_state(
|
|||||||
pub async fn reconcile_migration(project: &Project, app_handle: &tauri::AppHandle) {
|
pub async fn reconcile_migration(project: &Project, app_handle: &tauri::AppHandle) {
|
||||||
// A migration running right now is indistinguishable from a crashed one
|
// A migration running right now is indistinguishable from a crashed one
|
||||||
// from the outside; only this process knows the difference.
|
// 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;
|
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) {
|
let state = match migration_store::load(&project.id) {
|
||||||
Ok(Some(s)) => s,
|
Ok(Some(s)) => s,
|
||||||
Ok(None) => return,
|
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!(
|
Err(e) => log::warn!(
|
||||||
"Could not read the migration record for {} while cleaning up: {}",
|
"Could not read the migration record for {} while cleaning up: {}",
|
||||||
project_id,
|
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_staging(project_id);
|
||||||
let _ = migration_store::clear(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 {
|
fn default_docker_socket() -> String {
|
||||||
@@ -1661,6 +1938,32 @@ fn summarize(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
fn byte_sizes_read_the_way_a_disk_warning_should() {
|
||||||
assert_eq!(human_bytes(512), "512 B");
|
assert_eq!(human_bytes(512), "512 B");
|
||||||
@@ -1819,16 +2122,23 @@ mod tests {
|
|||||||
{
|
{
|
||||||
let g = ActiveGuard::acquire(id).expect("first acquire must succeed");
|
let g = ActiveGuard::acquire(id).expect("first acquire must succeed");
|
||||||
assert!(is_migrating(id));
|
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!(
|
assert!(
|
||||||
ActiveGuard::acquire(id).is_none(),
|
refused.contains("base update"),
|
||||||
"a second concurrent migration must be refused"
|
"the refusal must name the holder: {}",
|
||||||
|
refused
|
||||||
);
|
);
|
||||||
drop(g);
|
drop(g);
|
||||||
}
|
}
|
||||||
assert!(!is_migrating(id), "the guard must release on drop");
|
assert!(!is_migrating(id), "the guard must release on drop");
|
||||||
// …including when the migration bailed out through an early return.
|
// …including when the migration bailed out through an early return.
|
||||||
fn early_return(id: &str) -> Option<()> {
|
fn early_return(id: &str) -> Option<()> {
|
||||||
let _g = ActiveGuard::acquire(id)?;
|
let _g = ActiveGuard::acquire(id).ok()?;
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
assert!(early_return(id).is_none());
|
assert!(early_return(id).is_none());
|
||||||
@@ -1842,4 +2152,225 @@ mod tests {
|
|||||||
assert!(!r.rollback_available);
|
assert!(!r.rollback_available);
|
||||||
assert!(r.packages_requested.is_empty());
|
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 migration_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
|
pub mod settings_export_commands;
|
||||||
pub mod stt_commands;
|
pub mod stt_commands;
|
||||||
pub mod terminal_commands;
|
pub mod terminal_commands;
|
||||||
pub mod update_commands;
|
pub mod update_commands;
|
||||||
|
|||||||
@@ -10,12 +10,72 @@ pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, Str
|
|||||||
Ok(state.settings_store.get())
|
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]
|
#[tauri::command]
|
||||||
pub async fn update_settings(
|
pub async fn update_settings(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<AppSettings, String> {
|
) -> Result<AppSettings, String> {
|
||||||
let before = state.settings_store.get();
|
let before = state.settings_store.get();
|
||||||
|
|
||||||
|
validate_settings_update(&before, &settings)?;
|
||||||
|
|
||||||
let saved = state.settings_store.update(settings)?;
|
let saved = state.settings_store.update(settings)?;
|
||||||
|
|
||||||
// Persisting a setting is not the same as applying it. The gateway is the
|
// Persisting a setting is not the same as applying it. The gateway is the
|
||||||
@@ -90,7 +150,10 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
|||||||
GatewayAction::StopIfRunning => {
|
GatewayAction::StopIfRunning => {
|
||||||
log::info!("Model gateway disabled in settings — stopping the container");
|
log::info!("Model gateway disabled in settings — stopping the container");
|
||||||
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
if let Err(e) = docker::gateway::stop_gateway_container().await {
|
||||||
log::error!("Failed to stop the model gateway after it was disabled: {}", e);
|
log::error!(
|
||||||
|
"Failed to stop the model gateway after it was disabled: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GatewayAction::RestartIfRunning => {
|
GatewayAction::RestartIfRunning => {
|
||||||
@@ -106,10 +169,7 @@ async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn pull_image(
|
pub async fn pull_image(image_name: String, app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
image_name: String,
|
|
||||||
app_handle: tauri::AppHandle,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
docker::pull_image(&image_name, move |msg| {
|
docker::pull_image(&image_name, move |msg| {
|
||||||
let _ = app_handle.emit("image-pull-progress", msg);
|
let _ = app_handle.emit("image-pull-progress", msg);
|
||||||
@@ -302,7 +362,10 @@ mod tests {
|
|||||||
let before = enabled_gateway();
|
let before = enabled_gateway();
|
||||||
let mut after = before.clone();
|
let mut after = before.clone();
|
||||||
after.enabled = false;
|
after.enabled = false;
|
||||||
assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning);
|
assert_eq!(
|
||||||
|
gateway_action(&before, &after),
|
||||||
|
GatewayAction::StopIfRunning
|
||||||
|
);
|
||||||
// Still true when it was already off — a stray running container is
|
// Still true when it was already off — a stray running container is
|
||||||
// still a container that shouldn't be up.
|
// still a container that shouldn't be up.
|
||||||
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning);
|
||||||
|
|||||||
@@ -0,0 +1,654 @@
|
|||||||
|
//! Settings export/import — see triple-c#35.
|
||||||
|
//!
|
||||||
|
//! Exports the *host* environment (global `AppSettings` plus the global
|
||||||
|
//! secrets kept in the OS keychain: the shared Claude Code OAuth login and
|
||||||
|
//! the model gateway's two keys), encrypted with a user-chosen password —
|
||||||
|
//! see `storage::settings_crypto` for the actual cryptography. Deliberately
|
||||||
|
//! out of scope: per-project settings, per-project secrets, and anything
|
||||||
|
//! living in a project's Docker volumes.
|
||||||
|
//!
|
||||||
|
//! **The save/open dialogs are opened from Rust**, the same pattern
|
||||||
|
//! `file_commands.rs`'s `pick_save_path`/`pick_files_to_upload` already
|
||||||
|
//! establish and document at length: a frontend-driven dialog handing Rust a
|
||||||
|
//! host path string is the exact shape of bug that produced this app's past
|
||||||
|
//! criticals, so the boundary here is drawn the same place. The frontend can
|
||||||
|
//! ask for a picker; it cannot name a host path as an *input*. `preview_
|
||||||
|
//! settings_import` resolves the chosen path itself and remembers it
|
||||||
|
//! (`AppState::pending_settings_import`) so `apply_settings_import` re-reads
|
||||||
|
//! the same file without the path ever crossing back over IPC.
|
||||||
|
//!
|
||||||
|
//! The *decrypted payload* is not cached between preview and apply — the
|
||||||
|
//! password the frontend passes to each call is what it already held for
|
||||||
|
//! the first, not a fresh secret extracted from the user, but nothing here
|
||||||
|
//! keeps the plaintext itself — export/import secrets included — around for
|
||||||
|
//! longer than one command's execution; `apply_settings_import` re-decrypts
|
||||||
|
//! the file rather than reusing anything `preview_settings_import` computed.
|
||||||
|
//!
|
||||||
|
//! **This is new attack surface**: a settings export is a file one person
|
||||||
|
//! can hand another and ask them to import, together with a password, and
|
||||||
|
//! `apply_settings_import` applies whatever `AppSettings` it decrypts to
|
||||||
|
//! wholesale — see the module doc on `models::settings_export` for the
|
||||||
|
//! `web_terminal.access_token` carve-out a review of this feature found,
|
||||||
|
//! and treat that as the standing example of the class of thing to keep
|
||||||
|
//! checking for here, not a one-off fixed bug.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tauri::State;
|
||||||
|
use tauri_plugin_dialog::DialogExt;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::models::{
|
||||||
|
AppSettings, ExportedSecrets, SettingsExportPayload, SettingsImportOutcome,
|
||||||
|
SettingsImportPreview, SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
};
|
||||||
|
use crate::storage::{secure, settings_crypto};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// What `preview_settings_import` pins so `apply_settings_import` can tell
|
||||||
|
/// whether the file it's about to re-read is the same one the user actually
|
||||||
|
/// saw a preview of. Confirming a preview is only meaningful if it's binding
|
||||||
|
/// on what gets applied — without this, a file replaced on disk between the
|
||||||
|
/// two calls (this app's own stated threat model is a file shared between
|
||||||
|
/// people, which may sit in a synced or shared directory) would decrypt and
|
||||||
|
/// apply silently different content than what the confirmation dialog showed.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PendingSettingsImport {
|
||||||
|
path: PathBuf,
|
||||||
|
ciphertext_hash: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_ciphertext(data: &[u8]) -> [u8; 32] {
|
||||||
|
Sha256::digest(data).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_EXTENSION: &str = "triplec";
|
||||||
|
|
||||||
|
/// Enforced here, not only in the export modal: the frontend's minimum is a
|
||||||
|
/// UX nudge, but `export_settings` is the actual boundary a weak password
|
||||||
|
/// has to cross, and Argon2id's memory-hardness buys little against an
|
||||||
|
/// attacker who can just try a three-character password directly.
|
||||||
|
const MIN_PASSWORD_LEN: usize = 8;
|
||||||
|
|
||||||
|
fn suggested_export_name() -> String {
|
||||||
|
// Timestamped so exporting more than once doesn't silently overwrite an
|
||||||
|
// earlier file just because the save dialog defaults to the same name.
|
||||||
|
format!(
|
||||||
|
"triple-c-settings-{}.{}",
|
||||||
|
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||||
|
FILE_EXTENSION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pick_export_save_path(window: &tauri::Window, suggested: &str) -> Option<PathBuf> {
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
window
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_parent(window)
|
||||||
|
.set_title("Export Triple-C settings")
|
||||||
|
.set_file_name(suggested)
|
||||||
|
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||||
|
.save_file(move |picked| {
|
||||||
|
let _ = tx.send(picked);
|
||||||
|
});
|
||||||
|
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pick_import_open_path(window: &tauri::Window) -> Option<PathBuf> {
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
window
|
||||||
|
.dialog()
|
||||||
|
.file()
|
||||||
|
.set_parent(window)
|
||||||
|
.set_title("Import Triple-C settings")
|
||||||
|
.add_filter("Triple-C settings export", &[FILE_EXTENSION])
|
||||||
|
.pick_file(move |picked| {
|
||||||
|
let _ = tx.send(picked);
|
||||||
|
});
|
||||||
|
rx.await.ok().flatten().and_then(|p| p.into_path().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gather the current global secrets, and hand back the `AppSettings` to
|
||||||
|
/// export with the web-terminal token blanked out of it — see the module
|
||||||
|
/// doc comment on `models::settings_export` for why that field cannot
|
||||||
|
/// travel through `settings` like the rest of this struct.
|
||||||
|
///
|
||||||
|
/// A missing keychain secret reads as `None` — a keychain read failure is
|
||||||
|
/// treated as "nothing to export" for that one entry rather than aborting
|
||||||
|
/// the whole export, matching how the rest of this app degrades a keychain
|
||||||
|
/// error to "absent" (`has_claude_oauth_token`, `has_gateway_api_key`)
|
||||||
|
/// rather than surfacing it as a hard failure.
|
||||||
|
fn split_settings_and_secrets(current: AppSettings) -> (AppSettings, ExportedSecrets) {
|
||||||
|
let mut settings = current;
|
||||||
|
let web_terminal_access_token = settings.web_terminal.access_token.take();
|
||||||
|
|
||||||
|
let secrets = ExportedSecrets {
|
||||||
|
claude_oauth_token: secure::get_claude_oauth_token().unwrap_or_default(),
|
||||||
|
gateway_api_key: secure::get_gateway_api_key().unwrap_or_default(),
|
||||||
|
gateway_master_key: secure::get_gateway_master_key().unwrap_or_default(),
|
||||||
|
web_terminal_access_token,
|
||||||
|
};
|
||||||
|
|
||||||
|
(settings, secrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the current global settings and secrets to a password-encrypted
|
||||||
|
/// file. `Ok(false)` means the save dialog was dismissed — not an error, and
|
||||||
|
/// deliberately distinguishable from one so the frontend shows nothing
|
||||||
|
/// rather than a "failed" toast for a plain cancel.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_settings(
|
||||||
|
password: String,
|
||||||
|
window: tauri::Window,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
// `.chars().count()` — Unicode scalar values, not bytes — to stay as
|
||||||
|
// close as this pair of languages allows to the frontend's `.length`
|
||||||
|
// check (UTF-16 code units); the two only diverge on astral-plane
|
||||||
|
// characters, which no reasonable password touches.
|
||||||
|
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||||
|
return Err(format!(
|
||||||
|
"Use a password of at least {} characters.",
|
||||||
|
MIN_PASSWORD_LEN
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(dest) = pick_export_save_path(&window, &suggested_export_name()).await else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (settings, secrets) = split_settings_and_secrets(state.settings_store.get());
|
||||||
|
if secrets.is_empty() {
|
||||||
|
log::info!("Exporting settings with no global secrets configured on this machine");
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = SettingsExportPayload {
|
||||||
|
format_version: SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
app_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
settings,
|
||||||
|
secrets,
|
||||||
|
};
|
||||||
|
|
||||||
|
let plaintext = Zeroizing::new(
|
||||||
|
serde_json::to_vec(&payload)
|
||||||
|
.map_err(|e| format!("Failed to prepare settings for export: {}", e))?,
|
||||||
|
);
|
||||||
|
let encrypted = settings_crypto::encrypt(&plaintext, &password)?;
|
||||||
|
|
||||||
|
std::fs::write(&dest, &encrypted).map_err(|e| format!("Failed to write export file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a file picker, decrypt the chosen file with `password`, and return a
|
||||||
|
/// preview (counts and presence flags only — never a secret value) for a
|
||||||
|
/// confirmation UI. `Ok(None)` means the picker was dismissed.
|
||||||
|
///
|
||||||
|
/// Remembers the resolved path *and a hash of the file's ciphertext* in
|
||||||
|
/// `AppState::pending_settings_import` for `apply_settings_import` to check
|
||||||
|
/// against — does **not** remember the decrypted payload itself, so the
|
||||||
|
/// password must be supplied again to actually apply it — seeing the preview
|
||||||
|
/// is not the same as committing to it. The hash exists so it also can't be
|
||||||
|
/// swapped out from under that commitment: `apply_settings_import` refuses to
|
||||||
|
/// proceed if the file on disk no longer matches what was just previewed.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn preview_settings_import(
|
||||||
|
password: String,
|
||||||
|
window: tauri::Window,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Option<SettingsImportPreview>, String> {
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err("A password is required to open a settings export.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(path) = pick_import_open_path(&window).await else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let encrypted = std::fs::read(&path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||||
|
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||||
|
let preview = SettingsImportPreview::from_payload(&payload);
|
||||||
|
|
||||||
|
*state.pending_settings_import.lock().await = Some(PendingSettingsImport {
|
||||||
|
path,
|
||||||
|
ciphertext_hash: hash_ciphertext(&encrypted),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Some(preview))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the import a prior `preview_settings_import` call resolved a path
|
||||||
|
/// for. Fails if no preview is pending — this is not a general "decrypt and
|
||||||
|
/// apply this file" entry point, deliberately: seeing the preview first is
|
||||||
|
/// required, not just encouraged, since it is the only place a user is told
|
||||||
|
/// what an import is about to touch before it touches it. That requirement
|
||||||
|
/// is only real if the file can't change out from under it, so this also
|
||||||
|
/// refuses to proceed if the file's ciphertext no longer matches the hash
|
||||||
|
/// `preview_settings_import` pinned — a file replaced on disk between the
|
||||||
|
/// two calls (this feature's own threat model is a file shared between
|
||||||
|
/// people, which may sit in a synced or shared directory) must not be able
|
||||||
|
/// to apply silently different content than what the confirmation dialog
|
||||||
|
/// showed.
|
||||||
|
///
|
||||||
|
/// Global settings are replaced wholesale — an import is "restore this
|
||||||
|
/// environment," not a field-by-field merge. Global secrets are handled
|
||||||
|
/// differently and on purpose: **only secrets actually present in the
|
||||||
|
/// import are written**; a secret the export doesn't have is left alone on
|
||||||
|
/// this machine rather than cleared, because an absent secret in the export
|
||||||
|
/// means "the source machine never had this configured," not "delete this
|
||||||
|
/// on import." A user who wants to clear a secret already has dedicated UI
|
||||||
|
/// for that (signing out of shared auth, clearing the gateway key).
|
||||||
|
///
|
||||||
|
/// Order matters here, twice over.
|
||||||
|
///
|
||||||
|
/// First: the imported settings are **validated before any secret is
|
||||||
|
/// written**, using the same checks `update_settings` itself runs
|
||||||
|
/// (`settings_commands::validate_settings_update`). Restoring a secret is
|
||||||
|
/// hard to undo unnoticed — a stale env-var-name rejection or a disallowed
|
||||||
|
/// host path used to be caught only when `update_settings` ran, by which
|
||||||
|
/// point the three keychain secrets below were already overwritten with the
|
||||||
|
/// file's, each with a fresh rotation id, silently flagging every project
|
||||||
|
/// container for recreation — while the error the user saw talked only
|
||||||
|
/// about the rejected setting and said nothing about the credentials that
|
||||||
|
/// had already moved. Failing this check first makes a rejected import
|
||||||
|
/// leave nothing touched, matching what "the import failed" is supposed to
|
||||||
|
/// mean.
|
||||||
|
///
|
||||||
|
/// Second, among the things that *do* get written: secrets are restored
|
||||||
|
/// **before** the settings replace runs (which is what triggers
|
||||||
|
/// `reconcile_gateway`), so a gateway recreation that replace provokes sees
|
||||||
|
/// the final key material rather than racing it — restoring the other way
|
||||||
|
/// round left a real window where the running gateway and the keychain
|
||||||
|
/// briefly disagreed. A gateway *secret* alone (same shape, new key) is
|
||||||
|
/// invisible to `reconcile_gateway`'s shape comparison, so this additionally
|
||||||
|
/// nudges a running gateway container to recreate itself whenever a secret
|
||||||
|
/// this import carried was actually written — otherwise the running
|
||||||
|
/// container keeps serving the old key material indefinitely while every
|
||||||
|
/// project container is handed the new one.
|
||||||
|
///
|
||||||
|
/// A keychain write failing is reported back rather than only logged: an
|
||||||
|
/// import that silently restores two of three secrets but not the third
|
||||||
|
/// must not read as unqualified success.
|
||||||
|
///
|
||||||
|
/// The pending import is only cleared on success. A failure here (rejected
|
||||||
|
/// by the validation above, a stale-file mismatch, or some other error)
|
||||||
|
/// leaves it pending so the frontend can let the user retry `apply` without
|
||||||
|
/// making them pick the file and re-enter the password again — the
|
||||||
|
/// preview's job was confirming *what* to import, not spending the one
|
||||||
|
/// attempt at applying it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn apply_settings_import(
|
||||||
|
password: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<SettingsImportOutcome, String> {
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err("A password is required to import settings.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let pending = state
|
||||||
|
.pending_settings_import
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "No import is pending — choose a file first.".to_string())?;
|
||||||
|
|
||||||
|
let encrypted = std::fs::read(&pending.path)
|
||||||
|
.map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||||
|
if hash_ciphertext(&encrypted) != pending.ciphertext_hash {
|
||||||
|
return Err(
|
||||||
|
"This file changed since you reviewed it — choose it again to see an up-to-date preview."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let payload = read_and_decrypt_bytes(&encrypted, &password)?;
|
||||||
|
|
||||||
|
let current = state.settings_store.get();
|
||||||
|
|
||||||
|
// The web-terminal token lives inside `AppSettings` itself rather than
|
||||||
|
// the keychain, so "leave an absent secret alone" has to be done by
|
||||||
|
// hand here: carry the destination's current token forward when the
|
||||||
|
// import doesn't have one, instead of letting the wholesale replace
|
||||||
|
// below blank it (every export writes `None` there — see
|
||||||
|
// `split_settings_and_secrets`).
|
||||||
|
let mut settings = payload.settings;
|
||||||
|
settings.web_terminal.access_token = non_blank(payload.secrets.web_terminal_access_token)
|
||||||
|
.or_else(|| current.web_terminal.access_token.clone());
|
||||||
|
|
||||||
|
crate::commands::settings_commands::validate_settings_update(¤t, &settings)?;
|
||||||
|
|
||||||
|
let mut secret_restore_warnings = Vec::new();
|
||||||
|
let mut gateway_secret_changed = false;
|
||||||
|
|
||||||
|
if let Some(token) = non_blank(payload.secrets.claude_oauth_token) {
|
||||||
|
if let Err(e) = secure::store_claude_oauth_token(&token) {
|
||||||
|
log::warn!(
|
||||||
|
"Settings import: could not restore the shared Claude login: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
secret_restore_warnings
|
||||||
|
.push(format!("Could not restore your shared Claude login: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(key) = non_blank(payload.secrets.gateway_api_key) {
|
||||||
|
match secure::store_gateway_api_key(&key) {
|
||||||
|
Ok(()) => gateway_secret_changed = true,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Settings import: could not restore the gateway provider API key: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
secret_restore_warnings.push(format!(
|
||||||
|
"Could not restore the gateway provider API key: {}",
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(key) = non_blank(payload.secrets.gateway_master_key) {
|
||||||
|
match secure::store_gateway_master_key(&key) {
|
||||||
|
Ok(()) => gateway_secret_changed = true,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Settings import: could not restore the gateway master key: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
secret_restore_warnings
|
||||||
|
.push(format!("Could not restore the gateway master key: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved =
|
||||||
|
crate::commands::settings_commands::update_settings(settings, state.clone()).await?;
|
||||||
|
|
||||||
|
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
|
||||||
|
// *shape* — port, provider, base URL, models — because that's what's
|
||||||
|
// rendered into the container's config. A secret changing with the shape
|
||||||
|
// held constant is invisible to it, so a running gateway container would
|
||||||
|
// otherwise keep serving the old key material forever after an import
|
||||||
|
// that restored a new one, while `docker::gateway`'s own fingerprint
|
||||||
|
// (which does include the secret rotation id) means the *next* unrelated
|
||||||
|
// settings save would suddenly and confusingly recreate it instead.
|
||||||
|
if gateway_secret_changed && saved.gateway.enabled {
|
||||||
|
match crate::docker::gateway::gateway_container_presence().await {
|
||||||
|
Ok((true, true)) => {
|
||||||
|
if let Err(e) = crate::docker::gateway::ensure_gateway_running(&saved.gateway).await
|
||||||
|
{
|
||||||
|
log::error!(
|
||||||
|
"Settings import: could not apply the restored gateway credentials to the running gateway container: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => log::debug!("Settings import: gateway reconcile skipped ({})", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pending_settings_import.lock().await.take();
|
||||||
|
|
||||||
|
Ok(SettingsImportOutcome {
|
||||||
|
settings: saved,
|
||||||
|
secret_restore_warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_blank(value: Option<String>) -> Option<String> {
|
||||||
|
value.filter(|v| !v.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the field `read_and_decrypt` needs before deciding whether the rest
|
||||||
|
/// of the payload is even worth attempting to parse.
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct FormatVersionProbe {
|
||||||
|
format_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and decrypt an export file at `path`, then parse it — see
|
||||||
|
/// `read_and_decrypt_bytes` for why the format-version check runs before the
|
||||||
|
/// full parse. Every real caller already has the file's bytes in hand by the
|
||||||
|
/// time it needs this (`preview_settings_import`/`apply_settings_import`
|
||||||
|
/// both hash the ciphertext first) and calls `read_and_decrypt_bytes`
|
||||||
|
/// directly to avoid reading the file twice; this path-based wrapper only
|
||||||
|
/// exists now for tests that don't need that.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn read_and_decrypt(path: &Path, password: &str) -> Result<SettingsExportPayload, String> {
|
||||||
|
let encrypted =
|
||||||
|
std::fs::read(path).map_err(|e| format!("Failed to read export file: {}", e))?;
|
||||||
|
read_and_decrypt_bytes(&encrypted, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt and parse an already-read export file's bytes, checking the
|
||||||
|
/// format version **before** attempting to deserialize the full payload.
|
||||||
|
///
|
||||||
|
/// That ordering is not just tidiness: a version bump that isn't
|
||||||
|
/// deserialize-compatible (a field's type changes, not just a new
|
||||||
|
/// `#[serde(default)]`-covered one) is exactly the case this check exists
|
||||||
|
/// for, and parsing the full struct first would fail on the shape mismatch
|
||||||
|
/// before the version check ever ran, surfacing a raw parse error instead
|
||||||
|
/// of "update Triple-C" — and, more seriously, `serde_json`'s type-mismatch
|
||||||
|
/// errors quote the offending value inline. This file is not attacker
|
||||||
|
/// content in the usual sense (it must still decrypt under the right
|
||||||
|
/// password), but the plaintext it decrypts to can hold a live credential,
|
||||||
|
/// so neither error path below ever interpolates what `serde_json`
|
||||||
|
/// actually says — only a fixed, generic message.
|
||||||
|
fn read_and_decrypt_bytes(encrypted: &[u8], password: &str) -> Result<SettingsExportPayload, String> {
|
||||||
|
let plaintext = settings_crypto::decrypt(encrypted, password)?;
|
||||||
|
|
||||||
|
let probe: FormatVersionProbe = serde_json::from_slice(&plaintext)
|
||||||
|
.map_err(|_| "This file doesn't look like a valid settings export.".to_string())?;
|
||||||
|
if probe.format_version > SETTINGS_EXPORT_FORMAT_VERSION {
|
||||||
|
return Err(format!(
|
||||||
|
"This export was made by a newer version of Triple-C (format {}, this app supports up to {}). \
|
||||||
|
Update Triple-C before importing it.",
|
||||||
|
probe.format_version, SETTINGS_EXPORT_FORMAT_VERSION
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_slice(&plaintext).map_err(|_| {
|
||||||
|
"This file doesn't look like a valid settings export (unexpected shape).".to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_blank_treats_whitespace_only_as_absent() {
|
||||||
|
assert_eq!(non_blank(Some(" ".to_string())), None);
|
||||||
|
assert_eq!(non_blank(Some("".to_string())), None);
|
||||||
|
assert_eq!(non_blank(None), None);
|
||||||
|
assert_eq!(non_blank(Some(" a ".to_string())), Some(" a ".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ciphertext_hashing_is_deterministic_and_tamper_sensitive() {
|
||||||
|
// What `apply_settings_import` compares against the pinned hash from
|
||||||
|
// `preview_settings_import` to detect a file swapped out from under a
|
||||||
|
// pending import — this only defends anything if identical bytes
|
||||||
|
// always hash identically and any change to those bytes changes the
|
||||||
|
// hash.
|
||||||
|
let bytes = b"pretend this is an encrypted export file";
|
||||||
|
assert_eq!(hash_ciphertext(bytes), hash_ciphertext(bytes));
|
||||||
|
|
||||||
|
let mut tampered = bytes.to_vec();
|
||||||
|
tampered[0] ^= 0xFF;
|
||||||
|
assert_ne!(hash_ciphertext(bytes), hash_ciphertext(&tampered));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_export(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
name: &str,
|
||||||
|
payload: &SettingsExportPayload,
|
||||||
|
password: &str,
|
||||||
|
) -> PathBuf {
|
||||||
|
write_raw_export(dir, name, &serde_json::to_value(payload).unwrap(), password)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `write_export`, but takes an arbitrary `serde_json::Value` rather
|
||||||
|
/// than a real `SettingsExportPayload` — for fixtures that are
|
||||||
|
/// deliberately not shape-compatible, which the typed helper above can't
|
||||||
|
/// produce at all.
|
||||||
|
fn write_raw_export(
|
||||||
|
dir: &std::path::Path,
|
||||||
|
name: &str,
|
||||||
|
value: &serde_json::Value,
|
||||||
|
password: &str,
|
||||||
|
) -> PathBuf {
|
||||||
|
let plaintext = serde_json::to_vec(value).unwrap();
|
||||||
|
let encrypted = settings_crypto::encrypt(&plaintext, password).unwrap();
|
||||||
|
let path = dir.join(name);
|
||||||
|
std::fs::write(&path, &encrypted).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splitting_settings_moves_the_web_terminal_token_out_rather_than_copying_it() {
|
||||||
|
let mut settings = AppSettings::default();
|
||||||
|
settings.web_terminal.access_token = Some("super-secret-token".to_string());
|
||||||
|
|
||||||
|
let (settings, secrets) = split_settings_and_secrets(settings);
|
||||||
|
|
||||||
|
assert_eq!(settings.web_terminal.access_token, None);
|
||||||
|
assert_eq!(
|
||||||
|
secrets.web_terminal_access_token,
|
||||||
|
Some("super-secret-token".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn splitting_settings_with_no_token_leaves_it_absent_on_both_sides() {
|
||||||
|
let (settings, secrets) = split_settings_and_secrets(AppSettings::default());
|
||||||
|
|
||||||
|
assert_eq!(settings.web_terminal.access_token, None);
|
||||||
|
assert_eq!(secrets.web_terminal_access_token, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_payload(format_version: u32) -> SettingsExportPayload {
|
||||||
|
SettingsExportPayload {
|
||||||
|
format_version,
|
||||||
|
exported_at: "2026-08-27T00:00:00Z".to_string(),
|
||||||
|
app_version: "0.4.14".to_string(),
|
||||||
|
settings: AppSettings::default(),
|
||||||
|
secrets: ExportedSecrets::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"triple-c-settings-export-test-{}-{}",
|
||||||
|
name,
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_from_a_newer_format_is_refused_before_the_full_shape_is_parsed() {
|
||||||
|
// Shape-incompatible with the *current* `SettingsExportPayload` (a
|
||||||
|
// future version could easily have changed `settings` from an object
|
||||||
|
// to something else) as well as newer — so this only passes under
|
||||||
|
// the probe-first ordering. Parsing the full struct first (the old
|
||||||
|
// behavior) would fail on the shape mismatch and never reach the
|
||||||
|
// version check, producing the "unexpected shape" message instead of
|
||||||
|
// "newer version" / "Update Triple-C".
|
||||||
|
let dir = temp_dir("newer-format");
|
||||||
|
let path = write_raw_export(
|
||||||
|
&dir,
|
||||||
|
"export.triplec",
|
||||||
|
&serde_json::json!({
|
||||||
|
"format_version": SETTINGS_EXPORT_FORMAT_VERSION + 1,
|
||||||
|
"exported_at": "2026-08-27T00:00:00Z",
|
||||||
|
"app_version": "9.9.9",
|
||||||
|
"settings": "this-app-version-stores-settings-differently",
|
||||||
|
"secrets": {},
|
||||||
|
}),
|
||||||
|
"correct password",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||||
|
assert!(err.contains("newer version"), "unexpected message: {}", err);
|
||||||
|
assert!(err.contains("Update Triple-C"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_at_the_current_format_is_accepted() {
|
||||||
|
let dir = temp_dir("current-format");
|
||||||
|
let path = write_export(
|
||||||
|
&dir,
|
||||||
|
"export.triplec",
|
||||||
|
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||||
|
"correct password",
|
||||||
|
);
|
||||||
|
|
||||||
|
let payload = read_and_decrypt(&path, "correct password").unwrap();
|
||||||
|
assert_eq!(payload.format_version, SETTINGS_EXPORT_FORMAT_VERSION);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_payload_produces_a_generic_error_not_a_raw_serde_message() {
|
||||||
|
// A `format_version` the probe accepts, but a `settings` field of
|
||||||
|
// the wrong *type* rather than just a missing field — this is what
|
||||||
|
// makes `serde_json` produce an "invalid type: string `...`, expected
|
||||||
|
// struct AppSettings" error that quotes the offending value
|
||||||
|
// verbatim. That value here stands in for plaintext that, in a real
|
||||||
|
// export, could be a live credential — the assertion below is only
|
||||||
|
// meaningful against a fixture that actually exercises serde's
|
||||||
|
// value-quoting behavior, which a merely-missing-field fixture does
|
||||||
|
// not.
|
||||||
|
let dir = temp_dir("malformed");
|
||||||
|
let path = write_raw_export(
|
||||||
|
&dir,
|
||||||
|
"export.triplec",
|
||||||
|
&serde_json::json!({
|
||||||
|
"format_version": SETTINGS_EXPORT_FORMAT_VERSION,
|
||||||
|
"exported_at": "2026-08-27T00:00:00Z",
|
||||||
|
"app_version": "0.4.14",
|
||||||
|
"settings": "NOT-A-REAL-CREDENTIAL-abc123",
|
||||||
|
"secrets": {},
|
||||||
|
}),
|
||||||
|
"correct password",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = read_and_decrypt(&path, "correct password").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
!err.contains("NOT-A-REAL-CREDENTIAL-abc123"),
|
||||||
|
"leaked plaintext into the error: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
assert!(err.contains("doesn't look like a valid settings export"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wrong_password_is_reported_without_a_version_check_ever_running() {
|
||||||
|
let dir = temp_dir("wrong-password");
|
||||||
|
let path = write_export(
|
||||||
|
&dir,
|
||||||
|
"export.triplec",
|
||||||
|
&sample_payload(SETTINGS_EXPORT_FORMAT_VERSION),
|
||||||
|
"correct password",
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = read_and_decrypt(&path, "wrong password").unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("Wrong password"),
|
||||||
|
"unexpected message: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,31 +196,64 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
host_path: String,
|
host_path: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<String, String> {
|
) -> 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 container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||||
|
|
||||||
let meta = tokio::fs::metadata(&host_path)
|
let meta = tokio::fs::metadata(&host_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
|
||||||
if meta.is_dir() {
|
// `!is_file()`, not `!is_dir()`. A FIFO is neither a directory nor a
|
||||||
return Err(format!("{} is a directory — drop individual files", host_path));
|
// 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
|
// Guard against ballooning host RAM: the file is packed into an in-memory
|
||||||
// tar before upload, so cap the size of a dropped file.
|
// tar before upload, so cap the size of a dropped file. The ceiling lives
|
||||||
const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB
|
// 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 {
|
if meta.len() > MAX_DROP_BYTES {
|
||||||
return Err(format!(
|
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),
|
meta.len() as f64 / (1024.0 * 1024.0),
|
||||||
MAX_DROP_BYTES / (1024 * 1024)
|
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
|
// Ensure the destination directory exists rather than relying on Docker's
|
||||||
// archive extractor to create the parent for the uploaded tar entry.
|
// archive extractor to create the parent for the uploaded tar entry.
|
||||||
@@ -231,7 +264,13 @@ pub async fn upload_host_file_to_terminal(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let file_name = format!("triple-c-drops/{}", base);
|
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]
|
#[tauri::command]
|
||||||
@@ -283,3 +322,37 @@ pub async fn stop_audio_bridge(
|
|||||||
state.exec_manager.close_session(&audio_session_id).await;
|
state.exec_manager.close_session(&audio_session_id).await;
|
||||||
Ok(())
|
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 =
|
const GHCR_TOKEN_URL: &str =
|
||||||
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
|
||||||
|
|
||||||
|
/// The build-time preview suffix, if one was baked in and isn't blank.
|
||||||
|
///
|
||||||
|
/// The bundle version itself (`tauri.conf.json`, `Cargo.toml`, `package.json`)
|
||||||
|
/// is never given a `-preview.<sha>` suffix — `build-app-preview.yml` strips
|
||||||
|
/// it before patching those files, because the Windows MSI's `ProductVersion`
|
||||||
|
/// is a fixed-width numeric field with no room for one, and nothing here can
|
||||||
|
/// verify a change to that without an actual Windows build. `TRIPLE_C_BUILD_SUFFIX`
|
||||||
|
/// is the workaround: set as a build-time env var in the preview workflow
|
||||||
|
/// only, so `option_env!` bakes it into the binary without the bundle version
|
||||||
|
/// ever seeing it. A production build sets nothing, so `option_env!` reads
|
||||||
|
/// `None` here — see triple-c#32.
|
||||||
|
///
|
||||||
|
/// The single source of truth for "is this a preview build": both
|
||||||
|
/// `get_app_version()` (what the About panel shows) and `check_for_updates()`
|
||||||
|
/// (whether a same-numbered release counts as an update — see `pick_update`)
|
||||||
|
/// read this rather than each calling `option_env!` themselves, so the two
|
||||||
|
/// can never silently disagree about which build this is.
|
||||||
|
fn preview_build_suffix() -> Option<&'static str> {
|
||||||
|
option_env!("TRIPLE_C_BUILD_SUFFIX").filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_app_version(base: &str, build_suffix: Option<&str>) -> String {
|
||||||
|
match build_suffix {
|
||||||
|
Some(suffix) if !suffix.is_empty() => format!("{}-{}", base, suffix),
|
||||||
|
_ => base.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_app_version() -> String {
|
pub fn get_app_version() -> String {
|
||||||
env!("CARGO_PKG_VERSION").to_string()
|
format_app_version(env!("CARGO_PKG_VERSION"), preview_build_suffix())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -51,30 +79,20 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
&[".AppImage", ".deb", ".rpm"]
|
&[".AppImage", ".deb", ".rpm"]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter releases that have at least one asset matching the current platform
|
// `current_version` above is always the bare, stripped `CARGO_PKG_VERSION`
|
||||||
let platform_releases: Vec<&GitHubRelease> = releases
|
// — the preview workflow patches `Cargo.toml` with that before compiling,
|
||||||
.iter()
|
// never the `-preview.<sha>`-suffixed one `get_app_version()` reports —
|
||||||
.filter(|r| {
|
// so a preview build and the release it precedes compile to the identical
|
||||||
r.assets.iter().any(|a| {
|
// numeric tuple by construction (see `build-app-preview.yml`'s "highest
|
||||||
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
|
// tag used, +1" computation). A strict `>` therefore never fires for the
|
||||||
})
|
// one release a preview most needs to be offered. `is_preview_build`
|
||||||
})
|
// relaxes that one comparison to `>=` so "there is a real release at my
|
||||||
.collect();
|
// own number" reads as an update, without touching the production case
|
||||||
|
// — see `pick_update`.
|
||||||
|
let is_preview_build = preview_build_suffix().is_some();
|
||||||
|
|
||||||
// Find the latest release with a higher semver version
|
match pick_update(&releases, current_semver, platform_extensions, is_preview_build) {
|
||||||
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
|
Some(release) => {
|
||||||
for release in &platform_releases {
|
|
||||||
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
|
|
||||||
if ver > current_semver {
|
|
||||||
if best.is_none() || ver > best.unwrap().1 {
|
|
||||||
best = Some((release, ver));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match best {
|
|
||||||
Some((release, _)) => {
|
|
||||||
// Only include assets matching the current platform
|
// Only include assets matching the current platform
|
||||||
let assets = release
|
let assets = release
|
||||||
.assets
|
.assets
|
||||||
@@ -105,6 +123,51 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pick the newest available update out of a release list, or `None` if
|
||||||
|
/// nothing beats `current_semver`. Pure and synchronous — split out of
|
||||||
|
/// `check_for_updates` so the prerelease/platform/version filtering can be
|
||||||
|
/// tested without a live HTTP call.
|
||||||
|
///
|
||||||
|
/// Three filters, all of which must pass: not a prerelease (see the long
|
||||||
|
/// comment on `GitHubRelease::prerelease`), at least one asset for this
|
||||||
|
/// platform, and a tag that parses as semver *and* beats what is running. A
|
||||||
|
/// tag that does not parse — `preview-<sha>` (the shape
|
||||||
|
/// `build-app-preview.yml` actually creates release tags with), most
|
||||||
|
/// realistically — is skipped rather than erroring, the same as it always
|
||||||
|
/// has been; nothing here changes what an update tag is expected to look
|
||||||
|
/// like, only what channel it is allowed to come from.
|
||||||
|
///
|
||||||
|
/// `is_preview_build` relaxes "beats" from `>` to `>=`. A preview build's
|
||||||
|
/// `current_semver` is the bare number it was compiled with, which is by
|
||||||
|
/// construction identical to the release it precedes — see the comment at
|
||||||
|
/// `check_for_updates`'s call site — so a strict `>` would never fire for
|
||||||
|
/// exactly the release a preview install most needs to be told about.
|
||||||
|
fn pick_update<'a>(
|
||||||
|
releases: &'a [GitHubRelease],
|
||||||
|
current_semver: (u32, u32, u32),
|
||||||
|
platform_extensions: &[&str],
|
||||||
|
is_preview_build: bool,
|
||||||
|
) -> Option<&'a GitHubRelease> {
|
||||||
|
releases
|
||||||
|
.iter()
|
||||||
|
.filter(|r| !r.prerelease)
|
||||||
|
.filter(|r| {
|
||||||
|
r.assets
|
||||||
|
.iter()
|
||||||
|
.any(|a| platform_extensions.iter().any(|ext| a.name.ends_with(ext)))
|
||||||
|
})
|
||||||
|
.filter_map(|r| parse_semver_from_tag(&r.tag_name).map(|ver| (r, ver)))
|
||||||
|
.filter(|(_, ver)| {
|
||||||
|
if is_preview_build {
|
||||||
|
*ver >= current_semver
|
||||||
|
} else {
|
||||||
|
*ver > current_semver
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.max_by_key(|(_, ver)| *ver)
|
||||||
|
.map(|(r, _)| r)
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
|
||||||
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
|
||||||
let clean = version.trim_start_matches('v');
|
let clean = version.trim_start_matches('v');
|
||||||
@@ -131,6 +194,120 @@ fn extract_version_from_tag(tag: &str) -> Option<String> {
|
|||||||
Some(format!("{}.{}.{}", major, minor, patch))
|
Some(format!("{}.{}.{}", major, minor, patch))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::GitHubAsset;
|
||||||
|
|
||||||
|
// ── format_app_version ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_production_build_reports_the_bare_version() {
|
||||||
|
assert_eq!(format_app_version("0.4.12", None), "0.4.12");
|
||||||
|
// An empty env var (set but blank) must not print a trailing dash.
|
||||||
|
assert_eq!(format_app_version("0.4.12", Some("")), "0.4.12");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_preview_build_reports_its_suffix() {
|
||||||
|
assert_eq!(
|
||||||
|
format_app_version("0.4.12", Some("preview.a1b2c3d")),
|
||||||
|
"0.4.12-preview.a1b2c3d"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── pick_update ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn release(tag: &str, prerelease: bool, asset_names: &[&str]) -> GitHubRelease {
|
||||||
|
GitHubRelease {
|
||||||
|
tag_name: tag.to_string(),
|
||||||
|
html_url: format!("https://example.invalid/{}", tag),
|
||||||
|
body: String::new(),
|
||||||
|
assets: asset_names
|
||||||
|
.iter()
|
||||||
|
.map(|name| GitHubAsset {
|
||||||
|
name: name.to_string(),
|
||||||
|
browser_download_url: String::new(),
|
||||||
|
size: 0,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
published_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
prerelease,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINUX_EXTENSIONS: &[&str] = &[".AppImage", ".deb", ".rpm"];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_prerelease_is_never_offered_even_if_its_tag_would_otherwise_win() {
|
||||||
|
let releases = vec![release("v9.9.9", true, &["app-9.9.9.AppImage"])];
|
||||||
|
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_release_with_no_asset_for_this_platform_is_skipped() {
|
||||||
|
let releases = vec![release("v0.4.12", false, &["app-0.4.12.msi"])];
|
||||||
|
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_release_that_is_not_newer_is_not_offered() {
|
||||||
|
let releases = vec![release("v0.4.10", false, &["app.AppImage"])];
|
||||||
|
assert!(pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_untagged_or_unparseable_release_is_skipped_not_fatal() {
|
||||||
|
// A `-preview.<sha>` tag is exactly the shape this must not choke on
|
||||||
|
// or mistake for an update — it simply never parses as a bare semver.
|
||||||
|
let releases = vec![
|
||||||
|
release("preview-a1b2c3d", false, &["app.AppImage"]),
|
||||||
|
release("v0.4.12", false, &["app.AppImage"]),
|
||||||
|
];
|
||||||
|
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||||
|
assert_eq!(best.tag_name, "v0.4.12");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_highest_qualifying_version_wins_not_the_first_or_last_in_the_list() {
|
||||||
|
let releases = vec![
|
||||||
|
release("v0.4.11", false, &["app.AppImage"]),
|
||||||
|
release("v0.4.13", false, &["app.AppImage"]),
|
||||||
|
release("v0.4.12", false, &["app.AppImage"]),
|
||||||
|
];
|
||||||
|
let best = pick_update(&releases, (0, 4, 10), LINUX_EXTENSIONS, false).unwrap();
|
||||||
|
assert_eq!(best.tag_name, "v0.4.13");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── is_preview_build (>= instead of >) ─────────────────────────────────
|
||||||
|
|
||||||
|
/// The exact scenario triple-c#32 was filed to fix: a preview compiled as
|
||||||
|
/// `0.4.12-preview.<sha>` (bare `CARGO_PKG_VERSION` "0.4.12") must be
|
||||||
|
/// offered the `v0.4.12` release that follows it, even though the two
|
||||||
|
/// compute to the identical numeric tuple.
|
||||||
|
#[test]
|
||||||
|
fn a_preview_build_is_offered_the_release_it_precedes() {
|
||||||
|
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||||
|
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||||
|
let best = pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).unwrap();
|
||||||
|
assert_eq!(best.tag_name, "v0.4.12");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_preview_build_is_not_offered_an_older_release() {
|
||||||
|
let releases = vec![release("v0.4.11", false, &["app.AppImage"])];
|
||||||
|
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, true).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_production_build_still_requires_strictly_newer() {
|
||||||
|
// A production build must never treat "equal" as an update — that
|
||||||
|
// would perpetually re-offer the version already running.
|
||||||
|
let releases = vec![release("v0.4.12", false, &["app.AppImage"])];
|
||||||
|
assert!(pick_update(&releases, (0, 4, 12), LINUX_EXTENSIONS, false).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check whether a newer container image is available in the registry.
|
/// Check whether a newer container image is available in the registry.
|
||||||
///
|
///
|
||||||
/// Compares the local image digest with the remote registry digest using the
|
/// Compares the local image digest with the remote registry digest using the
|
||||||
|
|||||||
@@ -301,21 +301,10 @@ impl ExecSessionManager {
|
|||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
// Build a tar archive in memory containing the file
|
// Owned by the container user, stamped now: a default tar header would
|
||||||
let mut tar_buf = Vec::new();
|
// land it as root:root/1970 and Claude Code could not rewrite it.
|
||||||
{
|
let (uid, gid) = container_user_ids(container_id).await;
|
||||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
let tar_buf = build_single_file_tar(file_name, data, 0o644, uid, gid, now_epoch_secs())?;
|
||||||
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))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
docker
|
docker
|
||||||
.upload_to_container(
|
.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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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(
|
pub async fn upload_host_file_to_container(
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
host_path: &str,
|
host_path: &str,
|
||||||
|
dest_dir: &str,
|
||||||
dest_name: &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> {
|
) -> Result<String, String> {
|
||||||
let host_path = host_path.to_string();
|
let host_path = host_path.to_string();
|
||||||
let dest_name = dest_name.to_string();
|
let dest_name = dest_name.to_string();
|
||||||
let dest_for_blk = dest_name.clone();
|
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 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))?;
|
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
crate::commands::file_commands::verify_opened_path(
|
||||||
{
|
&file,
|
||||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
std::path::Path::new(&host_path),
|
||||||
let mut header = tar::Header::new_gnu();
|
)?;
|
||||||
// Size comes from the bytes in hand, so header and payload can't disagree.
|
let mut data = Vec::new();
|
||||||
header.set_size(data.len() as u64);
|
std::io::Read::read_to_end(
|
||||||
header.set_mode(0o644);
|
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
|
||||||
header.set_cksum();
|
&mut data,
|
||||||
builder
|
)
|
||||||
.append_data(&mut header, &dest_for_blk, &data[..])
|
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
if data.len() as u64 > MAX_DROP_BYTES {
|
||||||
builder
|
return Err(format!(
|
||||||
.finish()
|
"File too large to upload (limit {} MB)",
|
||||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
MAX_DROP_BYTES / (1024 * 1024)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(tar_buf)
|
build_single_file_tar(&dest_for_blk, &data[..], 0o644, uid, gid, mtime)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
||||||
@@ -376,7 +410,7 @@ pub async fn upload_host_file_to_container(
|
|||||||
.upload_to_container(
|
.upload_to_container(
|
||||||
container_id,
|
container_id,
|
||||||
Some(UploadToContainerOptions {
|
Some(UploadToContainerOptions {
|
||||||
path: "/tmp".to_string(),
|
path: dest_dir.to_string(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
tar_buf.into(),
|
tar_buf.into(),
|
||||||
@@ -384,7 +418,17 @@ pub async fn upload_host_file_to_container(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
.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`.
|
/// 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> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
|
// 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
|
||||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
// read as 1970.
|
||||||
let mut header = tar::Header::new_gnu();
|
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
|
||||||
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))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
docker
|
docker
|
||||||
.upload_to_container(
|
.upload_to_container(
|
||||||
@@ -432,6 +466,74 @@ pub async fn upload_bytes_to_container(
|
|||||||
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
|
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
|
/// Ceiling on how much container output a one-shot exec will buffer into the
|
||||||
/// host process.
|
/// host process.
|
||||||
///
|
///
|
||||||
@@ -450,14 +552,31 @@ pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024;
|
|||||||
/// past anything genuine, far short of a problem.
|
/// past anything genuine, far short of a problem.
|
||||||
pub const PROC_NET_OUTPUT_LIMIT: usize = 1024 * 1024;
|
pub const PROC_NET_OUTPUT_LIMIT: usize = 1024 * 1024;
|
||||||
|
|
||||||
/// Append to `buf` while it stays inside `limit`. Returns `false` once the
|
/// Marker on the "that command printed more than this will buffer" refusal.
|
||||||
/// limit is exceeded, at which point the caller must stop reading.
|
///
|
||||||
fn push_capped(buf: &mut String, chunk: &str, limit: usize) -> bool {
|
/// 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 {
|
if buf.len() + chunk.len() > limit {
|
||||||
return false;
|
return None;
|
||||||
}
|
}
|
||||||
buf.push_str(chunk);
|
let start = buf.len();
|
||||||
true
|
buf.extend_from_slice(chunk);
|
||||||
|
Some((start, buf.len()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
/// 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
|
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(
|
async fn exec_oneshot_inner(
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
user: &str,
|
user: &str,
|
||||||
@@ -528,6 +706,17 @@ async fn exec_oneshot_inner(
|
|||||||
env: Vec<String>,
|
env: Vec<String>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<(String, i64), String> {
|
) -> 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 docker = get_docker()?;
|
||||||
|
|
||||||
let exec = docker
|
let exec = docker
|
||||||
@@ -550,22 +739,31 @@ async fn exec_oneshot_inner(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
.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 {
|
match result {
|
||||||
StartExecResults::Attached { mut output, .. } => {
|
StartExecResults::Attached { mut output, .. } => {
|
||||||
while let Some(msg) = output.next().await {
|
while let Some(msg) = output.next().await {
|
||||||
match msg {
|
match msg {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
let chunk = String::from_utf8_lossy(&data.into_bytes()).into_owned();
|
let from_stdout = matches!(data, LogOutput::StdOut { .. });
|
||||||
if !push_capped(&mut combined, &chunk, limit) {
|
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
|
// Stop reading rather than truncate silently: every
|
||||||
// caller parses this output, and a half-read
|
// caller parses this output, and a half-read
|
||||||
// manifest or JSON array is worse than an error.
|
// manifest or JSON array is worse than an error.
|
||||||
// Dropping `output` kills the exec's stream.
|
// Dropping `output` kills the exec's stream.
|
||||||
|
None => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Command output exceeded {} bytes and was abandoned",
|
"{}: Command output exceeded {} bytes and was abandoned",
|
||||||
limit
|
OUTPUT_LIMIT_MARKER, limit
|
||||||
));
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => return Err(format!("Exec output error: {}", e)),
|
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
|
// The output stream draining doesn't strictly guarantee inspect_exec has the
|
||||||
// final exit_code populated yet, so poll until the exec reports finished.
|
// 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.
|
/// 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
|
/// 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).
|
/// 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> {
|
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||||
let docker = get_docker().ok()?;
|
let docker = get_docker().ok()?;
|
||||||
for _ in 0..40 {
|
for _ in 0..200 {
|
||||||
match docker.inspect_exec(exec_id).await {
|
match docker.inspect_exec(exec_id).await {
|
||||||
Ok(info) => {
|
Ok(info) => {
|
||||||
if info.running != Some(true) {
|
if info.running != Some(true) {
|
||||||
// Finished: use the reported code (default 0 if somehow absent).
|
// Finished. `exit_code` rather than `unwrap_or(0)`: an exec
|
||||||
return Some(info.exit_code.unwrap_or(0));
|
// 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,
|
Err(_) => return None,
|
||||||
@@ -607,31 +842,96 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn output_under_the_limit_is_buffered_whole() {
|
fn output_under_the_limit_is_buffered_whole() {
|
||||||
let mut buf = String::new();
|
let mut buf = Vec::new();
|
||||||
assert!(push_capped(&mut buf, "hello ", 16));
|
assert_eq!(push_capped(&mut buf, b"hello ", 16), Some((0, 6)));
|
||||||
assert!(push_capped(&mut buf, "world", 16));
|
assert_eq!(push_capped(&mut buf, b"world", 16), Some((6, 11)));
|
||||||
assert_eq!(buf, "hello world");
|
assert_eq!(buf, b"hello world");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn output_over_the_limit_is_refused_rather_than_truncated() {
|
fn output_over_the_limit_is_refused_rather_than_truncated() {
|
||||||
// The abandoned chunk must not land in the buffer either: a caller that
|
// The abandoned chunk must not land in the buffer either: a caller that
|
||||||
// ignored the error would otherwise parse a half-read document.
|
// ignored the error would otherwise parse a half-read document.
|
||||||
let mut buf = String::new();
|
let mut buf = Vec::new();
|
||||||
assert!(push_capped(&mut buf, "0123456789", 12));
|
assert!(push_capped(&mut buf, b"0123456789", 12).is_some());
|
||||||
assert!(!push_capped(&mut buf, "0123456789", 12));
|
assert!(push_capped(&mut buf, b"0123456789", 12).is_none());
|
||||||
assert_eq!(buf, "0123456789");
|
assert_eq!(buf, b"0123456789");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_single_oversized_chunk_is_refused() {
|
fn a_single_oversized_chunk_is_refused() {
|
||||||
let mut buf = String::new();
|
let mut buf = Vec::new();
|
||||||
assert!(!push_capped(&mut buf, "0123456789", 4));
|
assert!(push_capped(&mut buf, b"0123456789", 4).is_none());
|
||||||
assert!(buf.is_empty());
|
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]
|
#[test]
|
||||||
fn the_bridge_budget_is_far_smaller_than_the_general_one() {
|
fn the_bridge_budget_is_far_smaller_than_the_general_one() {
|
||||||
// The auth bridge re-reads container-controlled procfs every 2s, so it
|
// 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.
|
// …but still comfortably above a genuine /proc/net/tcp{,6} pair.
|
||||||
assert!(PROC_NET_OUTPUT_LIMIT > 100 * 150);
|
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-task-runner", "Scheduled task runner"),
|
||||||
("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"),
|
("/usr/local/bin/triple-c-sso-refresh", "AWS SSO auto-refresh"),
|
||||||
("/opt/mission-control", "Mission Control (Flight Control)"),
|
("/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
|
/// 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> {
|
pub fn bind_mount_exclusions(paths: &[ProjectPath]) -> Vec<String> {
|
||||||
let mut out: Vec<String> = paths
|
let mut out: Vec<String> = paths
|
||||||
.iter()
|
.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))
|
.map(|p| format!("/workspace/{}", p.mount_name))
|
||||||
.collect();
|
.collect();
|
||||||
out.sort();
|
out.sort();
|
||||||
@@ -710,13 +721,83 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to create probe container for {}: {}", image, e))?;
|
.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(
|
.remove_container(
|
||||||
&id,
|
id,
|
||||||
Some(RemoveContainerOptions {
|
Some(RemoveContainerOptions {
|
||||||
force: true,
|
force: true,
|
||||||
v: true,
|
v: true,
|
||||||
@@ -725,11 +806,95 @@ pub async fn run_throwaway(image: &str, script: &str) -> Result<ThrowawayResult,
|
|||||||
)
|
)
|
||||||
.await
|
.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> {
|
async fn run_throwaway_inner(id: &str) -> Result<ThrowawayResult, String> {
|
||||||
let docker = get_docker()?;
|
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"))
|
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`.
|
/// Split `repo:tag` into its parts, defaulting the tag to `latest`.
|
||||||
pub fn split_image_ref(image: &str) -> (String, String) {
|
pub fn split_image_ref(image: &str) -> (String, String) {
|
||||||
match image.rsplit_once(':') {
|
match image.rsplit_once(':') {
|
||||||
@@ -1008,6 +1377,30 @@ pub fn parse_preflight(raw: &str) -> PreflightEnvironment {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 super::*;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
MIGRATION_PHASE_AWAITING, MIGRATION_PHASE_INTERRUPTED, MIGRATION_PHASE_IN_PROGRESS,
|
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'"#);
|
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 install_helper;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod project_lock;
|
||||||
mod storage;
|
mod storage;
|
||||||
pub mod web_terminal;
|
pub mod web_terminal;
|
||||||
|
|
||||||
@@ -28,6 +29,21 @@ pub struct AppState {
|
|||||||
pub auth_bridge: Arc<AuthBridgeManager>,
|
pub auth_bridge: Arc<AuthBridgeManager>,
|
||||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||||
pub lifecycle: Arc<Lifecycle>,
|
pub lifecycle: Arc<Lifecycle>,
|
||||||
|
/// The file `preview_settings_import` last decrypted successfully, held
|
||||||
|
/// so `apply_settings_import` can re-read and re-decrypt the same file
|
||||||
|
/// without the frontend ever passing a host path back to Rust as an
|
||||||
|
/// argument — see the doc comment on `commands::settings_export_commands`
|
||||||
|
/// for why that direction specifically is the one this app treats as
|
||||||
|
/// dangerous. Deliberately re-decrypted rather than cached in plaintext:
|
||||||
|
/// nothing here holds a decrypted secret in memory for longer than one
|
||||||
|
/// command's execution.
|
||||||
|
///
|
||||||
|
/// Also pins a hash of the file's ciphertext at preview time, so
|
||||||
|
/// `apply_settings_import` can refuse to proceed if the file on disk
|
||||||
|
/// changed underneath the pending import — otherwise confirming a
|
||||||
|
/// preview is not actually binding on what gets applied.
|
||||||
|
pub pending_settings_import:
|
||||||
|
Arc<tokio::sync::Mutex<Option<commands::settings_export_commands::PendingSettingsImport>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -212,7 +228,6 @@ pub fn run() {
|
|||||||
let lifecycle_setup = lifecycle.clone();
|
let lifecycle_setup = lifecycle.clone();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
@@ -222,6 +237,7 @@ pub fn run() {
|
|||||||
auth_bridge,
|
auth_bridge,
|
||||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
pending_settings_import: Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
})
|
})
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||||
@@ -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
|
// Auto-start web terminal server if enabled in settings
|
||||||
let settings = settings_store_setup.get();
|
let settings = settings_store_setup.get();
|
||||||
if settings.web_terminal.enabled {
|
if settings.web_terminal.enabled {
|
||||||
@@ -411,7 +461,6 @@ pub fn run() {
|
|||||||
commands::docker_commands::check_image_exists,
|
commands::docker_commands::check_image_exists,
|
||||||
commands::docker_commands::build_image,
|
commands::docker_commands::build_image,
|
||||||
commands::docker_commands::get_container_info,
|
commands::docker_commands::get_container_info,
|
||||||
commands::docker_commands::list_sibling_containers,
|
|
||||||
// Projects
|
// Projects
|
||||||
commands::project_commands::list_projects,
|
commands::project_commands::list_projects,
|
||||||
commands::project_commands::add_project,
|
commands::project_commands::add_project,
|
||||||
@@ -440,12 +489,19 @@ pub fn run() {
|
|||||||
browser_view::commands::close_browser_view_popout,
|
browser_view::commands::close_browser_view_popout,
|
||||||
browser_view::commands::get_browser_view_popout_state,
|
browser_view::commands::get_browser_view_popout_state,
|
||||||
browser_view::commands::set_browser_view_popout_always_on_top,
|
browser_view::commands::set_browser_view_popout_always_on_top,
|
||||||
|
browser_view::commands::open_page_in_container_browser,
|
||||||
|
browser_view::commands::set_container_page_viewport,
|
||||||
|
browser_view::commands::get_container_page_state,
|
||||||
|
browser_view::commands::close_container_page,
|
||||||
|
browser_view::commands::set_browser_view_match_window,
|
||||||
|
browser_view::commands::get_browser_view_match_window,
|
||||||
// Shared Claude Code auth token
|
// Shared Claude Code auth token
|
||||||
commands::auth_token_commands::acquire_claude_token,
|
commands::auth_token_commands::acquire_claude_token,
|
||||||
commands::auth_token_commands::submit_claude_token_code,
|
commands::auth_token_commands::submit_claude_token_code,
|
||||||
commands::auth_token_commands::cancel_claude_token,
|
commands::auth_token_commands::cancel_claude_token,
|
||||||
commands::auth_token_commands::has_claude_token,
|
commands::auth_token_commands::has_claude_token,
|
||||||
commands::auth_token_commands::clear_claude_token,
|
commands::auth_token_commands::clear_claude_token,
|
||||||
|
commands::auth_token_commands::sweep_claude_token_snapshots,
|
||||||
// Settings
|
// Settings
|
||||||
commands::settings_commands::get_settings,
|
commands::settings_commands::get_settings,
|
||||||
commands::settings_commands::update_settings,
|
commands::settings_commands::update_settings,
|
||||||
@@ -454,6 +510,10 @@ pub fn run() {
|
|||||||
commands::settings_commands::inspect_ca_cert_path,
|
commands::settings_commands::inspect_ca_cert_path,
|
||||||
commands::settings_commands::list_aws_profiles,
|
commands::settings_commands::list_aws_profiles,
|
||||||
commands::settings_commands::detect_host_timezone,
|
commands::settings_commands::detect_host_timezone,
|
||||||
|
// Settings export/import
|
||||||
|
commands::settings_export_commands::export_settings,
|
||||||
|
commands::settings_export_commands::preview_settings_import,
|
||||||
|
commands::settings_export_commands::apply_settings_import,
|
||||||
// Terminal
|
// Terminal
|
||||||
commands::terminal_commands::open_terminal_session,
|
commands::terminal_commands::open_terminal_session,
|
||||||
commands::terminal_commands::terminal_input,
|
commands::terminal_commands::terminal_input,
|
||||||
@@ -466,9 +526,12 @@ pub fn run() {
|
|||||||
commands::terminal_commands::stop_audio_bridge,
|
commands::terminal_commands::stop_audio_bridge,
|
||||||
// Files
|
// Files
|
||||||
commands::file_commands::list_container_files,
|
commands::file_commands::list_container_files,
|
||||||
commands::file_commands::download_container_file,
|
|
||||||
commands::file_commands::download_container_backup,
|
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
|
// AWS
|
||||||
commands::aws_commands::aws_sso_refresh,
|
commands::aws_commands::aws_sso_refresh,
|
||||||
// Updates
|
// Updates
|
||||||
@@ -646,4 +709,244 @@ mod tests {
|
|||||||
lifecycle.settle_startup_tasks().await;
|
lifecycle.settle_startup_tasks().await;
|
||||||
assert!(started.elapsed() <= STARTUP_CANCEL_BUDGET + Duration::from_secs(1));
|
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::fs;
|
||||||
use std::path::PathBuf;
|
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/`
|
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
|
||||||
fn log_dir() -> Option<PathBuf> {
|
fn log_dir() -> Option<PathBuf> {
|
||||||
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
|
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
|
||||||
@@ -33,7 +38,7 @@ pub fn init() {
|
|||||||
message
|
message
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.level(log::LevelFilter::Info)
|
.level(LOG_LEVEL)
|
||||||
.chain(std::io::stderr());
|
.chain(std::io::stderr());
|
||||||
|
|
||||||
if let Some((_path, file)) = &log_file_path {
|
if let Some((_path, file)) = &log_file_path {
|
||||||
@@ -41,7 +46,28 @@ pub fn init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = dispatch.apply() {
|
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.
|
// 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());
|
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
|
// Prevents additional console window on Windows in release
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
/// WebKitGTK's DMA-BUF renderer (its default accelerated-compositing path
|
||||||
|
/// since 2.42) fails outright on some Mesa/driver/compositor combinations
|
||||||
|
/// under Wayland, printing `Could not create default EGL display:
|
||||||
|
/// EGL_BAD_PARAMETER. Aborting.` straight to stderr from WebKitGTK's own C
|
||||||
|
/// code and killing the webview before Triple-C's own logging even starts —
|
||||||
|
/// see triple-c#34, reported on CachyOS/Arch with Wayland.
|
||||||
|
///
|
||||||
|
/// Set unconditionally on Linux rather than gated on `WAYLAND_DISPLAY`: that
|
||||||
|
/// variable is exported into an XWayland client's environment too, so a
|
||||||
|
/// gate on it wouldn't even cleanly separate "Wayland" from "X11" — and
|
||||||
|
/// there is no reliable heuristic at all for the actual variable that
|
||||||
|
/// matters, which Mesa/driver/compositor combination is affected. This is
|
||||||
|
/// the blunt instrument, chosen deliberately because the fallback is a real
|
||||||
|
/// trade, not a free one: the terminal's `@xterm/addon-webgl` renderer
|
||||||
|
/// (`TerminalView.tsx`) is the one surface in this app actually asking for
|
||||||
|
/// GPU compositing, and it degrades to xterm's canvas renderer under this
|
||||||
|
/// setting — slower on very heavy output, but the addon's own construction
|
||||||
|
/// is already wrapped in a fallback (`WebGL not available` is a handled
|
||||||
|
/// case, not a crash), so this is a real but graceful downgrade, traded
|
||||||
|
/// against a startup abort that has no fallback at all.
|
||||||
|
///
|
||||||
|
/// Must be set before `triple_c_lib::run()` — GTK/WebKitGTK reads it at
|
||||||
|
/// their own init time, which happens inside the Tauri builder that
|
||||||
|
/// function calls into, not at binary load.
|
||||||
|
///
|
||||||
|
/// A user who has already set this themselves is left alone. That includes
|
||||||
|
/// setting it to `0`, on the assumption WebKitGTK treats it as a boolean
|
||||||
|
/// rather than presence-only — not verified against WebKitGTK's own source,
|
||||||
|
/// so if it turns out to be presence-only, `=0` still reads as "set" here
|
||||||
|
/// and disables DMA-BUF the same as any other value, which is at least the
|
||||||
|
/// safe direction to be wrong in.
|
||||||
|
///
|
||||||
|
/// This env var also leaks to whatever the app spawns afterwards — notably
|
||||||
|
/// a cold-launched default browser via the `opener` plugin's `xdg-open`
|
||||||
|
/// call. Narrow in practice (an already-running browser just receives the
|
||||||
|
/// URL; most non-WebKitGTK browsers ignore the variable entirely), but
|
||||||
|
/// worth knowing before chasing the "links don't open" half of triple-c#34
|
||||||
|
/// as a separate, unrelated cause.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn apply_webkit_wayland_workaround() {
|
||||||
|
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||||
|
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
apply_webkit_wayland_workaround();
|
||||||
|
|
||||||
triple_c_lib::run()
|
triple_c_lib::run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod container_config;
|
|||||||
pub mod app_settings;
|
pub mod app_settings;
|
||||||
pub mod gateway_settings;
|
pub mod gateway_settings;
|
||||||
pub mod migration;
|
pub mod migration;
|
||||||
|
pub mod settings_export;
|
||||||
pub mod update_info;
|
pub mod update_info;
|
||||||
|
|
||||||
pub use project::*;
|
pub use project::*;
|
||||||
@@ -10,4 +11,5 @@ pub use container_config::*;
|
|||||||
pub use app_settings::*;
|
pub use app_settings::*;
|
||||||
pub use gateway_settings::*;
|
pub use gateway_settings::*;
|
||||||
pub use migration::*;
|
pub use migration::*;
|
||||||
|
pub use settings_export::*;
|
||||||
pub use update_info::*;
|
pub use update_info::*;
|
||||||
|
|||||||
@@ -8,6 +8,100 @@ pub struct EnvVar {
|
|||||||
pub value: String,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct ProjectPath {
|
pub struct ProjectPath {
|
||||||
pub host_path: String,
|
pub host_path: String,
|
||||||
@@ -85,31 +179,141 @@ impl PermissionMode {
|
|||||||
/// Settings for Claude Code CLI behavior inside the container.
|
/// Settings for Claude Code CLI behavior inside the container.
|
||||||
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
#[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 {
|
pub struct ClaudeCodeSettings {
|
||||||
/// TUI rendering mode: None = default, Some("fullscreen") = flicker-free alt-screen
|
/// TUI renderer. `None` leaves settings.json's `tui` key unset, which is
|
||||||
#[serde(default)]
|
/// 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>,
|
pub tui_mode: Option<String>,
|
||||||
/// Effort level: None = default, Some("low"|"medium"|"high")
|
/// Saved `/effort` level: `None` = unset, otherwise one of
|
||||||
#[serde(default)]
|
/// `"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>,
|
pub effort: Option<String>,
|
||||||
/// Disable auto-scroll in fullscreen TUI mode
|
/// Disable auto-scroll in fullscreen TUI mode. Held in the *disabled* sense
|
||||||
#[serde(default)]
|
/// because Claude Code's `autoScrollEnabled` defaults to `true`, so the
|
||||||
pub auto_scroll_disabled: bool,
|
/// zero value of this field has to mean "leave it on".
|
||||||
/// Enable focus mode (collapsed tool output)
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(default)]
|
pub auto_scroll_disabled: Option<bool>,
|
||||||
pub focus_mode: 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
|
/// Show thinking summaries in responses
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub show_thinking_summaries: bool,
|
pub show_thinking_summaries: Option<bool>,
|
||||||
/// Enable session recap when returning to a session
|
/// Turn the session recap **off**.
|
||||||
#[serde(default)]
|
///
|
||||||
pub enable_session_recap: bool,
|
/// 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
|
/// Strip credentials from subprocess environments
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub env_scrub: bool,
|
pub env_scrub: Option<bool>,
|
||||||
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
|
/// 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)]
|
#[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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -145,6 +349,22 @@ pub struct Project {
|
|||||||
/// container-recreation label.
|
/// container-recreation label.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub browser_view_enabled: bool,
|
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
|
/// Use the shared, long-lived Claude Code OAuth token (from
|
||||||
/// `claude setup-token`, held in the OS keychain) for this project instead
|
/// `claude setup-token`, held in the OS keychain) for this project instead
|
||||||
/// of requiring its own `claude login`. Only consulted when `backend` is
|
/// of requiring its own `claude login`. Only consulted when `backend` is
|
||||||
@@ -202,6 +422,61 @@ pub enum ProjectStatus {
|
|||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What `remove_project` could not delete, named so the UI can say so instead
|
||||||
|
/// of reporting a clean removal that was not one.
|
||||||
|
///
|
||||||
|
/// The project record is dropped from `projects.json` regardless — see the
|
||||||
|
/// long comment on `remove_project` for why refusing is not the answer — but
|
||||||
|
/// anything named here is also written to a pending-cleanup record that
|
||||||
|
/// startup housekeeping retries, so it stays reachable after the project it
|
||||||
|
/// belonged to no longer exists.
|
||||||
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProjectRemovalReport {
|
||||||
|
/// The project's container, if it could not be removed. Named by its
|
||||||
|
/// deterministic `triple-c-{id}` name (see `Project::container_name`),
|
||||||
|
/// not the container id, since the id can be stale or absent and the
|
||||||
|
/// name is what a later retry can still resolve.
|
||||||
|
pub container: Option<String>,
|
||||||
|
/// The `triple-c-snapshot-{id}` image, if it could not be removed.
|
||||||
|
pub image: Option<String>,
|
||||||
|
/// Named volumes (home, claude config) that could not be removed.
|
||||||
|
pub volumes: Vec<String>,
|
||||||
|
/// True once the leftovers above were durably recorded for automatic
|
||||||
|
/// retry on the next launch. False means the pending-cleanup record
|
||||||
|
/// itself could not be written — nothing will retry these, and the UI
|
||||||
|
/// must say so rather than promising a retry that will not happen.
|
||||||
|
/// Meaningless (and left at its default) when `is_clean()` is true.
|
||||||
|
pub retry_scheduled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProjectRemovalReport {
|
||||||
|
/// True when nothing was left behind.
|
||||||
|
pub fn is_clean(&self) -> bool {
|
||||||
|
self.container.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `rebuild_project_container` (Reset) produced: the project as it
|
||||||
|
/// stands after restarting, and anything Reset could not clear.
|
||||||
|
///
|
||||||
|
/// Reset's contract is "back to a clean base image", so a leftover volume or
|
||||||
|
/// image here is reused/rebuilt-from as-is by the container this creates —
|
||||||
|
/// the opposite of what was asked for — and unlike [`ProjectRemovalReport`]
|
||||||
|
/// there is no pending-cleanup record for either: the project id survives
|
||||||
|
/// Reset, so a later Reset attempt can retry them itself.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ProjectResetOutcome {
|
||||||
|
pub project: Project,
|
||||||
|
/// The `triple-c-snapshot-{id}` image, if Reset could not remove it. The
|
||||||
|
/// more serious of the two leftovers here: the new container is created
|
||||||
|
/// from this image whenever it exists, so a surviving image means Reset
|
||||||
|
/// silently rebuilt the exact system layer it was asked to discard.
|
||||||
|
pub leftover_image: Option<String>,
|
||||||
|
/// Volumes that survived Reset and were mounted into the new container
|
||||||
|
/// unchanged.
|
||||||
|
pub leftover_volumes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Which AI model backend/provider the project uses.
|
/// Which AI model backend/provider the project uses.
|
||||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||||
@@ -366,6 +641,7 @@ impl Project {
|
|||||||
mission_control_enabled: false,
|
mission_control_enabled: false,
|
||||||
auth_bridge_enabled: false,
|
auth_bridge_enabled: false,
|
||||||
browser_view_enabled: false,
|
browser_view_enabled: false,
|
||||||
|
vpn_support_enabled: false,
|
||||||
use_shared_auth_token: default_use_shared_auth_token(),
|
use_shared_auth_token: default_use_shared_auth_token(),
|
||||||
full_permissions: false,
|
full_permissions: false,
|
||||||
permission_mode: None,
|
permission_mode: None,
|
||||||
@@ -424,3 +700,189 @@ impl Project {
|
|||||||
val
|
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 body: String,
|
||||||
pub assets: Vec<GitHubAsset>,
|
pub assets: Vec<GitHubAsset>,
|
||||||
pub published_at: String,
|
pub published_at: String,
|
||||||
|
/// Whether GitHub itself has this release marked as a prerelease.
|
||||||
|
/// `#[serde(default)]` rather than required: every response GitHub sends
|
||||||
|
/// carries this, but nothing here should refuse to parse the rest of a
|
||||||
|
/// release over one missing field. Defaults to `false` (offered) rather
|
||||||
|
/// than `true` (excluded) — a missing field only happens if GitHub's API
|
||||||
|
/// shape changes, and "API changed, therefore updates silently stop
|
||||||
|
/// working forever" is the worse failure of the two.
|
||||||
|
///
|
||||||
|
/// `build-app.yml`'s own mirror never publishes a prerelease, but
|
||||||
|
/// `.gitea/workflows/backfill-releases.yml` forwards every Gitea release
|
||||||
|
/// unfiltered, `prerelease` included. A preview release's `preview-<sha>`
|
||||||
|
/// tag already fails semver parsing on its own, so this field is not what
|
||||||
|
/// stops *that* case — it is what stops the case tag-parsing can't catch:
|
||||||
|
/// a normally-tagged release (`v0.4.13`) that someone marks as a
|
||||||
|
/// prerelease on Gitea (a hotfix candidate, an RC) and a backfill then
|
||||||
|
/// mirrors as-is. Real defence for that case, not a no-op.
|
||||||
|
#[serde(default)]
|
||||||
|
pub prerelease: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GitHub API asset response (internal).
|
/// GitHub API asset response (internal).
|
||||||
|
|||||||
@@ -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
|
/// 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
|
/// flight; an unparseable file is treated the same way (and logged) rather than
|
||||||
/// blocking every future migration on a corrupt record.
|
/// 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> {
|
pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
||||||
let path = state_path(project_id)?;
|
let path = state_path(project_id)?;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
@@ -59,27 +82,326 @@ pub fn load(project_id: &str) -> Result<Option<MigrationState>, String> {
|
|||||||
match serde_json::from_str::<MigrationState>(&data) {
|
match serde_json::from_str::<MigrationState>(&data) {
|
||||||
Ok(state) => Ok(Some(state)),
|
Ok(state) => Ok(Some(state)),
|
||||||
Err(e) => {
|
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!(
|
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,
|
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)
|
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> {
|
pub fn save(project_id: &str, state: &MigrationState) -> Result<(), String> {
|
||||||
let path = state_path(project_id)?;
|
let path = state_path(project_id)?;
|
||||||
let data = serde_json::to_string_pretty(state)
|
let data = serde_json::to_string_pretty(state)
|
||||||
.map_err(|e| format!("Failed to serialize migration state: {}", e))?;
|
.map_err(|e| format!("Failed to serialize migration state: {}", e))?;
|
||||||
let tmp = path.with_extension("json.tmp");
|
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))?;
|
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(())
|
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.
|
/// Remove a project's migration state file. Missing is success.
|
||||||
pub fn clear(project_id: &str) -> Result<(), String> {
|
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||||
let path = state_path(project_id)?;
|
let path = state_path(project_id)?;
|
||||||
@@ -104,6 +426,39 @@ pub fn clear_staging(project_id: &str) -> Result<(), String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn project_ids_cannot_escape_the_migrations_directory() {
|
fn project_ids_cannot_escape_the_migrations_directory() {
|
||||||
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||||
@@ -115,4 +470,43 @@ mod tests {
|
|||||||
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
"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 migration_store;
|
||||||
|
pub mod pending_cleanup;
|
||||||
pub mod projects_store;
|
pub mod projects_store;
|
||||||
pub mod secure;
|
pub mod secure;
|
||||||
|
pub mod settings_crypto;
|
||||||
pub mod settings_store;
|
pub mod settings_store;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
//! Host-side record of Docker resources `remove_project` could not delete.
|
||||||
|
//!
|
||||||
|
//! `remove_project` drops a project's id from `projects.json` unconditionally
|
||||||
|
//! — see the comment on `ProjectRemovalReport` — so once that happens nothing
|
||||||
|
//! in the app can name the leftover container, image or volume again by any
|
||||||
|
//! path a user can reach. This is what keeps it reachable anyway: one JSON
|
||||||
|
//! file per affected project under `<data_dir>/triple-c/pending-cleanup/`,
|
||||||
|
//! written *before* the project record is dropped. Startup housekeeping
|
||||||
|
//! retries every record on the next launch (see
|
||||||
|
//! `commands::project_commands::retry_pending_cleanup_logged`) and deletes
|
||||||
|
//! the ones that fully succeed.
|
||||||
|
//!
|
||||||
|
//! **This record is written in the same instant its record in `projects.json`
|
||||||
|
//! is destroyed, and it is the only remaining handle on the leftover
|
||||||
|
//! resource** — which is a stronger claim on durability than an ordinary
|
||||||
|
//! write-temp-then-rename gives. `storage::migration_store::save` carries the
|
||||||
|
//! same reasoning for the migration state file: `fs::write` returns once the
|
||||||
|
//! bytes are in the page cache, and a rename over them is atomic with respect
|
||||||
|
//! to other readers, not to power loss. A crash in that window leaves the
|
||||||
|
//! rename applied and the data half-written, which [`list`] then treats as
|
||||||
|
//! unparseable and skips — reproducing the exact bug this module exists to
|
||||||
|
//! close, silently, with only a startup log line as evidence. So `save` here
|
||||||
|
//! takes the same `File::create` → `write_all` → `sync_all` → `rename` →
|
||||||
|
//! directory-sync shape `migration_store` does.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PendingCleanup {
|
||||||
|
pub project_id: String,
|
||||||
|
/// Kept only so a log line or a future UI can name the project without a
|
||||||
|
/// second lookup — the project record itself is already gone by the time
|
||||||
|
/// this is read back.
|
||||||
|
pub project_name: String,
|
||||||
|
/// The project's container, if it could not be removed. Named by its
|
||||||
|
/// deterministic `triple-c-{id}` name rather than the (possibly stale)
|
||||||
|
/// container id Docker handed out — Docker's remove-container API
|
||||||
|
/// accepts either, and the name is the one identifier guaranteed to still
|
||||||
|
/// resolve to the same container by the time a retry runs.
|
||||||
|
pub container_id: Option<String>,
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub volumes: Vec<String>,
|
||||||
|
pub recorded_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingCleanup {
|
||||||
|
/// True once nothing named here still needs to be removed.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.container_id.is_none() && self.image.is_none() && self.volumes.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<data_dir>/triple-c/pending-cleanup`, created on demand.
|
||||||
|
fn dir() -> Result<PathBuf, String> {
|
||||||
|
let dir = dirs::data_dir()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
"Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string()
|
||||||
|
})?
|
||||||
|
.join("triple-c")
|
||||||
|
.join("pending-cleanup");
|
||||||
|
fs::create_dir_all(&dir)
|
||||||
|
.map_err(|e| format!("Failed to create pending-cleanup directory: {}", e))?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project ids are UUIDs, but they arrive over IPC, so refuse to let one steer
|
||||||
|
/// the write anywhere but the pending-cleanup directory. Mirrors
|
||||||
|
/// `storage::migration_store::sanitize`.
|
||||||
|
fn sanitize(project_id: &str) -> String {
|
||||||
|
project_id
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write (or overwrite) a project's pending-cleanup record.
|
||||||
|
pub fn save(record: &PendingCleanup) -> Result<(), String> {
|
||||||
|
save_in(&dir()?, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a project's pending-cleanup record. Missing is success — this is
|
||||||
|
/// how a fully-succeeded retry (or a record that never existed) is expressed.
|
||||||
|
pub fn clear(project_id: &str) -> Result<(), String> {
|
||||||
|
clear_in(&dir()?, project_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every pending-cleanup record on disk. An unparseable file is logged and
|
||||||
|
/// skipped rather than blocking every other project's retry — the same
|
||||||
|
/// "one bad record can't wedge the rest" reasoning as the migration store.
|
||||||
|
pub fn list() -> Vec<PendingCleanup> {
|
||||||
|
let Ok(dir) = dir() else { return Vec::new() };
|
||||||
|
list_in(&dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_in(dir: &Path, project_id: &str) -> PathBuf {
|
||||||
|
dir.join(format!("{}.json", sanitize(project_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durable write: fsync the file before the rename, and fsync the directory
|
||||||
|
/// after it — see the module doc comment for why a plain
|
||||||
|
/// write-temp-then-rename is not enough here. Mirrors
|
||||||
|
/// `storage::migration_store::save`/`sync_dir`.
|
||||||
|
fn save_in(dir: &Path, record: &PendingCleanup) -> Result<(), String> {
|
||||||
|
let path = path_in(dir, &record.project_id);
|
||||||
|
let data = serde_json::to_string_pretty(record)
|
||||||
|
.map_err(|e| format!("Failed to serialize pending cleanup record: {}", e))?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file = fs::File::create(&tmp)
|
||||||
|
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
|
||||||
|
file.write_all(data.as_bytes())
|
||||||
|
.map_err(|e| format!("Failed to write pending cleanup record: {}", e))?;
|
||||||
|
file.sync_all()
|
||||||
|
.map_err(|e| format!("Failed to flush pending cleanup record to disk: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::rename(&tmp, &path)
|
||||||
|
.map_err(|e| format!("Failed to commit pending cleanup record: {}", e))?;
|
||||||
|
sync_dir(&path);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_in(dir: &Path, project_id: &str) -> Result<(), String> {
|
||||||
|
let path = path_in(dir, project_id);
|
||||||
|
match fs::remove_file(&path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(format!("Failed to remove pending cleanup record: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_in(dir: &Path) -> Vec<PendingCleanup> {
|
||||||
|
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
|
||||||
|
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
|
||||||
|
.filter_map(|e| {
|
||||||
|
let path = e.path();
|
||||||
|
let data = fs::read_to_string(&path).ok()?;
|
||||||
|
match serde_json::from_str::<PendingCleanup>(&data) {
|
||||||
|
Ok(record) => Some(record),
|
||||||
|
Err(err) => {
|
||||||
|
// Moved aside rather than left in place: a record nothing
|
||||||
|
// ever repairs would otherwise warn on every single
|
||||||
|
// startup forever, same as an ordinary `.json` file it
|
||||||
|
// would keep looking like one to `list_in` on the next
|
||||||
|
// call too. One aside-copy is enough here — this only
|
||||||
|
// ever holds names to retry removing, not the class of
|
||||||
|
// once-in-a-lifetime crash evidence `migration_store`
|
||||||
|
// keeps multiple timestamped backups of.
|
||||||
|
let corrupt = path.with_extension("json.corrupt");
|
||||||
|
let moved = !corrupt.exists() && fs::rename(&path, &corrupt).is_ok();
|
||||||
|
log::warn!(
|
||||||
|
"Could not parse pending cleanup record {}: {}{}",
|
||||||
|
path.display(),
|
||||||
|
err,
|
||||||
|
if moved {
|
||||||
|
format!(" — moved aside to {}", corrupt.display())
|
||||||
|
} else {
|
||||||
|
" — leaving it in place".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// fsync the directory holding `path`, so a rename into it survives power
|
||||||
|
/// loss. Best effort only on the platforms where it is meaningless: Windows
|
||||||
|
/// has no directory handle to sync and errors on the attempt, so failure is
|
||||||
|
/// logged rather than propagated — the file's own `sync_all` above is what
|
||||||
|
/// carries the data. Mirrors `storage::migration_store::sync_dir`, which is
|
||||||
|
/// private to that module, so this is a small deliberate duplicate rather
|
||||||
|
/// than a shared dependency between two otherwise-independent stores.
|
||||||
|
fn sync_dir(path: &Path) {
|
||||||
|
let Some(dir) = path.parent() else { return };
|
||||||
|
match fs::File::open(dir).and_then(|d| d.sync_all()) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => log::debug!(
|
||||||
|
"Could not fsync the pending-cleanup directory {}: {} — the record itself was flushed",
|
||||||
|
dir.display(),
|
||||||
|
e
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn temp_dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"triple-c-pending-cleanup-{}-{}",
|
||||||
|
name,
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
));
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(project_id: &str) -> PendingCleanup {
|
||||||
|
PendingCleanup {
|
||||||
|
project_id: project_id.to_string(),
|
||||||
|
project_name: "Some Project".to_string(),
|
||||||
|
container_id: Some("triple-c-abc".to_string()),
|
||||||
|
image: Some("triple-c-snapshot-abc:latest".to_string()),
|
||||||
|
volumes: vec!["triple-c-home-abc".to_string()],
|
||||||
|
recorded_at: "2026-08-25T00:00:00Z".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_ids_cannot_escape_the_pending_cleanup_directory() {
|
||||||
|
assert_eq!(sanitize("../../etc/passwd"), "______etc_passwd");
|
||||||
|
assert_eq!(sanitize("a/b"), "a_b");
|
||||||
|
assert_eq!(
|
||||||
|
sanitize("ab62cd24-51aa-4645-8f5c-17a124062050"),
|
||||||
|
"ab62cd24-51aa-4645-8f5c-17a124062050"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_empty_reflects_whatever_still_needs_removing() {
|
||||||
|
let mut r = record("p1");
|
||||||
|
assert!(!r.is_empty());
|
||||||
|
|
||||||
|
r.container_id = None;
|
||||||
|
r.image = None;
|
||||||
|
assert!(!r.is_empty(), "a leftover volume alone still counts");
|
||||||
|
|
||||||
|
r.volumes.clear();
|
||||||
|
assert!(r.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exercises the real `save_in`/`list_in`/`clear_in` — not a
|
||||||
|
/// re-implementation of their bodies — against a temp directory standing
|
||||||
|
/// in for `dir()`.
|
||||||
|
#[test]
|
||||||
|
fn a_saved_record_round_trips_and_clearing_removes_it() {
|
||||||
|
let dir = temp_dir("roundtrip");
|
||||||
|
let rec = record("proj-1");
|
||||||
|
|
||||||
|
save_in(&dir, &rec).expect("save");
|
||||||
|
let found = list_in(&dir);
|
||||||
|
assert_eq!(found.len(), 1);
|
||||||
|
assert_eq!(found[0].project_id, "proj-1");
|
||||||
|
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
|
||||||
|
|
||||||
|
clear_in(&dir, "proj-1").expect("clear");
|
||||||
|
assert!(list_in(&dir).is_empty());
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A second `save` for the same project overwrites rather than appending
|
||||||
|
/// — a retry that narrows the leftovers must not leave the old, wider
|
||||||
|
/// record behind it.
|
||||||
|
#[test]
|
||||||
|
fn saving_the_same_project_twice_overwrites_not_appends() {
|
||||||
|
let dir = temp_dir("overwrite");
|
||||||
|
let mut rec = record("proj-1");
|
||||||
|
save_in(&dir, &rec).expect("save");
|
||||||
|
|
||||||
|
rec.container_id = None;
|
||||||
|
rec.image = None;
|
||||||
|
save_in(&dir, &rec).expect("save again");
|
||||||
|
|
||||||
|
let found = list_in(&dir);
|
||||||
|
assert_eq!(found.len(), 1, "one file per project, not one per save");
|
||||||
|
assert!(found[0].container_id.is_none());
|
||||||
|
assert_eq!(found[0].volumes, vec!["triple-c-home-abc".to_string()]);
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A record that fails to parse must not poison the rest of the listing.
|
||||||
|
#[test]
|
||||||
|
fn an_unparseable_record_is_skipped_not_fatal() {
|
||||||
|
let dir = temp_dir("corrupt");
|
||||||
|
fs::write(dir.join("bad.json"), "{ not json").unwrap();
|
||||||
|
save_in(&dir, &record("proj-2")).expect("save");
|
||||||
|
|
||||||
|
let found = list_in(&dir);
|
||||||
|
assert_eq!(found.len(), 1);
|
||||||
|
assert_eq!(found[0].project_id, "proj-2");
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A record that fails to parse is moved aside once, rather than left in
|
||||||
|
/// place to be re-warned about — and re-warned about — on every future
|
||||||
|
/// launch forever.
|
||||||
|
#[test]
|
||||||
|
fn an_unparseable_record_is_moved_aside_exactly_once() {
|
||||||
|
let dir = temp_dir("corrupt-aside");
|
||||||
|
let bad = dir.join("bad.json");
|
||||||
|
fs::write(&bad, "{ not json").unwrap();
|
||||||
|
|
||||||
|
list_in(&dir);
|
||||||
|
assert!(!bad.exists(), "the bad file should have been moved aside");
|
||||||
|
let corrupt = dir.join("bad.json.corrupt");
|
||||||
|
assert!(corrupt.exists(), "and the moved copy should be at .json.corrupt");
|
||||||
|
|
||||||
|
// A second pass must not warn about `bad.json` again — it is gone —
|
||||||
|
// and must not choke on `.json.corrupt` already being there.
|
||||||
|
assert!(list_in(&dir).is_empty());
|
||||||
|
assert!(corrupt.exists(), "the aside copy is not itself deleted");
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `list_in` must not pick up the `.json.tmp` staging file `save_in`
|
||||||
|
/// leaves behind if a crash lands between the write and the rename — the
|
||||||
|
/// whole point of the temp-then-rename dance is that only the renamed
|
||||||
|
/// file is ever a complete record.
|
||||||
|
#[test]
|
||||||
|
fn a_leftover_tmp_file_is_not_listed() {
|
||||||
|
let dir = temp_dir("tmp-leftover");
|
||||||
|
fs::write(dir.join("proj-3.json.tmp"), "not a complete record").unwrap();
|
||||||
|
assert!(list_in(&dir).is_empty());
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clearing by project id must remove exactly the file that id maps to
|
||||||
|
/// under `sanitize`, and nothing else.
|
||||||
|
#[test]
|
||||||
|
fn clearing_one_project_does_not_touch_another() {
|
||||||
|
let dir = temp_dir("clear-scoped");
|
||||||
|
save_in(&dir, &record("proj-a")).unwrap();
|
||||||
|
save_in(&dir, &record("proj-b")).unwrap();
|
||||||
|
|
||||||
|
clear_in(&dir, "proj-a").unwrap();
|
||||||
|
|
||||||
|
let found = list_in(&dir);
|
||||||
|
assert_eq!(found.len(), 1);
|
||||||
|
assert_eq!(found[0].project_id, "proj-b");
|
||||||
|
|
||||||
|
fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,65 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use crate::models::Project;
|
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 {
|
pub struct ProjectsStore {
|
||||||
projects: Mutex<Vec<Project>>,
|
projects: Mutex<Vec<Project>>,
|
||||||
file_path: PathBuf,
|
file_path: PathBuf,
|
||||||
@@ -43,20 +99,14 @@ impl ProjectsStore {
|
|||||||
Ok(parsed) => (parsed, migrated),
|
Ok(parsed) => (parsed, migrated),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e);
|
log::error!("Failed to parse migrated projects.json: {}. Starting with empty list.", e);
|
||||||
let backup = file_path.with_extension("json.bak");
|
record_corrupt_load(&file_path, &chrono::Utc::now());
|
||||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
|
||||||
log::error!("Failed to back up corrupted projects.json: {}", be);
|
|
||||||
}
|
|
||||||
(Vec::new(), false)
|
(Vec::new(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
|
log::error!("Failed to parse projects.json: {}. Starting with empty list.", e);
|
||||||
let backup = file_path.with_extension("json.bak");
|
record_corrupt_load(&file_path, &chrono::Utc::now());
|
||||||
if let Err(be) = fs::copy(&file_path, &backup) {
|
|
||||||
log::error!("Failed to back up corrupted projects.json: {}", be);
|
|
||||||
}
|
|
||||||
(Vec::new(), false)
|
(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,48 +26,123 @@ const CLAUDE_TOKEN_VERSION_SERVICE: &str = "triple-c-claude-oauth-token-version"
|
|||||||
/// Fixed account name used for every triple-c keychain entry.
|
/// Fixed account name used for every triple-c keychain entry.
|
||||||
const KEYCHAIN_ACCOUNT: &str = "secret";
|
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.
|
/// Store a per-project secret in the OS keychain.
|
||||||
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
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);
|
project_secret_entry(project_id, key_name)?
|
||||||
let entry = keyring::Entry::new(&service, "secret")
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
entry
|
|
||||||
.set_password(value)
|
.set_password(value)
|
||||||
.map_err(|e| format!("Failed to store project secret '{}': {}", key_name, e))
|
.map_err(|e| format!("Failed to store project secret '{}': {}", key_name, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieve a per-project secret from the OS keychain.
|
/// Retrieve a per-project secret from the OS keychain.
|
||||||
pub fn get_project_secret(project_id: &str, key_name: &str) -> Result<Option<String>, String> {
|
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);
|
match project_secret_entry(project_id, key_name)?.get_password() {
|
||||||
let entry = keyring::Entry::new(&service, "secret")
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
match entry.get_password() {
|
|
||||||
Ok(value) => Ok(Some(value)),
|
Ok(value) => Ok(Some(value)),
|
||||||
Err(keyring::Error::NoEntry) => Ok(None),
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
Err(e) => Err(format!("Failed to retrieve project secret '{}': {}", key_name, e)),
|
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_secrets(project_id: &str) -> Result<(), String> {
|
pub fn delete_project_secret(project_id: &str, key_name: &str) -> Result<(), String> {
|
||||||
let secret_keys = [
|
match project_secret_entry(project_id, key_name)?.delete_credential() {
|
||||||
"git-token",
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
"aws-access-key-id",
|
Err(e) => Err(format!("Failed to delete project secret '{}': {}", key_name, e)),
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
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(())
|
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
|
/// only enforces auth when a master key is configured, so Triple-C always
|
||||||
/// configures one.
|
/// configures one.
|
||||||
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
pub fn get_or_create_gateway_master_key() -> Result<String, String> {
|
||||||
if let Some(existing) = read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")? {
|
if let Some(existing) = get_gateway_master_key()? {
|
||||||
if !existing.trim().is_empty() {
|
|
||||||
return Ok(existing);
|
return Ok(existing);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
regenerate_gateway_master_key()
|
regenerate_gateway_master_key()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the gateway master key without minting one if none exists yet.
|
||||||
|
/// Distinct from [`get_or_create_gateway_master_key`], which mints as a side
|
||||||
|
/// effect the read half of that function must not have — settings export
|
||||||
|
/// (triple-c#35) needs "is there one, and if so what is it", not "make sure
|
||||||
|
/// one exists".
|
||||||
|
pub fn get_gateway_master_key() -> Result<Option<String>, String> {
|
||||||
|
Ok(read_entry(GATEWAY_MASTER_KEY_SERVICE, "the gateway master key")?
|
||||||
|
.filter(|k| !k.trim().is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
/// Mint a new gateway master key, invalidating the old one. Projects using the
|
||||||
/// previous value must be updated.
|
/// previous value must be updated.
|
||||||
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
pub fn regenerate_gateway_master_key() -> Result<String, String> {
|
||||||
// LiteLLM requires the master key to start with `sk-`.
|
// LiteLLM requires the master key to start with `sk-`.
|
||||||
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
let key = format!("sk-triple-c-{}", uuid::Uuid::new_v4().simple());
|
||||||
|
store_gateway_master_key(&key)?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store an exact given gateway master key, replacing any previous one.
|
||||||
|
///
|
||||||
|
/// Distinct from [`regenerate_gateway_master_key`], which always mints a
|
||||||
|
/// fresh random value: this exists for settings import (triple-c#35), where
|
||||||
|
/// restoring the *same* key an export captured is the point — projects on
|
||||||
|
/// the destination machine may not exist yet, but a project migrated or
|
||||||
|
/// re-added later that still has the old key pasted into its config must
|
||||||
|
/// keep working against it. Blank input is rejected rather than silently
|
||||||
|
/// stored, matching every other `store_*` function in this module.
|
||||||
|
pub fn store_gateway_master_key(key: &str) -> Result<(), String> {
|
||||||
|
if key.trim().is_empty() {
|
||||||
|
return Err("Refusing to store an empty gateway master key.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
let entry = keyring::Entry::new(GATEWAY_MASTER_KEY_SERVICE, KEYCHAIN_ACCOUNT)
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
.map_err(|e| format!("Keyring error: {}", e))?;
|
||||||
entry
|
entry
|
||||||
.set_password(&key)
|
.set_password(key.trim())
|
||||||
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
.map_err(|e| format!("Failed to store the gateway master key: {}", e))?;
|
||||||
|
|
||||||
bump_gateway_secret_version()?;
|
bump_gateway_secret_version()
|
||||||
Ok(key)
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<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>
|
<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>
|
Subresource Integrity on every CDN asset.
|
||||||
<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>
|
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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1b26;
|
--bg-primary: #1a1b26;
|
||||||
@@ -334,6 +401,9 @@
|
|||||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
||||||
enterkeyhint="send" inputmode="text">
|
enterkeyhint="send" inputmode="text">
|
||||||
<button class="key-btn" id="btnEnter">Enter</button>
|
<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="btnTab">Tab</button>
|
||||||
<button class="key-btn" id="btnCtrlC">^C</button>
|
<button class="key-btn" id="btnCtrlC">^C</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,6 +430,19 @@
|
|||||||
const emptyState = document.getElementById('emptyState');
|
const emptyState = document.getElementById('emptyState');
|
||||||
const mobileInput = document.getElementById('mobileInput');
|
const mobileInput = document.getElementById('mobileInput');
|
||||||
const btnEnter = document.getElementById('btnEnter');
|
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 btnTab = document.getElementById('btnTab');
|
||||||
const btnCtrlC = document.getElementById('btnCtrlC');
|
const btnCtrlC = document.getElementById('btnCtrlC');
|
||||||
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
|
||||||
@@ -519,7 +602,7 @@
|
|||||||
updateProjectList(msg.projects);
|
updateProjectList(msg.projects);
|
||||||
break;
|
break;
|
||||||
case 'opened':
|
case 'opened':
|
||||||
onSessionOpened(msg.session_id, msg.project_name);
|
onSessionOpened(msg.session_id, msg.project_name, msg.session_type);
|
||||||
break;
|
break;
|
||||||
case 'output':
|
case 'output':
|
||||||
onSessionOutput(msg.session_id, msg.data);
|
onSessionOutput(msg.session_id, msg.data);
|
||||||
@@ -570,8 +653,18 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSessionOpened(sessionId, projectName) {
|
function onSessionOpened(sessionId, projectName, serverSessionType) {
|
||||||
const sessionType = pendingSessionType || 'claude';
|
// 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;
|
pendingSessionType = null;
|
||||||
|
|
||||||
// Create terminal
|
// 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
|
// Track scroll position for scroll-to-bottom button
|
||||||
term.onScroll(() => updateScrollButton());
|
term.onScroll(() => updateScrollButton());
|
||||||
|
|
||||||
@@ -698,6 +819,7 @@
|
|||||||
switchToSession(remaining[remaining.length - 1]);
|
switchToSession(remaining[remaining.length - 1]);
|
||||||
} else {
|
} else {
|
||||||
activeSessionId = null;
|
activeSessionId = null;
|
||||||
|
syncNewlineButton();
|
||||||
emptyState.style.display = '';
|
emptyState.style.display = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -724,6 +846,7 @@
|
|||||||
|
|
||||||
function switchToSession(sessionId) {
|
function switchToSession(sessionId) {
|
||||||
activeSessionId = sessionId;
|
activeSessionId = sessionId;
|
||||||
|
syncNewlineButton();
|
||||||
|
|
||||||
// Update tab styles
|
// Update tab styles
|
||||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
@@ -799,7 +922,11 @@
|
|||||||
sendTerminalInput(val);
|
sendTerminalInput(val);
|
||||||
mobileInput.value = '';
|
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') {
|
} else if (e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendTerminalInput('\t');
|
sendTerminalInput('\t');
|
||||||
@@ -807,6 +934,27 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
|
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(); };
|
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
|
||||||
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ enum ServerMessage {
|
|||||||
Opened {
|
Opened {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
project_name: 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 {
|
Output {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
@@ -319,6 +329,11 @@ async fn handle_open(
|
|||||||
let _ = out_tx.send(ServerMessage::Opened {
|
let _ = out_tx.send(ServerMessage::Opened {
|
||||||
session_id,
|
session_id,
|
||||||
project_name,
|
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(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||||
"productName": "Triple-C",
|
"productName": "Triple-C",
|
||||||
"version": "0.3.0",
|
"version": "0.4.0",
|
||||||
"identifier": "com.triple-c.desktop",
|
"identifier": "com.triple-c.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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": {
|
"bundle": {
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.ico",
|
"icons/icon.ico",
|
||||||
|
"icons/icon.icns",
|
||||||
"icons/icon.png"
|
"icons/icon.png"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import DockerInstallDialog from "./components/DockerInstallDialog";
|
|||||||
import ProjectHome from "./components/projects/home/ProjectHome";
|
import ProjectHome from "./components/projects/home/ProjectHome";
|
||||||
import AddProjectDialog from "./components/projects/AddProjectDialog";
|
import AddProjectDialog from "./components/projects/AddProjectDialog";
|
||||||
import ToastHost from "./components/ui/ToastHost";
|
import ToastHost from "./components/ui/ToastHost";
|
||||||
|
import { PaneVisibilityProvider } from "./components/ui/PaneVisibility";
|
||||||
import StatusIndicator from "./components/ui/StatusIndicator";
|
import StatusIndicator from "./components/ui/StatusIndicator";
|
||||||
import Button from "./components/ui/Button";
|
import Button from "./components/ui/Button";
|
||||||
import { useDocker } from "./hooks/useDocker";
|
import { useDocker } from "./hooks/useDocker";
|
||||||
@@ -128,19 +129,34 @@ export default function App() {
|
|||||||
<WelcomeScreen />
|
<WelcomeScreen />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full">
|
<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) => (
|
{homeProjectIds.map((projectId) => (
|
||||||
<ProjectHome
|
<PaneVisibilityProvider
|
||||||
key={projectId}
|
key={projectId}
|
||||||
|
visible={activeTabKey === homeTabKey(projectId)}
|
||||||
|
>
|
||||||
|
<ProjectHome
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
active={activeTabKey === homeTabKey(projectId)}
|
active={activeTabKey === homeTabKey(projectId)}
|
||||||
/>
|
/>
|
||||||
|
</PaneVisibilityProvider>
|
||||||
))}
|
))}
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<TerminalView
|
<PaneVisibilityProvider
|
||||||
key={session.id}
|
key={session.id}
|
||||||
|
visible={session.id === activeSessionId}
|
||||||
|
>
|
||||||
|
<TerminalView
|
||||||
sessionId={session.id}
|
sessionId={session.id}
|
||||||
active={session.id === activeSessionId}
|
active={session.id === activeSessionId}
|
||||||
/>
|
/>
|
||||||
|
</PaneVisibilityProvider>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--bg-primary)]/95 backdrop-blur-sm"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
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"
|
data-testid="shutdown-overlay"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-2 px-6 text-center">
|
<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
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/<[^>]+>/g, "") // strip HTML tags (e.g. from inline code)
|
.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(/[^\w\s-]/g, "") // remove non-word chars except spaces/dashes
|
||||||
.replace(/\s+/g, "-") // spaces to dashes
|
.replace(/\s+/g, "-") // spaces to dashes
|
||||||
.replace(/-+/g, "-") // collapse consecutive dashes
|
.replace(/-+/g, "-") // collapse consecutive dashes
|
||||||
.replace(/^-|-$/g, ""); // trim leading/trailing 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;
|
let html = md;
|
||||||
|
|
||||||
// Normalize line endings
|
// Normalize line endings
|
||||||
html = html.replace(/\r\n/g, "\n");
|
html = html.replace(/\r\n/g, "\n");
|
||||||
|
|
||||||
// Escape HTML entities (but we'll re-introduce tags below)
|
// Escape HTML entities (but we'll re-introduce tags below).
|
||||||
html = html.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
//
|
||||||
|
// 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 (```...```)
|
// Fenced code blocks (```...```)
|
||||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
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)
|
// Markdown-style anchor links [text](#anchor)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
/\[([^\]]+)\]\(#([^)]+)\)/g,
|
/\[([^\]]+)\]\(#([^)]+)\)/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)
|
// Markdown-style external links [text](url)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
|
/\[([^\]]+)\]\((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 (- ...)
|
// Unordered list items (- ...)
|
||||||
@@ -117,7 +165,8 @@ function renderMarkdown(md: string): string {
|
|||||||
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
|
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
|
/(?<!="|'>)(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
|
// Wrap remaining loose text lines in paragraphs
|
||||||
|
|||||||
@@ -127,6 +127,59 @@ describe("MainTabs reordering", () => {
|
|||||||
expect(useAppState.getState().activeTabKey).toBe(HOME);
|
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", () => {
|
it("shows the drop marker only while a drag is under way", () => {
|
||||||
render(<MainTabs />);
|
render(<MainTabs />);
|
||||||
const tabs = laidOut();
|
const tabs = laidOut();
|
||||||
|
|||||||
@@ -55,9 +55,21 @@ export default function MainTabs() {
|
|||||||
/** The tab being dragged, and the slot it would drop into. */
|
/** The tab being dragged, and the slot it would drop into. */
|
||||||
const [dragKey, setDragKey] = useState<string | null>(null);
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||||
const [dropIndex, setDropIndex] = useState<number | 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);
|
const stripRef = useRef<HTMLDivElement>(null);
|
||||||
/** A press that has not yet moved far enough to be a drag. */
|
/** 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);
|
const suppressClick = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -87,6 +99,7 @@ export default function MainTabs() {
|
|||||||
pending.current = null;
|
pending.current = null;
|
||||||
setDragKey(null);
|
setDragKey(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
|
setGhost(null);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
@@ -165,16 +178,35 @@ export default function MainTabs() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tabClass = (active: boolean, dragging: boolean) =>
|
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
|
active
|
||||||
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
}${dragging ? " opacity-40" : ""}`;
|
}${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 = () => {
|
const endDrag = () => {
|
||||||
pending.current = null;
|
pending.current = null;
|
||||||
setDragKey(null);
|
setDragKey(null);
|
||||||
setDropIndex(null);
|
setDropIndex(null);
|
||||||
|
setGhost(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -212,7 +244,19 @@ export default function MainTabs() {
|
|||||||
// rename input is up — that drag is a text selection.
|
// rename input is up — that drag is a text selection.
|
||||||
if (e.button !== 0 || renaming) return;
|
if (e.button !== 0 || renaming) return;
|
||||||
if ((e.target as HTMLElement).closest("button, input")) 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);
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
},
|
},
|
||||||
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -223,6 +267,12 @@ export default function MainTabs() {
|
|||||||
drag.dragging = true;
|
drag.dragging = true;
|
||||||
setDragKey(drag.key);
|
setDragKey(drag.key);
|
||||||
setDropIndex(dropIndexAt(e.clientX));
|
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>) => {
|
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
const drag = pending.current;
|
const drag = pending.current;
|
||||||
@@ -352,7 +402,7 @@ export default function MainTabs() {
|
|||||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
if (e.key === "Escape") setRenamingId(null);
|
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}>
|
<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". */}
|
the hand naturally goes to say "put it at the end". */}
|
||||||
<div className="flex-1 self-stretch">{markerPending && dropMarker}</div>
|
<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 && (() => {
|
{menu && (() => {
|
||||||
const session = sessions.find((s) => s.id === menu.sessionId);
|
const session = sessions.find((s) => s.id === menu.sessionId);
|
||||||
const hasCustom = session
|
const hasCustom = session
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export default function StatusBar({ stt }: Props) {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const running = projects.filter((p) => p.status === "running").length;
|
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 (
|
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)]">
|
<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>
|
</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 */}
|
{/* Right-aligned controls: Jump to Current + STT mic */}
|
||||||
<div className="ml-auto flex items-center gap-3 pl-2">
|
<div className="ml-auto flex items-center gap-3 pl-2">
|
||||||
{activeSessionId && !terminalAtBottom && (
|
{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;
|
disabled: boolean;
|
||||||
disabledReason?: string;
|
disabledReason?: string;
|
||||||
onSave: (settings: ClaudeCodeSettings | null) => Promise<unknown>;
|
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 = {
|
export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
|
||||||
tui_mode: null,
|
tui_mode: null,
|
||||||
effort: null,
|
effort: null,
|
||||||
auto_scroll_disabled: false,
|
auto_scroll_disabled: null,
|
||||||
focus_mode: false,
|
focus_mode: null,
|
||||||
show_thinking_summaries: false,
|
show_thinking_summaries: null,
|
||||||
enable_session_recap: false,
|
session_recap_disabled: null,
|
||||||
env_scrub: false,
|
env_scrub: null,
|
||||||
prompt_caching_1h: false,
|
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 {
|
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 (
|
return (
|
||||||
s.tui_mode === null &&
|
s.tui_mode == null &&
|
||||||
s.effort === null &&
|
s.effort == null &&
|
||||||
s.auto_scroll_disabled === false &&
|
s.auto_scroll_disabled == null &&
|
||||||
s.focus_mode === false &&
|
s.focus_mode == null &&
|
||||||
s.show_thinking_summaries === false &&
|
s.show_thinking_summaries == null &&
|
||||||
s.enable_session_recap === false &&
|
s.session_recap_disabled == null &&
|
||||||
s.env_scrub === false &&
|
s.env_scrub == null &&
|
||||||
s.prompt_caching_1h === false
|
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: {
|
const BOOLEAN_FIELDS: {
|
||||||
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
|
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
|
||||||
label: string;
|
label: string;
|
||||||
hint: 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",
|
key: "show_thinking_summaries",
|
||||||
label: "Thinking summaries",
|
label: "Thinking summaries",
|
||||||
hint: "Shows Claude's thinking process as summaries.",
|
hint: "Shows Claude's thinking process as summaries.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "enable_session_recap",
|
key: "session_recap_disabled",
|
||||||
label: "Session recap",
|
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",
|
key: "auto_scroll_disabled",
|
||||||
label: "Auto-scroll disabled",
|
label: "Auto-scroll",
|
||||||
hint: "Disables auto-scroll when in fullscreen TUI mode.",
|
hint: "Follows new output to the bottom in fullscreen rendering.",
|
||||||
|
invert: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "env_scrub",
|
key: "env_scrub",
|
||||||
@@ -72,6 +111,7 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
disabled,
|
disabled,
|
||||||
disabledReason,
|
disabledReason,
|
||||||
onSave,
|
onSave,
|
||||||
|
scope = "global",
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [local, setLocal] = useState<ClaudeCodeSettings>(
|
const [local, setLocal] = useState<ClaudeCodeSettings>(
|
||||||
settings ?? { ...CLAUDE_CODE_DEFAULTS },
|
settings ?? { ...CLAUDE_CODE_DEFAULTS },
|
||||||
@@ -95,9 +135,16 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
</p>
|
</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
|
<SwitchRow
|
||||||
label="TUI mode"
|
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={
|
control={
|
||||||
<select
|
<select
|
||||||
value={local.tui_mode ?? ""}
|
value={local.tui_mode ?? ""}
|
||||||
@@ -106,7 +153,8 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={selectClass}
|
className={selectClass}
|
||||||
>
|
>
|
||||||
<option value="">Default</option>
|
<option value="">Automatic</option>
|
||||||
|
<option value="default">Classic</option>
|
||||||
<option value="fullscreen">Fullscreen</option>
|
<option value="fullscreen">Fullscreen</option>
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
@@ -127,11 +175,24 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
<option value="low">Low</option>
|
<option value="low">Low</option>
|
||||||
<option value="medium">Medium</option>
|
<option value="medium">Medium</option>
|
||||||
<option value="high">High</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>
|
</select>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
|
{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
|
<SwitchRow
|
||||||
key={key}
|
key={key}
|
||||||
label={label}
|
label={label}
|
||||||
@@ -139,13 +200,54 @@ export default function ClaudeCodeSettingsEditor({
|
|||||||
control={
|
control={
|
||||||
<Toggle
|
<Toggle
|
||||||
label={label}
|
label={label}
|
||||||
checked={local[key]}
|
checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(v) => apply({ [key]: v } as Partial<ClaudeCodeSettings>)}
|
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>
|
</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)]">
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||||
Are you sure you want to remove{" "}
|
Are you sure you want to remove{" "}
|
||||||
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
|
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This deletes
|
||||||
delete the container, config volume, and stored credentials.
|
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>
|
</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,8 +43,15 @@ export default function EnvVarsEditor({
|
|||||||
</p>
|
</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) => (
|
{vars.map((ev, i) => (
|
||||||
<div key={i} className="flex gap-2 items-center">
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<div className="w-2/5 shrink-0">
|
||||||
<input
|
<input
|
||||||
value={ev.key}
|
value={ev.key}
|
||||||
onChange={(e) => updateVar(i, "key", e.target.value)}
|
onChange={(e) => updateVar(i, "key", e.target.value)}
|
||||||
@@ -52,8 +59,10 @@ export default function EnvVarsEditor({
|
|||||||
placeholder="KEY"
|
placeholder="KEY"
|
||||||
aria-label={`Environment variable ${i + 1} name`}
|
aria-label={`Environment variable ${i + 1} name`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`w-2/5 ${monoInputClass}`}
|
className={monoInputClass}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<input
|
<input
|
||||||
value={ev.value}
|
value={ev.value}
|
||||||
onChange={(e) => updateVar(i, "value", e.target.value)}
|
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||||
@@ -61,8 +70,9 @@ export default function EnvVarsEditor({
|
|||||||
placeholder="value"
|
placeholder="value"
|
||||||
aria-label={`Environment variable ${i + 1} value`}
|
aria-label={`Environment variable ${i + 1} value`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`flex-1 ${monoInputClass}`}
|
className={monoInputClass}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={disabled}
|
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 Modal from "../../ui/Modal";
|
||||||
import StatusIndicator from "../../ui/StatusIndicator";
|
import StatusIndicator from "../../ui/StatusIndicator";
|
||||||
import TaskEditorModal from "./TaskEditorModal";
|
import TaskEditorModal from "./TaskEditorModal";
|
||||||
import { formatAge } from "./format";
|
import { formatAge, formatRunningFor } from "./format";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -59,6 +59,22 @@ export default function AutomationTab({ project }: Props) {
|
|||||||
|
|
||||||
useEffect(load, [load]);
|
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>) => {
|
const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => {
|
||||||
setBusyTaskId(taskId);
|
setBusyTaskId(taskId);
|
||||||
try {
|
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)]">
|
<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}
|
{task.task_type}
|
||||||
</span>
|
</span>
|
||||||
|
{task.running && (
|
||||||
|
<StatusIndicator
|
||||||
|
tone="busy"
|
||||||
|
label={`Running ${formatRunningFor(task.running_since) ?? ""}`.trim()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
<div className="text-xs text-[var(--text-secondary)] font-mono truncate">
|
||||||
{task.at ?? task.schedule}
|
{task.at ?? task.schedule}
|
||||||
@@ -202,14 +224,15 @@ export default function AutomationTab({ project }: Props) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
disabled={busyTaskId === task.id}
|
disabled={busyTaskId === task.id || task.running}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
withTask(task.id, "Run now", () =>
|
withTask(task.id, "Run now", async () => {
|
||||||
runScheduledTaskNow(project.id, task.id),
|
await runScheduledTaskNow(project.id, task.id);
|
||||||
)
|
setJustTriggered(Date.now());
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Run now
|
{task.running ? "Running…" : "Run now"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
|
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
|
||||||
Edit
|
Edit
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const closeBrowserViewPopout = vi.fn<(id: string) => Promise<void>>();
|
|||||||
const getBrowserViewPopoutState =
|
const getBrowserViewPopoutState =
|
||||||
vi.fn<() => Promise<{ open: boolean; always_on_top: boolean }>>();
|
vi.fn<() => Promise<{ open: boolean; always_on_top: boolean }>>();
|
||||||
const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
|
||||||
|
const openPageInContainerBrowser =
|
||||||
|
vi.fn<(id: string, url: string, w: number, h: number) => Promise<{ error: string | null }>>();
|
||||||
|
const setBrowserViewMatchWindow = vi.fn<(id: string, on: boolean) => Promise<void>>();
|
||||||
|
const getBrowserViewMatchWindow = vi.fn<() => Promise<boolean>>();
|
||||||
const pushToast = vi.fn();
|
const pushToast = vi.fn();
|
||||||
const setContainerProgress = vi.fn();
|
const setContainerProgress = vi.fn();
|
||||||
|
|
||||||
@@ -32,6 +36,10 @@ vi.mock("../../../lib/tauri-commands", () => ({
|
|||||||
getBrowserViewPopoutState: () => getBrowserViewPopoutState(),
|
getBrowserViewPopoutState: () => getBrowserViewPopoutState(),
|
||||||
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
|
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
|
||||||
setBrowserViewPopoutAlwaysOnTop(id, onTop),
|
setBrowserViewPopoutAlwaysOnTop(id, onTop),
|
||||||
|
openPageInContainerBrowser: (id: string, url: string, w: number, h: number) =>
|
||||||
|
openPageInContainerBrowser(id, url, w, h),
|
||||||
|
setBrowserViewMatchWindow: (id: string, on: boolean) => setBrowserViewMatchWindow(id, on),
|
||||||
|
getBrowserViewMatchWindow: () => getBrowserViewMatchWindow(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/event", () => ({
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
@@ -69,6 +77,11 @@ const NOTHING: PlaywrightDetection = {
|
|||||||
cli_entry: null,
|
cli_entry: null,
|
||||||
browsers: [],
|
browsers: [],
|
||||||
chrome_channel: null,
|
chrome_channel: null,
|
||||||
|
chromium_executable: null,
|
||||||
|
chromium_executable_exists: false,
|
||||||
|
script_playwright_version: null,
|
||||||
|
script_chromium_executable: null,
|
||||||
|
script_chromium_executable_exists: false,
|
||||||
searched: [
|
searched: [
|
||||||
"/workspace",
|
"/workspace",
|
||||||
"/usr/lib/node_modules",
|
"/usr/lib/node_modules",
|
||||||
@@ -125,6 +138,9 @@ beforeEach(() => {
|
|||||||
openBrowserViewPopout.mockResolvedValue(undefined);
|
openBrowserViewPopout.mockResolvedValue(undefined);
|
||||||
closeBrowserViewPopout.mockResolvedValue(undefined);
|
closeBrowserViewPopout.mockResolvedValue(undefined);
|
||||||
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
|
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
|
||||||
|
setBrowserViewMatchWindow.mockResolvedValue(undefined);
|
||||||
|
getBrowserViewMatchWindow.mockResolvedValue(false);
|
||||||
|
openPageInContainerBrowser.mockResolvedValue({ error: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
const LIVE: BrowserViewStatus = {
|
const LIVE: BrowserViewStatus = {
|
||||||
@@ -362,6 +378,41 @@ describe("BrowserTab", () => {
|
|||||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names both halves when the installed browser isn\u2019t the one Playwright launches", async () => {
|
||||||
|
// The cache is full and every script fails \u2014 "install a browser" alone
|
||||||
|
// would read as nonsense, so the copy has to say which copy wants what.
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({
|
||||||
|
...READY,
|
||||||
|
browsers: ["chromium-1237"],
|
||||||
|
chromium_executable: "/home/claude/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome",
|
||||||
|
chromium_executable_exists: true,
|
||||||
|
script_playwright_version: "1.62.1",
|
||||||
|
script_chromium_executable:
|
||||||
|
"/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome",
|
||||||
|
script_chromium_executable_exists: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/isn\u2019t the one Playwright launches/i)).toBeInTheDocument();
|
||||||
|
// Both revisions appear in the explanation: what is installed, and what
|
||||||
|
// the failing copy actually wants.
|
||||||
|
expect(screen.getAllByText(/chromium-1237/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText(/chromium-1234/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText(/Set up Playwright/).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call an unanswered probe a skew", async () => {
|
||||||
|
// A container older than these fields omits them; unknown is not broken.
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
|
getBrowserViewStatus.mockResolvedValue(LIVE);
|
||||||
|
|
||||||
|
render(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
|
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/isn\u2019t the one Playwright launches/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("only offers a window of its own once there is something to watch", async () => {
|
it("only offers a window of its own once there is something to watch", async () => {
|
||||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
render(<BrowserTab project={project} active />);
|
render(<BrowserTab project={project} active />);
|
||||||
@@ -454,6 +505,58 @@ describe("BrowserTab", () => {
|
|||||||
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a page in the container’s browser at the chosen viewport", async () => {
|
||||||
|
await renderLive();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/^URL$/i), {
|
||||||
|
target: { value: "http://localhost:5173" },
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "1920 × 1080" }));
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open page/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openPageInContainerBrowser).toHaveBeenCalledWith(
|
||||||
|
"p1",
|
||||||
|
"http://localhost:5173",
|
||||||
|
1920,
|
||||||
|
1080,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a URL scheme the backend would reject, before the round trip", async () => {
|
||||||
|
await renderLive();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/^URL$/i), {
|
||||||
|
target: { value: "file:///etc/passwd" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /open page/i })).toBeDisabled();
|
||||||
|
expect(screen.getByText(/Only http:\/\/ and https:\/\//)).toBeInTheDocument();
|
||||||
|
expect(openPageInContainerBrowser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers match-window only once the view is in its own window", async () => {
|
||||||
|
await renderLive();
|
||||||
|
expect(screen.queryByRole("switch", { name: "Match window" })).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("switch", { name: "Match window" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setBrowserViewMatchWindow).toHaveBeenCalledWith("p1", true);
|
||||||
|
});
|
||||||
|
|
||||||
it("says why the window wouldn’t open instead of pretending it did", async () => {
|
it("says why the window wouldn’t open instead of pretending it did", async () => {
|
||||||
await renderLive();
|
await renderLive();
|
||||||
openBrowserViewPopout.mockRejectedValue("no display");
|
openBrowserViewPopout.mockRejectedValue("no display");
|
||||||
@@ -468,4 +571,57 @@ describe("BrowserTab", () => {
|
|||||||
// The view is still in the tab, where it was.
|
// The view is still in the tab, where it was.
|
||||||
expect(screen.getByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ import {
|
|||||||
getBrowserViewStatus,
|
getBrowserViewStatus,
|
||||||
installBrowserViewBrowser,
|
installBrowserViewBrowser,
|
||||||
installBrowserViewSupport,
|
installBrowserViewSupport,
|
||||||
|
getBrowserViewMatchWindow,
|
||||||
getBrowserViewPopoutState,
|
getBrowserViewPopoutState,
|
||||||
openBrowserViewPopout,
|
openBrowserViewPopout,
|
||||||
|
openPageInContainerBrowser,
|
||||||
setBrowserViewEnabled,
|
setBrowserViewEnabled,
|
||||||
|
setBrowserViewMatchWindow,
|
||||||
setBrowserViewPopoutAlwaysOnTop,
|
setBrowserViewPopoutAlwaysOnTop,
|
||||||
} from "../../../lib/tauri-commands";
|
} from "../../../lib/tauri-commands";
|
||||||
import { useAppState } from "../../../store/appState";
|
import { useAppState } from "../../../store/appState";
|
||||||
|
import OpenPageDialog from "./OpenPageDialog";
|
||||||
import AccordionSection from "../../ui/AccordionSection";
|
import AccordionSection from "../../ui/AccordionSection";
|
||||||
import Button from "../../ui/Button";
|
import Button from "../../ui/Button";
|
||||||
import StatusIndicator from "../../ui/StatusIndicator";
|
import StatusIndicator from "../../ui/StatusIndicator";
|
||||||
@@ -80,6 +84,10 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
*/
|
*/
|
||||||
const [poppedOut, setPoppedOut] = useState<boolean | null>(null);
|
const [poppedOut, setPoppedOut] = useState<boolean | null>(null);
|
||||||
const [onTop, setOnTop] = useState(false);
|
const [onTop, setOnTop] = useState(false);
|
||||||
|
/** The "open a page" dialog, and the request it is running. */
|
||||||
|
const [matchWindow, setMatchWindow] = useState(false);
|
||||||
|
const [askPage, setAskPage] = useState(false);
|
||||||
|
const [openingPage, setOpeningPage] = useState(false);
|
||||||
const pushToast = useAppState((s) => s.pushToast);
|
const pushToast = useAppState((s) => s.pushToast);
|
||||||
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
const setContainerProgress = useAppState((s) => s.setContainerProgress);
|
||||||
const progress = useAppState((s) => s.containerProgress[project.id]);
|
const progress = useAppState((s) => s.containerProgress[project.id]);
|
||||||
@@ -137,6 +145,9 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
// Unreachable in practice, but a pane stuck at "not asked yet" would
|
// Unreachable in practice, but a pane stuck at "not asked yet" would
|
||||||
// never show the view at all — so fail towards the tab.
|
// never show the view at all — so fail towards the tab.
|
||||||
.catch(() => mounted.current && setPoppedOut(false));
|
.catch(() => mounted.current && setPoppedOut(false));
|
||||||
|
getBrowserViewMatchWindow(projectId)
|
||||||
|
.then((on) => mounted.current && setMatchWindow(on))
|
||||||
|
.catch(() => {});
|
||||||
getBrowserViewStatus(projectId)
|
getBrowserViewStatus(projectId)
|
||||||
.then((s) => mounted.current && setStatus(s))
|
.then((s) => mounted.current && setStatus(s))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -219,6 +230,55 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
[projectId, pushToast],
|
[projectId, pushToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a URL in a browser inside the container.
|
||||||
|
*
|
||||||
|
* The pane only ever *watched* browsers something else published; this is the
|
||||||
|
* one action that opens one. It also means the page can be resized later —
|
||||||
|
* whoever launches a bound browser is the only process that can drive it.
|
||||||
|
*/
|
||||||
|
const openPage = useCallback(
|
||||||
|
async (url: string, width: number, height: number) => {
|
||||||
|
setOpeningPage(true);
|
||||||
|
try {
|
||||||
|
const result = await openPageInContainerBrowser(projectId, url, width, height);
|
||||||
|
if (!mounted.current) return;
|
||||||
|
setAskPage(false);
|
||||||
|
if (result.error) {
|
||||||
|
pushToast({ kind: "error", message: "The page didn’t open", detail: result.error });
|
||||||
|
} else {
|
||||||
|
pushToast({ kind: "success", message: `Opened ${url} at ${width}×${height}` });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not open the page in the container’s browser",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted.current) setOpeningPage(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleMatchWindow = useCallback(
|
||||||
|
async (next: boolean) => {
|
||||||
|
setMatchWindow(next);
|
||||||
|
try {
|
||||||
|
await setBrowserViewMatchWindow(projectId, next);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted.current) setMatchWindow(!next);
|
||||||
|
pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: "Could not match the page to the window",
|
||||||
|
detail: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, pushToast],
|
||||||
|
);
|
||||||
|
|
||||||
/** Run one install. Every path clears the progress line it started. */
|
/** Run one install. Every path clears the progress line it started. */
|
||||||
const install = useCallback(
|
const install = useCallback(
|
||||||
async (which: Exclude<SetupJob, null>) => {
|
async (which: Exclude<SetupJob, null>) => {
|
||||||
@@ -283,7 +343,9 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
// apt package, so it never shows up in `browsers`, and a container that has
|
// apt package, so it never shows up in `browsers`, and a container that has
|
||||||
// it is not missing a browser.
|
// it is not missing a browser.
|
||||||
const needsBrowser =
|
const needsBrowser =
|
||||||
probed !== null && probed.browsers.length === 0 && probed.chrome_channel === null;
|
probed !== null &&
|
||||||
|
probed.chrome_channel === null &&
|
||||||
|
(probed.browsers.length === 0 || revisionSkew(probed));
|
||||||
const needsSetup = probed !== null && (!ready || needsBrowser);
|
const needsSetup = probed !== null && (!ready || needsBrowser);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -315,6 +377,15 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<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 && (
|
{live && poppedOut === true && (
|
||||||
<span className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
<span className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||||
Keep on top
|
Keep on top
|
||||||
@@ -324,11 +395,25 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
<Toggle checked={onTop} onChange={toggleOnTop} label="Keep on top" />
|
<Toggle checked={onTop} onChange={toggleOnTop} label="Keep on top" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{live && poppedOut === true && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]"
|
||||||
|
title="Resize the page itself as the window is dragged, so the layout actually reflows. Applies to pages opened from here."
|
||||||
|
>
|
||||||
|
Match window
|
||||||
|
<Toggle checked={matchWindow} onChange={toggleMatchWindow} label="Match window" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{live && poppedOut === false && (
|
{live && poppedOut === false && (
|
||||||
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
<Button size="md" onClick={() => setReloadKey((k) => k + 1)}>
|
||||||
Reload
|
Reload
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{live && (
|
||||||
|
<Button size="md" onClick={() => setAskPage(true)}>
|
||||||
|
Open a page…
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{live && poppedOut !== null && (
|
{live && poppedOut !== null && (
|
||||||
<Button size="md" onClick={poppedOut ? popIn : popOut}>
|
<Button size="md" onClick={poppedOut ? popIn : popOut}>
|
||||||
{poppedOut ? "Put back in tab" : "Open in own window"}
|
{poppedOut ? "Put back in tab" : "Open in own window"}
|
||||||
@@ -372,6 +457,35 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
// host-side gate checks before anything reaches the container.
|
// host-side gate checks before anything reaches the container.
|
||||||
src={status.url ?? undefined}
|
src={status.url ?? undefined}
|
||||||
title={`Playwright browser view for ${project.name}`}
|
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)]"
|
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
|
||||||
/>
|
/>
|
||||||
) : live ? (
|
) : live ? (
|
||||||
@@ -413,6 +527,14 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{askPage && (
|
||||||
|
<OpenPageDialog
|
||||||
|
busy={openingPage}
|
||||||
|
onOpen={openPage}
|
||||||
|
onClose={() => setAskPage(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -422,6 +544,47 @@ function isUsable(d: PlaywrightDetection | null): boolean {
|
|||||||
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
|
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors Rust `PlaywrightDetection::revision_skew`.
|
||||||
|
*
|
||||||
|
* Browsers are installed, but not the revision one of the two Playwright copies
|
||||||
|
* would launch — so the cache looks full and launches fail. A probe that didn't
|
||||||
|
* answer leaves the executable null, and "unknown" must not read as "broken".
|
||||||
|
*/
|
||||||
|
function revisionSkew(d: PlaywrightDetection | null): boolean {
|
||||||
|
if (!d || d.browsers.length === 0) return false;
|
||||||
|
// `!= null`, not `!== null`: a probe from a container that predates these
|
||||||
|
// fields omits them entirely, and `undefined` is "didn't answer" — which must
|
||||||
|
// never render as "your browsers are wrong".
|
||||||
|
const viewerBroken = d.chromium_executable != null && !d.chromium_executable_exists;
|
||||||
|
const scriptsBroken =
|
||||||
|
d.script_chromium_executable != null && !d.script_chromium_executable_exists;
|
||||||
|
return viewerBroken || scriptsBroken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The skew sentence, naming both halves.
|
||||||
|
*
|
||||||
|
* "Install a browser" over a cache that visibly already holds one reads as
|
||||||
|
* nonsense, so the copy has to say which copy of Playwright wants what.
|
||||||
|
*/
|
||||||
|
function skewText(d: PlaywrightDetection | null): string {
|
||||||
|
if (!d) return "";
|
||||||
|
const scriptsBroken =
|
||||||
|
d.script_chromium_executable !== null && !d.script_chromium_executable_exists;
|
||||||
|
const [version, wanted] = scriptsBroken
|
||||||
|
? [d.script_playwright_version, d.script_chromium_executable]
|
||||||
|
: [d.playwright_version, d.chromium_executable];
|
||||||
|
return (
|
||||||
|
`This container has ${d.browsers.join(", ")}, but ` +
|
||||||
|
`${scriptsBroken ? 'the Playwright a script gets from require("playwright")' : "the Playwright serving the viewer"}` +
|
||||||
|
` — ${version ?? "?"} — launches ${wanted ?? "?"}, which isn’t there. ` +
|
||||||
|
(scriptsBroken
|
||||||
|
? "Two copies ended up in one tree, each pinning its own browser revision, so the viewer works and every script Claude writes fails. Re-run “Set up Playwright” to reinstall them as one consistent set."
|
||||||
|
: "Install Chromium below: it runs that build’s own installer, so it fetches exactly the revision that is missing.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** What the container is short of, as a list rather than as prose. */
|
/** What the container is short of, as a list rather than as prose. */
|
||||||
function missingParts(d: PlaywrightDetection | null): string[] {
|
function missingParts(d: PlaywrightDetection | null): string[] {
|
||||||
if (!d) return [];
|
if (!d) return [];
|
||||||
@@ -469,6 +632,10 @@ function Setup({
|
|||||||
const browsers = detection?.browsers ?? [];
|
const browsers = detection?.browsers ?? [];
|
||||||
const chrome = detection?.chrome_channel ?? null;
|
const chrome = detection?.chrome_channel ?? null;
|
||||||
const noBrowser = browsers.length === 0 && chrome === null;
|
const noBrowser = browsers.length === 0 && chrome === null;
|
||||||
|
// Installed browsers that cannot be launched. Handled apart from `noBrowser`
|
||||||
|
// because the fix is the same button but the sentence must not be "install a
|
||||||
|
// browser" over a cache that visibly has one.
|
||||||
|
const skew = revisionSkew(detection) && chrome === null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-[46rem] space-y-4">
|
<div className="p-4 max-w-[46rem] space-y-4">
|
||||||
@@ -476,6 +643,8 @@ function Setup({
|
|||||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||||
{!havePackages
|
{!havePackages
|
||||||
? "This container can’t serve a browser view yet"
|
? "This container can’t serve a browser view yet"
|
||||||
|
: skew
|
||||||
|
? "The installed browser isn’t the one Playwright launches"
|
||||||
: noBrowser
|
: noBrowser
|
||||||
? "Playwright is ready — but there’s no browser to drive yet"
|
? "Playwright is ready — but there’s no browser to drive yet"
|
||||||
: "This container is set up"}
|
: "This container is set up"}
|
||||||
@@ -484,6 +653,8 @@ function Setup({
|
|||||||
{message ??
|
{message ??
|
||||||
(missing.length > 0
|
(missing.length > 0
|
||||||
? `Missing: ${missing.join(", ")}.`
|
? `Missing: ${missing.join(", ")}.`
|
||||||
|
: skew
|
||||||
|
? skewText(detection)
|
||||||
: noBrowser
|
: noBrowser
|
||||||
? "Playwright and the viewer are installed. Install a browser below so there is something to watch."
|
? "Playwright and the viewer are installed. Install a browser below so there is something to watch."
|
||||||
: "Start the view from the button above once Claude has a browser open.")}
|
: "Start the view from the button above once Claude has a browser open.")}
|
||||||
|
|||||||
@@ -127,6 +127,46 @@ describe("ContainerMigrationBanner", () => {
|
|||||||
expect(container).toBeEmptyDOMElement();
|
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", () => {
|
it("disables the action and explains why while the container is running", () => {
|
||||||
renderBanner(migration({ staleness: STALE }), false);
|
renderBanner(migration({ staleness: STALE }), false);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -131,7 +131,15 @@ export default function ContainerMigrationBanner({
|
|||||||
const probeFoundGaps =
|
const probeFoundGaps =
|
||||||
!staleness.known &&
|
!staleness.known &&
|
||||||
(staleness.missing_features.length > 0 || staleness.missing_paths.length > 0);
|
(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 snapshot = formatSnapshotDate(staleness.snapshot_created_at);
|
||||||
const features = joinFeatures(staleness.missing_features);
|
const features = joinFeatures(staleness.missing_features);
|
||||||
@@ -139,15 +147,24 @@ export default function ContainerMigrationBanner({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
|
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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<StatusIndicator
|
<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={
|
label={
|
||||||
staleness.known
|
staleness.known
|
||||||
? "Container base is out of date"
|
? "Container base is out of date"
|
||||||
|
: probeUnavailable
|
||||||
|
? "Container base could not be checked"
|
||||||
: "Container is missing things the current base ships"
|
: "Container is missing things the current base ships"
|
||||||
}
|
}
|
||||||
className="text-[13px] font-semibold"
|
className="text-[13px] font-semibold"
|
||||||
@@ -158,6 +175,8 @@ export default function ContainerMigrationBanner({
|
|||||||
? snapshot
|
? snapshot
|
||||||
? `Running on a saved image from ${snapshot}.`
|
? `Running on a saved image from ${snapshot}.`
|
||||||
: "Running on a saved image older than the current base."
|
: "Running on a saved image older than the current base."
|
||||||
|
: 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."}
|
: "This container predates base-image tracking, so it was probed directly."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { Project } from "../../../lib/types";
|
import type { FileEntry, Project } from "../../../lib/types";
|
||||||
import { useFileManager } from "../../../hooks/useFileManager";
|
import { useFileManager } from "../../../hooks/useFileManager";
|
||||||
import Button from "../../ui/Button";
|
import Button from "../../ui/Button";
|
||||||
|
import FileViewerModal from "./FileViewerModal";
|
||||||
import { formatBytes } from "./format";
|
import { formatBytes } from "./format";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
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) {
|
export default function FilesTab({ project }: Props) {
|
||||||
const {
|
const {
|
||||||
currentPath,
|
currentPath,
|
||||||
entries,
|
entries,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
|
completed,
|
||||||
navigate,
|
navigate,
|
||||||
goUp,
|
goUp,
|
||||||
refresh,
|
refresh,
|
||||||
downloadFile,
|
renameEntry,
|
||||||
uploadFile,
|
createFolder,
|
||||||
|
uploadFiles,
|
||||||
|
saveToHost,
|
||||||
|
uploading,
|
||||||
|
savingPaths,
|
||||||
} = useFileManager(project.id);
|
} = useFileManager(project.id);
|
||||||
|
|
||||||
const running = project.status === "running";
|
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(() => {
|
useEffect(() => {
|
||||||
if (running) navigate("/workspace");
|
if (running) navigate("/workspace");
|
||||||
// Re-list when the container comes up.
|
// Re-list when the container comes up.
|
||||||
}, [navigate, running]);
|
}, [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 =
|
const breadcrumbs =
|
||||||
currentPath === "/"
|
currentPath === "/"
|
||||||
? [{ label: "/", path: "/" }]
|
? [{ 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 (
|
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">
|
<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">
|
<nav aria-label="Path" className="flex items-center gap-1">
|
||||||
{breadcrumbs.map((crumb, i) => (
|
{breadcrumbs.map((crumb, i) => (
|
||||||
@@ -64,7 +299,10 @@ export default function FilesTab({ project }: Props) {
|
|||||||
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap font-mono"
|
||||||
>
|
>
|
||||||
{crumb.label}
|
{crumb.label}
|
||||||
@@ -73,13 +311,38 @@ export default function FilesTab({ project }: Props) {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="flex-1" />
|
<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">
|
<Button onClick={refresh} disabled={loading} className="ml-1">
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<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 && (
|
{error && (
|
||||||
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
|
||||||
{error}
|
{error}
|
||||||
@@ -91,26 +354,128 @@ export default function FilesTab({ project }: Props) {
|
|||||||
Loading…
|
Loading…
|
||||||
</div>
|
</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>
|
<tbody>
|
||||||
{currentPath !== "/" && (
|
{creatingFolder && (
|
||||||
<tr
|
<tr role="row">
|
||||||
onClick={goUp}
|
<td role="gridcell" className="px-4 py-1.5" colSpan={4}>
|
||||||
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
<input
|
||||||
>
|
ref={folderInputRef}
|
||||||
<td className="px-4 py-1.5 text-[var(--text-primary)] font-mono">..</td>
|
value={folderDraft}
|
||||||
<td colSpan={3} />
|
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>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{entries.map((entry) => (
|
{currentPath !== "/" && (
|
||||||
|
<tr
|
||||||
|
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 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
|
<tr
|
||||||
key={entry.name}
|
key={entry.name}
|
||||||
onClick={() => entry.is_directory && navigate(entry.path)}
|
role="row"
|
||||||
className={`${
|
data-file-row={entry.name}
|
||||||
entry.is_directory ? "cursor-pointer" : ""
|
tabIndex={active === entry.name ? 0 : -1}
|
||||||
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
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 className="px-4 py-1.5">
|
<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
|
<span
|
||||||
className={`font-mono ${
|
className={`font-mono ${
|
||||||
entry.is_directory
|
entry.is_directory
|
||||||
@@ -118,34 +483,89 @@ export default function FilesTab({ project }: Props) {
|
|||||||
: "text-[var(--text-primary)]"
|
: "text-[var(--text-primary)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{entry.is_directory ? "📁 " : ""}
|
{/* Directory-ness was carried by hue and an
|
||||||
{entry.name}
|
`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>
|
</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>
|
||||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap tabular-nums">
|
<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)}
|
{!entry.is_directory && formatBytes(entry.size)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
<td role="gridcell" className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||||||
{entry.modified}
|
{entry.modified}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-right">
|
<td role="gridcell" className="px-2 py-1.5 text-right whitespace-nowrap">
|
||||||
{!entry.is_directory && (
|
{!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
|
<Button
|
||||||
aria-label={`Download ${entry.name}`}
|
aria-label={`Rename — ${entry.name}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
downloadFile(entry);
|
startRename(entry);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Download
|
Rename
|
||||||
</Button>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{entries.length === 0 && !loading && (
|
{entries.length === 0 && !loading && (
|
||||||
<tr>
|
<tr role="row">
|
||||||
<td
|
<td
|
||||||
|
role="gridcell"
|
||||||
colSpan={4}
|
colSpan={4}
|
||||||
className="px-4 py-8 text-center text-[var(--text-secondary)]"
|
className="px-4 py-8 text-center text-[var(--text-secondary)]"
|
||||||
>
|
>
|
||||||
@@ -157,6 +577,14 @@ export default function FilesTab({ project }: Props) {
|
|||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{viewing && (
|
||||||
|
<FileViewerModal
|
||||||
|
projectId={project.id}
|
||||||
|
entry={viewing}
|
||||||
|
onClose={() => setViewing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Modal from "../../ui/Modal";
|
||||||
|
import Button from "../../ui/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewport presets. These are the *page's* resolution, not the window's — the
|
||||||
|
* pane is a screencast, so a bigger window shows the same pixels drawn larger
|
||||||
|
* while this is what actually reflows the layout.
|
||||||
|
*/
|
||||||
|
const PRESETS: { label: string; width: number; height: number }[] = [
|
||||||
|
{ label: "1280 × 720", width: 1280, height: 720 },
|
||||||
|
{ label: "1920 × 1080", width: 1920, height: 1080 },
|
||||||
|
{ label: "1440 × 900", width: 1440, height: 900 },
|
||||||
|
{ label: "390 × 844 (phone)", width: 390, height: 844 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Prefilled URL — an auth URL from the terminal, or the last one used. */
|
||||||
|
initialUrl?: string;
|
||||||
|
initialWidth?: number;
|
||||||
|
initialHeight?: number;
|
||||||
|
busy?: boolean;
|
||||||
|
onOpen: (url: string, width: number, height: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for a URL and a viewport, then open it in the container's browser.
|
||||||
|
*
|
||||||
|
* Deliberately modal and short-lived — the convention for a task with one
|
||||||
|
* question and one button. The URL is not opened here; the caller runs the
|
||||||
|
* command so failures land in its toast.
|
||||||
|
*/
|
||||||
|
export default function OpenPageDialog({
|
||||||
|
initialUrl = "",
|
||||||
|
initialWidth = 1280,
|
||||||
|
initialHeight = 720,
|
||||||
|
busy = false,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const [url, setUrl] = useState(initialUrl);
|
||||||
|
const [width, setWidth] = useState(initialWidth);
|
||||||
|
const [height, setHeight] = useState(initialHeight);
|
||||||
|
|
||||||
|
const trimmed = url.trim();
|
||||||
|
// Mirrors the backend's allow-list, so the error arrives before the click
|
||||||
|
// rather than after a round trip.
|
||||||
|
const valid = /^https?:\/\/\S+$/i.test(trimmed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Open a page in the container's browser"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button size="md" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!valid || busy}
|
||||||
|
onClick={() => onOpen(trimmed, width, height)}
|
||||||
|
>
|
||||||
|
{busy ? "Opening…" : "Open page"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
Launches a browser <em>inside</em> this container and publishes it to the
|
||||||
|
Browser tab. Use it for a sign-in page — the callback listener is in the
|
||||||
|
container too, so the login completes without involving your host browser —
|
||||||
|
or for a dev server on container loopback.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">URL</span>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && valid && !busy) onOpen(trimmed, width, height);
|
||||||
|
}}
|
||||||
|
placeholder="http://localhost:5173"
|
||||||
|
spellCheck={false}
|
||||||
|
className="mt-1 w-full px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] font-mono text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
{trimmed !== "" && !valid && (
|
||||||
|
<span className="mt-1 block text-xs text-[var(--error)]">
|
||||||
|
Only http:// and https:// URLs can be opened.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">Viewport</span>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{PRESETS.map((p) => {
|
||||||
|
const active = p.width === width && p.height === height;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.label}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => {
|
||||||
|
setWidth(p.width);
|
||||||
|
setHeight(p.height);
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 text-xs rounded-[var(--radius-control)] border transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-[var(--accent)] bg-[var(--accent-muted)] text-[var(--accent)]"
|
||||||
|
: "border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
aria-label="Viewport width"
|
||||||
|
value={width}
|
||||||
|
min={200}
|
||||||
|
onChange={(e) => setWidth(Number(e.target.value))}
|
||||||
|
className="w-24 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<span aria-hidden="true" className="text-xs text-[var(--text-secondary)]">×</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
aria-label="Viewport height"
|
||||||
|
value={height}
|
||||||
|
min={200}
|
||||||
|
onChange={(e) => setHeight(Number(e.target.value))}
|
||||||
|
className="w-24 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">CSS pixels</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -120,6 +120,15 @@ export default function OverviewTab({
|
|||||||
{project.mission_control_enabled ? "ON" : "OFF"}
|
{project.mission_control_enabled ? "ON" : "OFF"}
|
||||||
</span>
|
</span>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpenTab("config")}
|
onClick={() => onOpenTab("config")}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { projectRemovalIsClean } from "../../../lib/types";
|
||||||
import { useAppState } from "../../../store/appState";
|
import { useAppState } from "../../../store/appState";
|
||||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||||
import { useProjects } from "../../../hooks/useProjects";
|
import { useProjects } from "../../../hooks/useProjects";
|
||||||
@@ -18,6 +19,7 @@ import ConfigTab from "./ConfigTab";
|
|||||||
import FilesTab from "./FilesTab";
|
import FilesTab from "./FilesTab";
|
||||||
import BrowserTab from "./BrowserTab";
|
import BrowserTab from "./BrowserTab";
|
||||||
import { formatUptime } from "./format";
|
import { formatUptime } from "./format";
|
||||||
|
import { describeLeftovers, leftoverPronoun, leftoverVerb } from "./removalReport";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: "overview", label: "Overview" },
|
{ id: "overview", label: "Overview" },
|
||||||
@@ -43,6 +45,16 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
const { projects, remove } = useProjects();
|
const { projects, remove } = useProjects();
|
||||||
const project = projects.find((p) => p.id === projectId);
|
const project = projects.find((p) => p.id === projectId);
|
||||||
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
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 [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
const [confirmReset, setConfirmReset] = useState(false);
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
const [showMigration, setShowMigration] = useState(false);
|
const [showMigration, setShowMigration] = useState(false);
|
||||||
@@ -272,7 +284,25 @@ export default function ProjectHome({ projectId, active }: Props) {
|
|||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
setConfirmRemove(false);
|
setConfirmRemove(false);
|
||||||
try {
|
try {
|
||||||
await remove(project.id);
|
const report = await remove(project.id);
|
||||||
|
if (!projectRemovalIsClean(report)) {
|
||||||
|
const verb = leftoverVerb(report);
|
||||||
|
if (report.retry_scheduled) {
|
||||||
|
useAppState.getState().pushToast({
|
||||||
|
kind: "info",
|
||||||
|
message: `“${project.name}” was removed, but Triple-C could not confirm all its Docker resources were removed`,
|
||||||
|
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed. It will check again the next time it starts.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// The pending-cleanup record itself failed to save — no
|
||||||
|
// retry will happen, so this must not promise one.
|
||||||
|
useAppState.getState().pushToast({
|
||||||
|
kind: "error",
|
||||||
|
message: `“${project.name}” was removed, but Triple-C could not confirm its Docker resources were removed`,
|
||||||
|
detail: `Triple-C could not confirm ${describeLeftovers(report)} ${verb} removed, and could not record this for a retry. You may need to remove ${leftoverPronoun(report)} manually (\`docker rm\` / \`docker rmi\` / \`docker volume rm\`).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
useAppState.getState().pushToast({
|
useAppState.getState().pushToast({
|
||||||
kind: "error",
|
kind: "error",
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const existingTask: ScheduledTask = {
|
|||||||
created_at: null,
|
created_at: null,
|
||||||
last_run: null,
|
last_run: null,
|
||||||
next_run: null,
|
next_run: null,
|
||||||
|
running: false,
|
||||||
|
running_since: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function renderEditor(task: ScheduledTask | null = null, project = baseProject) {
|
async function renderEditor(task: ScheduledTask | null = null, project = baseProject) {
|
||||||
|
|||||||
@@ -311,10 +311,22 @@ export default function TaskEditorModal({ project, task, onClose, onSaved }: Pro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task && (
|
{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)]">
|
<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
|
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
|
removes <code className="font-mono">{task.id}</code>. Assume its earlier run logs go
|
||||||
the old id.
|
with it.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSecretField } from "../../../../hooks/useSecretField";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { Project } from "../../../../lib/types";
|
import type { Project } from "../../../../lib/types";
|
||||||
import Button from "../../../ui/Button";
|
import Button from "../../../ui/Button";
|
||||||
@@ -24,14 +25,15 @@ export default function AccessSection({
|
|||||||
const [caCertPath, setCaCertPath] = useState(project.ca_cert_path ?? "");
|
const [caCertPath, setCaCertPath] = useState(project.ca_cert_path ?? "");
|
||||||
const [gitName, setGitName] = useState(project.git_user_name ?? "");
|
const [gitName, setGitName] = useState(project.git_user_name ?? "");
|
||||||
const [gitEmail, setGitEmail] = useState(project.git_user_email ?? "");
|
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(() => {
|
useEffect(() => {
|
||||||
setSshKeyPath(project.ssh_key_path ?? "");
|
setSshKeyPath(project.ssh_key_path ?? "");
|
||||||
setCaCertPath(project.ca_cert_path ?? "");
|
setCaCertPath(project.ca_cert_path ?? "");
|
||||||
setGitName(project.git_user_name ?? "");
|
setGitName(project.git_user_name ?? "");
|
||||||
setGitEmail(project.git_user_email ?? "");
|
setGitEmail(project.git_user_email ?? "");
|
||||||
setGitToken(project.git_token ?? "");
|
|
||||||
}, [project]);
|
}, [project]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,15 +103,19 @@ export default function AccessSection({
|
|||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Git HTTPS token"
|
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) => (
|
{(id) => (
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="password"
|
type="password"
|
||||||
value={gitToken}
|
value={gitToken.value}
|
||||||
onChange={(e) => setGitToken(e.target.value)}
|
onChange={(e) => gitToken.setValue(e.target.value)}
|
||||||
onBlur={() => save({ git_token: gitToken || null })}
|
onBlur={() => save({ ...gitToken.patch("git_token") })}
|
||||||
placeholder="ghp_…"
|
placeholder="ghp_…"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={inputClass}
|
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 { useEffect, useState } from "react";
|
||||||
|
import { useSecretField, withoutUntouchedSecrets } from "../../../../hooks/useSecretField";
|
||||||
import type {
|
import type {
|
||||||
Backend,
|
Backend,
|
||||||
BedrockAuthMethod,
|
BedrockAuthMethod,
|
||||||
@@ -16,6 +17,17 @@ import Field, {
|
|||||||
} from "../../../ui/Field";
|
} from "../../../ui/Field";
|
||||||
import Toggle from "../../../ui/Toggle";
|
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 = {
|
export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = {
|
||||||
auth_method: "static_credentials",
|
auth_method: "static_credentials",
|
||||||
aws_region: "us-east-1",
|
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.
|
// Local text state — saved on blur, not on every keystroke.
|
||||||
const [bedrockRegion, setBedrockRegion] = useState(bedrock.aws_region);
|
const [bedrockRegion, setBedrockRegion] = useState(bedrock.aws_region);
|
||||||
const [accessKeyId, setAccessKeyId] = useState(bedrock.aws_access_key_id ?? "");
|
// Secrets are never seeded from `project` — see `useSecretField`.
|
||||||
const [secretKey, setSecretKey] = useState(bedrock.aws_secret_access_key ?? "");
|
const accessKeyId = useSecretField(project.id);
|
||||||
const [sessionToken, setSessionToken] = useState(bedrock.aws_session_token ?? "");
|
const secretKey = useSecretField(project.id);
|
||||||
|
const sessionToken = useSecretField(project.id);
|
||||||
const [profile, setProfile] = useState(bedrock.aws_profile ?? "");
|
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 [bedrockModelId, setBedrockModelId] = useState(bedrock.model_id ?? "");
|
||||||
const [serviceTier, setServiceTier] = useState(bedrock.service_tier ?? "");
|
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 ??
|
project.openai_compatible_config?.base_url ??
|
||||||
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
DEFAULT_OPENAI_COMPATIBLE_CONFIG.base_url,
|
||||||
);
|
);
|
||||||
const [oaiApiKey, setOaiApiKey] = useState(
|
const oaiApiKey = useSecretField(project.id);
|
||||||
project.openai_compatible_config?.api_key ?? "",
|
|
||||||
);
|
|
||||||
const [oaiModelId, setOaiModelId] = useState(
|
const [oaiModelId, setOaiModelId] = useState(
|
||||||
project.openai_compatible_config?.model_id ?? "",
|
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 ?? "",
|
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(() => {
|
useEffect(() => {
|
||||||
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
const bc = project.bedrock_config ?? DEFAULT_BEDROCK_CONFIG;
|
||||||
setBedrockRegion(bc.aws_region);
|
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 ?? "");
|
setProfile(bc.aws_profile ?? "");
|
||||||
setBearerToken(bc.aws_bearer_token ?? "");
|
|
||||||
setBedrockModelId(bc.model_id ?? "");
|
setBedrockModelId(bc.model_id ?? "");
|
||||||
setServiceTier(bc.service_tier ?? "");
|
setServiceTier(bc.service_tier ?? "");
|
||||||
setOllamaBaseUrl(project.ollama_config?.base_url ?? DEFAULT_OLLAMA_CONFIG.base_url);
|
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 ??
|
project.openai_compatible_config?.base_url ??
|
||||||
DEFAULT_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 ?? "");
|
setOaiModelId(project.openai_compatible_config?.model_id ?? "");
|
||||||
setOaiHaikuModelId(project.openai_compatible_config?.haiku_model_id ?? "");
|
setOaiHaikuModelId(project.openai_compatible_config?.haiku_model_id ?? "");
|
||||||
}, [project]);
|
}, [project]);
|
||||||
|
|
||||||
const saveBedrock = (patch: Partial<BedrockConfig>) =>
|
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>) =>
|
const saveOllama = (patch: Partial<OllamaConfig>) =>
|
||||||
save({
|
save({
|
||||||
@@ -152,10 +167,14 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
|
|
||||||
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
|
const saveOpenAi = (patch: Partial<OpenAiCompatibleConfig>) =>
|
||||||
save({
|
save({
|
||||||
openai_compatible_config: {
|
openai_compatible_config: withoutUntouchedSecrets(
|
||||||
|
{
|
||||||
...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG),
|
...(project.openai_compatible_config ?? DEFAULT_OPENAI_COMPATIBLE_CONFIG),
|
||||||
...patch,
|
...patch,
|
||||||
},
|
},
|
||||||
|
patch,
|
||||||
|
OPENAI_SECRET_KEYS,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Defaults to on: projects created before the field existed, and any data
|
// 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) => (
|
{(id) => (
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
value={accessKeyId}
|
value={accessKeyId.value}
|
||||||
onChange={(e) => setAccessKeyId(e.target.value)}
|
onChange={(e) => accessKeyId.setValue(e.target.value)}
|
||||||
onBlur={() => saveBedrock({ aws_access_key_id: accessKeyId || null })}
|
onBlur={() => saveBedrock(accessKeyId.patch("aws_access_key_id"))}
|
||||||
placeholder="AKIA…"
|
placeholder="AKIA…"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={monoInputClass}
|
className={monoInputClass}
|
||||||
@@ -278,10 +297,10 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="password"
|
type="password"
|
||||||
value={secretKey}
|
value={secretKey.value}
|
||||||
onChange={(e) => setSecretKey(e.target.value)}
|
onChange={(e) => secretKey.setValue(e.target.value)}
|
||||||
onBlur={() =>
|
onBlur={() =>
|
||||||
saveBedrock({ aws_secret_access_key: secretKey || null })
|
saveBedrock(secretKey.patch("aws_secret_access_key"))
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={monoInputClass}
|
className={monoInputClass}
|
||||||
@@ -296,10 +315,10 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="password"
|
type="password"
|
||||||
value={sessionToken}
|
value={sessionToken.value}
|
||||||
onChange={(e) => setSessionToken(e.target.value)}
|
onChange={(e) => sessionToken.setValue(e.target.value)}
|
||||||
onBlur={() =>
|
onBlur={() =>
|
||||||
saveBedrock({ aws_session_token: sessionToken || null })
|
saveBedrock(sessionToken.patch("aws_session_token"))
|
||||||
}
|
}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={monoInputClass}
|
className={monoInputClass}
|
||||||
@@ -337,9 +356,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="password"
|
type="password"
|
||||||
value={bearerToken}
|
value={bearerToken.value}
|
||||||
onChange={(e) => setBearerToken(e.target.value)}
|
onChange={(e) => bearerToken.setValue(e.target.value)}
|
||||||
onBlur={() => saveBedrock({ aws_bearer_token: bearerToken || null })}
|
onBlur={() => saveBedrock(bearerToken.patch("aws_bearer_token"))}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={monoInputClass}
|
className={monoInputClass}
|
||||||
/>
|
/>
|
||||||
@@ -507,9 +526,9 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="password"
|
type="password"
|
||||||
value={oaiApiKey}
|
value={oaiApiKey.value}
|
||||||
onChange={(e) => setOaiApiKey(e.target.value)}
|
onChange={(e) => oaiApiKey.setValue(e.target.value)}
|
||||||
onBlur={() => saveOpenAi({ api_key: oaiApiKey || null })}
|
onBlur={() => saveOpenAi(oaiApiKey.patch("api_key"))}
|
||||||
placeholder="sk-…"
|
placeholder="sk-…"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={monoInputClass}
|
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 PermissionModeControl, { permissionModePatch } from "../../PermissionModeControl";
|
||||||
import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor";
|
import ClaudeInstructionsEditor from "../../ClaudeInstructionsEditor";
|
||||||
import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor";
|
import ClaudeCodeSettingsEditor from "../../ClaudeCodeSettingsEditor";
|
||||||
|
import AuthBridgeRow from "./AuthBridgeRow";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
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
|
<SwitchRow
|
||||||
label="Mission Control"
|
label="Mission Control"
|
||||||
hint="A web dashboard for monitoring and managing Claude sessions remotely."
|
hint="A web dashboard for monitoring and managing Claude sessions remotely."
|
||||||
@@ -89,9 +109,19 @@ export default function RuntimeSection({
|
|||||||
|
|
||||||
<ConfigGroup
|
<ConfigGroup
|
||||||
title="Claude Code settings"
|
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
|
<ClaudeCodeSettingsEditor
|
||||||
|
scope="project"
|
||||||
settings={project.claude_code_settings}
|
settings={project.claude_code_settings}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
disabledReason={disabledReason}
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||